diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 000000000..8e037e92d --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# Formatting-only commits that `git blame` skips (GitHub reads this file; locally: git config blame.ignoreRevsFile .git-blame-ignore-revs). + +# style: line width 150 with shallow objects collapsed (#1229) +25d183326355a1de182eb15c775b61833d1f681d diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e54cbf4e..567350e5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,8 +74,8 @@ jobs: - name: Run Biome run: pnpm biome check . - - name: Run style checks (terminology / docs / comments) - run: pnpm prose:check + - name: Run style checks (terminology / docs / comments / frontend) + run: pnpm style - name: Run lens checks (append-only / collision / purity / wire completeness) run: pnpm --filter shared lens:check @@ -288,12 +288,9 @@ jobs: if: env.SCW_ACCESS_KEY != '' run: pnpm --filter infra preflight --mode production --login - # Storybook component tests (Vitest browser mode via Playwright). Heavy: runs on the release PR only. + # Storybook component tests (Vitest browser mode via Playwright). Heavy: runs on the release PR only, so a + # story that breaks on a feature PR fails the release PR; run `pnpm test:storybook` before merging frontend work. storybook-test: - # Same-repo branch only: release-please pushes its branch into this repo, so a - # PR from another repository (whose branch name its author fully controls) - # cannot spoof the prefix to trigger the heavy suites. head_ref is set only for - # pull_request events. if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'release-please--branches--main') runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/backend/README.md b/backend/README.md index d5255c7cb..658fea7ec 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,12 +2,24 @@ Hono API server on PostgreSQL (Drizzle ORM): authentication, entity CRUD, file uploads, real-time sync, and all server-side business logic. Runs as part of `pnpm dev`. +## Dependencies + +tsup bundles the backend into `dist/`, and the production image installs only `dependencies` (`pnpm install --prod`). So +`dependencies` lists the packages the bundle loads from disk ([keep-on-disk.ts](../shared/src/keep-on-disk.ts)), and every +other package is a devDependency, runtime libraries included. The build throws on a `dependencies` entry it would inline. +`pnpm deps:unused` (knip) reports unused and unlisted packages across the workspaces. + ## Query module conventions Database access for a module lives in `-queries.ts`. Operations and handlers assemble authorization and request filters, then call these query functions. Business rules, HTTP response shaping, notifications, and cache invalidation stay outside query modules. +A module's `helpers/` holds database-free code only: key formats, crypto, cookies, URL or HTML +building, column selections. A function that reads or writes is a query. One that combines queries +with rules, permission checks or side effects is an operation, also when only other operations call +it. + When adding or changing a query: - Accept `ctx` as the first argument and read database and scope values from `ctx.var` inside the diff --git a/backend/drizzle/20261002100738_bindings_version_drop_mentions/migration.sql b/backend/drizzle/20261002100738_bindings_version_drop_mentions/migration.sql new file mode 100644 index 000000000..0ae717ea3 --- /dev/null +++ b/backend/drizzle/20261002100738_bindings_version_drop_mentions/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "actors" ADD COLUMN "bindings_version" uuid DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint +ALTER TABLE "attachments" DROP COLUMN "mentions";--> statement-breakpoint +ALTER TABLE "tasks" DROP COLUMN "mentions"; \ No newline at end of file diff --git a/backend/drizzle/20261002100738_bindings_version_drop_mentions/snapshot.json b/backend/drizzle/20261002100738_bindings_version_drop_mentions/snapshot.json new file mode 100644 index 000000000..afb4d4b04 --- /dev/null +++ b/backend/drizzle/20261002100738_bindings_version_drop_mentions/snapshot.json @@ -0,0 +1,10665 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "869b6897-4e4f-451f-b792-55d5cc44961b", + "prevIds": [ + "5fc51ebc-6899-400b-b7fa-72ce65e36c7b" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "attachments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "identities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rate_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "totps", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "domains", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "channel_counters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "product_counters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "labels", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "inactive_memberships", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "memberships", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notification_preferences", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oauth_clients", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oidc_payloads", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "signing_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "organizations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "projects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "push_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "requests", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "seen_by", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "api_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "service_accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "system_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "tasks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tenants", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "user_counters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "workspaces", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "yjs_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "yjs_updates", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "table_name", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changed_fields", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "bindings_version", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'attachment'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'New attachment'", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "keywords", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_by", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "public_bucket", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bucket_name", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "filename", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content_type", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "converted_content_type", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "keys", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id_hash", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notified_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'oauth'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuer", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "verified", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connection_id", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used_at", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge_hash", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "purpose", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'desktop'", + "generated": null, + "identity": null, + "name": "device_type", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_os", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "browser", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name_on_device", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rate_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "points", + "entityType": "columns", + "schema": "public", + "table": "rate_limits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expire", + "entityType": "columns", + "schema": "public", + "table": "rate_limits" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'regular'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'desktop'", + "generated": null, + "identity": null, + "name": "device_type", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_os", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "browser", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_strategy", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_hash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_subnet_hash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(2)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_country", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_asn", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id_hash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_by", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revocation_reason", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "impersonator_session_id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stepped_up_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stepped_up_via", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "single_use_token", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identity_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inactive_membership_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_path", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pending_sign_up", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "invoked_at", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used_step", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "domain", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "verified", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_token", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_checked_at", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_key", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "counts", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_id", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_type", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_viewed_at", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'label'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'New label'", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "keywords", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_by", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'secondary'", + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "organization_tracked", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_type", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'member'", + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rejected_at", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reminded_at", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workspace_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_type", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'member'", + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "archived", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "muted", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workspace_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "mention_email", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "comment_email", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'weekly'", + "generated": null, + "identity": null, + "name": "digest", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_digest_at", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "context_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "read_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailed_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digested_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_hash", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "redirect_uris", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logo_uri", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "grant_id", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "uid", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed_at", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'RS256'", + "generated": null, + "identity": null, + "name": "alg", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_jwk", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_jwk", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retired_at", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'organization'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tools_config", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"id\"::text", + "type": "stored" + }, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "short_name", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "country", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "default_language", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[\"en\"]'", + "generated": null, + "identity": null, + "name": "languages", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notification_email", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logo_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "website_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "welcome_text", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "chat_support", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "organization_flags", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "setup_config", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'project'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tools_config", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"organization_id\"::text || '/' || \"id\"::text", + "type": "stored" + }, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "p256dh", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiration_time", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_id", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_type", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last4", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_by", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "bindings", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "oauth_client_id", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'task'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'New task'", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "keywords", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_by", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "expandable", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "summary_length", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primary_label_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "status_changed_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "labels", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assigned_to", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "checkbox_count", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "checked_count", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "attachments", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{\"quotas\":{\"user\":1000,\"organization\":1,\"workspace\":0,\"project\":0,\"task\":0,\"label\":0,\"attachment\":100,\"serviceAccount\":20,\"apiKey\":100},\"rateLimits\":{\"apiPointsPerHour\":1000},\"allowUnregisteredClients\":true}'", + "generated": null, + "identity": null, + "name": "restrictions", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "auth_strategies", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_id", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "subscription_status", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_plan", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_data", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "verified", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_verified_via", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_verified_at", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_started_at", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sign_in_at", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "mfa_required", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "user_flags", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'workspace'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tools_config", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"organization_id\"::text || '/' || \"id\"::text", + "type": "stored" + }, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "yjs_updates_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activities_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activities_org_id_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activities_entity_type_subject_id_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_organization_id_seq_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "group_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_group_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_project_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "notified_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_user_id_notified_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_last_seen_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "identities_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issuer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subject", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "identities_kind_issuer_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "challenge_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkey_challenges_challenge_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkeys_credential_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_secret_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ip_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_user_id_ip_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ip_subnet_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_ip_subnet_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "device_id_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_user_id_device_id_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "impersonator_session_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"impersonator_session_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_impersonator_session_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_secret_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "single_use_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_single_use_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "session_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"session_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_session_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "totps_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "totps" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "domains_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "domains" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "domain", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "domains_domain_idx", + "entityType": "indexes", + "schema": "public", + "table": "domains" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"mode\" = 'primary' AND \"deleted_at\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_project_primary_slug_unique", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_organization_id_seq_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_project_id_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "rejected_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_org_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "channel_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "role", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_channel_org_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "workspace_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "archived", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_workspace_user_archived_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "archived", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_project_user_archived_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_org_user_tenant_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "activity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_activity_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "read_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_unread_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_created_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subject_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_subject_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "grant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_grant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_account_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "uid", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_uid_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "signing_keys_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "signing_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" in ('current', 'next')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "signing_keys_one_per_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "signing_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_name_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_name_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "push_subscriptions_user_index", + "entityType": "indexes", + "schema": "public", + "table": "push_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "requests_emails", + "entityType": "indexes", + "schema": "public", + "table": "requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "requests_created_at", + "entityType": "indexes", + "schema": "public", + "table": "requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"email\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"type\" in ('waitlist', 'newsletter')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "requests_unique_signup_email_type", + "entityType": "indexes", + "schema": "public", + "table": "requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "product_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_user_product_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "channel_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "product_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_user_channel_type_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "product_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_product_id_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_actor_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "service_accounts_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_organization_id_seq_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_project_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_project_status_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primary_label_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_primary_label_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "labels", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "idx_tasks_labels_gin", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigned_to", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "idx_tasks_assigned_to_gin", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "attachments", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "idx_tasks_attachments_gin", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_status_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subscription_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_subscription_status_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "emails_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "emails" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_name_index", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_email_index", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_name_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_docs_tenant", + "entityType": "indexes", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_docs_org", + "entityType": "indexes", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_updates_doc", + "entityType": "indexes", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_updates_tenant", + "entityType": "indexes", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "attachments_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "attachments_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "attachments_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "deleted_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "attachments_deleted_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "attachments_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "attachments_Ytb3N4m0pWsH_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "devices_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "oauth_accounts_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "identities" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "passkey_challenges_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "passkeys_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "revoked_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "sessions_revoked_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "impersonator_session_id" + ], + "schemaTo": "public", + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_impersonator_session_id_sessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "identity_id" + ], + "schemaTo": "public", + "tableTo": "identities", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_oauth_account_id_oauth_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "session_id" + ], + "schemaTo": "public", + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_session_id_sessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "totps_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "totps" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "domains_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "domains" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "labels_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "labels_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "labels_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "deleted_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "labels_deleted_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "labels_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "labels_5EdeIYCO2iQy_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "inactive_memberships_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "workspace_id" + ], + "schemaTo": "public", + "tableTo": "workspaces", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_workspace_id_workspaces_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_gmmCA2ACknk4_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "memberships_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "memberships_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "memberships_updated_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "workspace_id" + ], + "schemaTo": "public", + "tableTo": "workspaces", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_workspace_id_workspaces_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_Yta3VHtyCTj4_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_preferences_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notification_preferences" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "oauth_clients_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "oauth_clients" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "organizations_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "organizations_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "organizations_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "projects_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "projects_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "projects_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "projects_UVK9MNjFoIk5_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "push_subscriptions_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "push_subscriptions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "seen_by_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": false, + "columns": [ + "actor_id" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "api_keys_actor_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "api_keys_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "revoked_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "api_keys_revoked_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "api_keys_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "id" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "service_accounts_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "service_accounts_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "oauth_client_id" + ], + "schemaTo": "public", + "tableTo": "oauth_clients", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "service_accounts_oauth_client_id_oauth_clients_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "service_accounts_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "service_accounts_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "system_roles_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "system_roles" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tasks_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tasks_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tasks_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "deleted_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tasks_deleted_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tasks_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tasks_tenant_id_organization_id_organizations_tenant_id_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tenants_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "emails_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "emails" + }, + { + "nameExplicit": false, + "columns": [ + "id" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "users_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "users_updated_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "workspaces_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "workspaces_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "workspaces_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "workspaces_mcfltaO23EEl_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "yjs_documents_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "yjs_documents_HY8MgE0sbYNM_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "yjs_updates_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "yjs_updates_Yt7qMI3dLu4u_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_updates" + }, + { + "columns": [ + "id", + "created_at" + ], + "nameExplicit": false, + "name": "activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "activities" + }, + { + "columns": [ + "user_id", + "device_id_hash" + ], + "nameExplicit": false, + "name": "devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "devices" + }, + { + "columns": [ + "id", + "created_at" + ], + "nameExplicit": false, + "name": "notifications_pkey", + "entityType": "pks", + "schema": "public", + "table": "notifications" + }, + { + "columns": [ + "type", + "id" + ], + "nameExplicit": false, + "name": "oidc_payloads_pkey", + "entityType": "pks", + "schema": "public", + "table": "oidc_payloads" + }, + { + "columns": [ + "id", + "created_at" + ], + "nameExplicit": false, + "name": "seen_by_pkey", + "entityType": "pks", + "schema": "public", + "table": "seen_by" + }, + { + "columns": [ + "entity_type", + "entity_id" + ], + "nameExplicit": false, + "name": "yjs_documents_pkey", + "entityType": "pks", + "schema": "public", + "table": "yjs_documents" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "attachments_pkey", + "schema": "public", + "table": "attachments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "oauth_accounts_pkey", + "schema": "public", + "table": "identities", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "passkey_challenges_pkey", + "schema": "public", + "table": "passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "passkeys_pkey", + "schema": "public", + "table": "passkeys", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rate_limits_pkey", + "schema": "public", + "table": "rate_limits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tokens_pkey", + "schema": "public", + "table": "tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "totps_pkey", + "schema": "public", + "table": "totps", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "domains_pkey", + "schema": "public", + "table": "domains", + "entityType": "pks" + }, + { + "columns": [ + "channel_key" + ], + "nameExplicit": false, + "name": "context_counters_pkey", + "schema": "public", + "table": "channel_counters", + "entityType": "pks" + }, + { + "columns": [ + "product_id" + ], + "nameExplicit": false, + "name": "product_counters_pkey", + "schema": "public", + "table": "product_counters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "labels_pkey", + "schema": "public", + "table": "labels", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "inactive_memberships_pkey", + "schema": "public", + "table": "inactive_memberships", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "memberships_pkey", + "schema": "public", + "table": "memberships", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "notification_preferences_pkey", + "schema": "public", + "table": "notification_preferences", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "oauth_clients_pkey", + "schema": "public", + "table": "oauth_clients", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "signing_keys_pkey", + "schema": "public", + "table": "signing_keys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "organizations_pkey", + "schema": "public", + "table": "organizations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "projects_pkey", + "schema": "public", + "table": "projects", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "push_subscriptions_pkey", + "schema": "public", + "table": "push_subscriptions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "requests_pkey", + "schema": "public", + "table": "requests", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "api_keys_pkey", + "schema": "public", + "table": "api_keys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "service_accounts_pkey", + "schema": "public", + "table": "service_accounts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "system_roles_pkey", + "schema": "public", + "table": "system_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tasks_pkey", + "schema": "public", + "table": "tasks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tenants_pkey", + "schema": "public", + "table": "tenants", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "emails_pkey", + "schema": "public", + "table": "emails", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "user_counters_pkey", + "schema": "public", + "table": "user_counters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspaces_pkey", + "schema": "public", + "table": "workspaces", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "yjs_updates_pkey", + "schema": "public", + "table": "yjs_updates", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "email", + "channel_id" + ], + "nullsNotDistinct": false, + "name": "inactive_memberships_tenant_email_ctx", + "entityType": "uniques", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "user_id", + "channel_id" + ], + "nullsNotDistinct": false, + "name": "memberships_unique_channel", + "entityType": "uniques", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id" + ], + "nullsNotDistinct": false, + "name": "organizations_tenant_id_key", + "entityType": "uniques", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "id" + ], + "nullsNotDistinct": false, + "name": "organizations_tenant_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "id" + ], + "nullsNotDistinct": false, + "name": "projects_tenant_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "id" + ], + "nullsNotDistinct": false, + "name": "workspaces_tenant_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "domain" + ], + "nullsNotDistinct": false, + "name": "domains_domain_key", + "schema": "public", + "table": "domains", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "organizations_slug_key", + "schema": "public", + "table": "organizations", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "projects_slug_key", + "schema": "public", + "table": "projects", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "endpoint" + ], + "nullsNotDistinct": false, + "name": "push_subscriptions_endpoint_key", + "schema": "public", + "table": "push_subscriptions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "nullsNotDistinct": false, + "name": "system_roles_user_id_key", + "schema": "public", + "table": "system_roles", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "emails_email_key", + "schema": "public", + "table": "emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "users_slug_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "workspaces_slug_key", + "schema": "public", + "table": "workspaces", + "entityType": "uniques" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"attachments\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n AND (\"attachments\".\"deleted_at\" IS NULL OR current_setting('app.include_deleted', true) = 'true')\n ", + "withCheck": null, + "name": "attachments_select_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "attachments_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "attachments_update_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "attachments_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"labels\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n AND (\"labels\".\"deleted_at\" IS NULL OR current_setting('app.include_deleted', true) = 'true')\n ", + "withCheck": null, + "name": "labels_select_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "labels_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "labels_update_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "labels_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"tasks\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n AND (\"tasks\".\"deleted_at\" IS NULL OR current_setting('app.include_deleted', true) = 'true')\n ", + "withCheck": null, + "name": "tasks_select_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "tasks_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "tasks_update_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "tasks_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"yjs_documents\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n \n ", + "withCheck": null, + "name": "yjs_documents_select_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "yjs_documents_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_documents_update_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_documents_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"yjs_updates\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n \n ", + "withCheck": null, + "name": "yjs_updates_select_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "yjs_updates_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_updates_update_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_updates_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/drizzle/20261002100739_side_effects/migration.sql b/backend/drizzle/20261002100739_side_effects/migration.sql new file mode 100644 index 000000000..26aeef276 --- /dev/null +++ b/backend/drizzle/20261002100739_side_effects/migration.sql @@ -0,0 +1,1375 @@ +-- ⚠️ AUTO-GENERATED: DO NOT EDIT THIS FILE DIRECTLY +-- This migration is generated by a script in backend/scripts/migrations/. +-- To make changes, edit the generator script and re-run it. +-- The user will handle migration generation and application. +-- +-- Combined side-effect migration. +-- Blocks (in order): cdc_setup, counter_functions, immutability_setup, jobs_grants, membership_rules, partition_setup, rls_setup, unlogged_setup, publicat_cascade, verify_side_effects +-- Regenerate with `pnpm generate`. Every block is idempotent; the whole set re-runs +-- whenever ANY block changes, so this file always reflects the full current side-effect state. + +-- ══════════════════════════════════════════════════════════════════════════ +-- [cdc_setup] CDC, publication, replica identity, replication slot +-- ══════════════════════════════════════════════════════════════════════════ +-- CDC (Change Data Capture) Setup +-- Sets up PostgreSQL logical replication for the activities CDC worker. +-- Requires: wal_level=logical (see compose.yaml) +-- Gracefully skips if logical replication is not available. + +DO $$ +BEGIN + -- Check if pg_publication is available + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_class WHERE relname = 'pg_publication') THEN + RAISE NOTICE 'Logical replication not available - skipping CDC setup.'; + RETURN; + END IF; + + -- 1. Create or update publication + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'cdc_pub') THEN + CREATE PUBLICATION cdc_pub FOR TABLE users, organizations, workspaces, projects, attachments, labels, tasks, requests, memberships, inactive_memberships, tenants, system_roles, service_accounts, api_keys, oauth_clients; + RAISE NOTICE 'Created publication cdc_pub'; + ELSE + -- Publication exists: replace with current table list + RAISE NOTICE 'Publication cdc_pub already exists, syncing tables...'; + ALTER PUBLICATION cdc_pub SET TABLE users, organizations, workspaces, projects, attachments, labels, tasks, requests, memberships, inactive_memberships, tenants, system_roles, service_accounts, api_keys, oauth_clients; + RAISE NOTICE 'Publication tables synced'; + END IF; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'Publication setup failed: % (SQLSTATE: %)', SQLERRM, SQLSTATE; + END; + + -- 2. Set REPLICA IDENTITY FULL (separate block) + BEGIN + ALTER TABLE users REPLICA IDENTITY FULL; + ALTER TABLE organizations REPLICA IDENTITY FULL; + ALTER TABLE workspaces REPLICA IDENTITY FULL; + ALTER TABLE projects REPLICA IDENTITY FULL; + ALTER TABLE attachments REPLICA IDENTITY FULL; + ALTER TABLE labels REPLICA IDENTITY FULL; + ALTER TABLE tasks REPLICA IDENTITY FULL; + ALTER TABLE requests REPLICA IDENTITY FULL; + ALTER TABLE memberships REPLICA IDENTITY FULL; + ALTER TABLE inactive_memberships REPLICA IDENTITY FULL; + ALTER TABLE tenants REPLICA IDENTITY FULL; + ALTER TABLE system_roles REPLICA IDENTITY FULL; + ALTER TABLE service_accounts REPLICA IDENTITY FULL; + ALTER TABLE api_keys REPLICA IDENTITY FULL; + ALTER TABLE oauth_clients REPLICA IDENTITY FULL; + RAISE NOTICE 'REPLICA IDENTITY FULL set on all tracked tables'; + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'REPLICA IDENTITY setup failed: % (SQLSTATE: %)', SQLERRM, SQLSTATE; + END; + + -- Replication slot ('cdc_slot') is NOT created here: the migrator applies all + -- migrations in one transaction, and logical slot creation always fails in a transaction + -- that has performed writes. The CDC worker creates the slot at startup + -- (cdc/src/pipeline/replication.ts). + + RAISE NOTICE 'CDC setup complete.'; +END $$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [counter_functions] Counter functions, apply_count_deltas +-- ══════════════════════════════════════════════════════════════════════════ +-- Counter Functions Setup +-- PL/pgSQL helper for JSONB counter delta merging. +-- Used by the CDC worker for prepared-statement-compatible counter upserts. + +CREATE OR REPLACE FUNCTION apply_count_deltas(existing jsonb, deltas jsonb) +RETURNS jsonb +LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE +AS $$ +DECLARE + result jsonb := COALESCE(existing, '{}'::jsonb); + k text; + v text; +BEGIN + FOR k, v IN SELECT * FROM jsonb_each_text(deltas) + LOOP + IF k LIKE 'e:li:%' OR k LIKE 'e:lu:%' OR k LIKE 'e:f:%' THEN + -- Activity stamps (epoch ms) and sequence frontiers (e:f: covers subtree + -- and self e:f:h:): keep the max, the signal only moves forward + result := result || jsonb_build_object( + k, GREATEST(COALESCE((result->>k)::bigint, 0), v::bigint) + ); + ELSE + result := result || jsonb_build_object( + k, GREATEST(0, COALESCE((result->>k)::bigint, 0) + v::bigint) + ); + END IF; + END LOOP; + RETURN result; +END; +$$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [immutability_setup] Immutability triggers, identity columns, write guards +-- ══════════════════════════════════════════════════════════════════════════ +-- Immutability Triggers Setup +-- Prevents modification of identity columns after row creation. +-- Gracefully skips triggers if required roles are not yet created. + +-- Functions are always created (harmless without triggers) + +CREATE OR REPLACE FUNCTION base_entity_immutable_keys() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.id IS DISTINCT FROM OLD.id + OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id + OR NEW.entity_type IS DISTINCT FROM OLD.entity_type + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.created_by IS DISTINCT FROM OLD.created_by THEN + RAISE EXCEPTION 'Cannot modify immutable columns (%) on %', 'id, tenant_id, entity_type, created_at, created_by', TG_TABLE_NAME; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION product_entity_immutable_keys() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.id IS DISTINCT FROM OLD.id + OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id + OR NEW.entity_type IS DISTINCT FROM OLD.entity_type + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.created_by IS DISTINCT FROM OLD.created_by + OR NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN + RAISE EXCEPTION 'Cannot modify immutable columns (%) on %', 'id, tenant_id, entity_type, created_at, created_by, organization_id', TG_TABLE_NAME; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION membership_immutable_keys() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id + OR NEW.channel_id IS DISTINCT FROM OLD.channel_id + OR NEW.channel_type IS DISTINCT FROM OLD.channel_type + OR NEW.organization_id IS DISTINCT FROM OLD.organization_id + OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id + OR NEW.project_id IS DISTINCT FROM OLD.project_id + OR NEW.user_id IS DISTINCT FROM OLD.user_id THEN + RAISE EXCEPTION 'Cannot modify immutable columns (%) on %', 'tenant_id, channel_id, channel_type, organization_id, workspace_id, project_id, user_id', TG_TABLE_NAME; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION inactive_membership_immutable_keys() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id + OR NEW.channel_id IS DISTINCT FROM OLD.channel_id + OR NEW.channel_type IS DISTINCT FROM OLD.channel_type + OR NEW.organization_id IS DISTINCT FROM OLD.organization_id + OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id + OR NEW.project_id IS DISTINCT FROM OLD.project_id THEN + RAISE EXCEPTION 'Cannot modify immutable columns (%) on %', 'tenant_id, channel_id, channel_type, organization_id, workspace_id, project_id', TG_TABLE_NAME; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION append_only_immutable_row() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'Table % is append-only: updates are not allowed', TG_TABLE_NAME; +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +CREATE OR REPLACE FUNCTION admin_only_write_row() RETURNS TRIGGER AS $$ +BEGIN + IF current_user = 'runtime_role' THEN + RAISE EXCEPTION 'Table % is not writable by %', TG_TABLE_NAME, current_user; + END IF; + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; +--> statement-breakpoint + +-- Triggers require roles to exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') THEN + RAISE NOTICE 'Roles not available - skipping immutability triggers.'; + RETURN; + END IF; + + BEGIN + EXECUTE 'DROP TRIGGER IF EXISTS organizations_immutable_keys_trigger ON organizations'; + EXECUTE 'CREATE TRIGGER organizations_immutable_keys_trigger BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION base_entity_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS workspaces_immutable_keys_trigger ON workspaces'; + EXECUTE 'CREATE TRIGGER workspaces_immutable_keys_trigger BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION base_entity_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS projects_immutable_keys_trigger ON projects'; + EXECUTE 'CREATE TRIGGER projects_immutable_keys_trigger BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION base_entity_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS tasks_immutable_keys_trigger ON tasks'; + EXECUTE 'CREATE TRIGGER tasks_immutable_keys_trigger BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION product_entity_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS labels_immutable_keys_trigger ON labels'; + EXECUTE 'CREATE TRIGGER labels_immutable_keys_trigger BEFORE UPDATE ON labels FOR EACH ROW EXECUTE FUNCTION product_entity_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS attachments_immutable_keys_trigger ON attachments'; + EXECUTE 'CREATE TRIGGER attachments_immutable_keys_trigger BEFORE UPDATE ON attachments FOR EACH ROW EXECUTE FUNCTION product_entity_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS memberships_immutable_keys_trigger ON memberships'; + EXECUTE 'CREATE TRIGGER memberships_immutable_keys_trigger BEFORE UPDATE ON memberships FOR EACH ROW EXECUTE FUNCTION membership_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS inactive_memberships_immutable_keys_trigger ON inactive_memberships'; + EXECUTE 'CREATE TRIGGER inactive_memberships_immutable_keys_trigger BEFORE UPDATE ON inactive_memberships FOR EACH ROW EXECUTE FUNCTION inactive_membership_immutable_keys()'; + EXECUTE 'DROP TRIGGER IF EXISTS activities_immutable_keys_trigger ON activities'; + EXECUTE 'CREATE TRIGGER activities_immutable_keys_trigger BEFORE UPDATE ON activities FOR EACH ROW EXECUTE FUNCTION append_only_immutable_row()'; + EXECUTE 'DROP TRIGGER IF EXISTS system_roles_admin_only_write_trigger ON system_roles'; + EXECUTE 'CREATE TRIGGER system_roles_admin_only_write_trigger BEFORE INSERT OR UPDATE OR DELETE ON system_roles FOR EACH ROW EXECUTE FUNCTION admin_only_write_row()'; + + RAISE NOTICE 'Immutability triggers setup complete.'; + EXCEPTION WHEN OTHERS THEN + -- Fail LOUDLY: a swallowed failure here rolls back EVERY trigger in this block and + -- ships a database where identity columns (tenant_id, organization_id, ...) are + -- mutable: the write-through RLS policies delegate that protection to these triggers. + RAISE EXCEPTION 'Immutability triggers setup failed: % (SQLSTATE: %)', SQLERRM, SQLSTATE; + END; +END $$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [jobs_grants] Job store privileges +-- ══════════════════════════════════════════════════════════════════════════ +-- Job store (pg-boss) privileges for runtime_role +-- The schema itself is installed by the migrate companion on the admin DSN; see +-- backend/scripts/db/install-jobs-schema.ts, which applies these same grants right after installing. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'pgboss') THEN + RAISE NOTICE 'Skipping job store grants - schema pgboss not installed yet.'; + RETURN; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') THEN + RAISE NOTICE 'Skipping job store grants - roles not available.'; + RETURN; + END IF; + + GRANT USAGE ON SCHEMA pgboss TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON ALL TABLES IN SCHEMA pgboss TO runtime_role; + GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA pgboss TO runtime_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA pgboss GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE ON TABLES TO runtime_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA pgboss GRANT USAGE, SELECT ON SEQUENCES TO runtime_role; + + RAISE NOTICE 'Job store grants complete.'; +END $$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [membership_rules] Membership rules, an organization keeps an admin, bindings versions +-- ══════════════════════════════════════════════════════════════════════════ +-- Membership rules +-- An organization keeps at least one 'admin' membership. + +CREATE OR REPLACE FUNCTION memberships_keep_org_admin() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.channel_type <> 'organization' OR OLD.role <> 'admin' THEN + RETURN NULL; + END IF; + IF TG_OP = 'UPDATE' AND NEW.role = 'admin' AND NEW.channel_id = OLD.channel_id THEN + RETURN NULL; + END IF; + -- The organization is gone with its memberships: there is nothing left to manage. + IF NOT EXISTS (SELECT 1 FROM organizations WHERE id = OLD.channel_id) THEN + RETURN NULL; + END IF; + PERFORM pg_advisory_xact_lock(hashtextextended('memberships_keep_org_admin:' || OLD.channel_id::text, 0)); + IF NOT EXISTS ( + SELECT 1 FROM memberships + WHERE channel_type = 'organization' AND organization_id = OLD.channel_id AND channel_id = OLD.channel_id + AND role = 'admin' + ) THEN + RAISE EXCEPTION 'Organization % would be left without an admin', OLD.channel_id + USING ERRCODE = '23514', CONSTRAINT = 'memberships_keep_org_admin'; + END IF; + RETURN NULL; +END; +$$; +--> statement-breakpoint + +DROP TRIGGER IF EXISTS memberships_keep_org_admin ON memberships; +--> statement-breakpoint + +CREATE CONSTRAINT TRIGGER memberships_keep_org_admin + AFTER UPDATE OF role, channel_id OR DELETE ON memberships + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION memberships_keep_org_admin(); + +--> statement-breakpoint + +-- Every membership write gives its user's actors.bindings_version a new value (the membership cache key). + +CREATE OR REPLACE FUNCTION memberships_bump_bindings_version() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE actors SET bindings_version = gen_random_uuid() WHERE id = COALESCE(NEW.user_id, OLD.user_id); + RETURN NULL; +END; +$$; +--> statement-breakpoint + +DROP TRIGGER IF EXISTS memberships_bump_bindings_version ON memberships; +--> statement-breakpoint + +CREATE TRIGGER memberships_bump_bindings_version + AFTER INSERT OR UPDATE OR DELETE ON memberships + FOR EACH ROW EXECUTE FUNCTION memberships_bump_bindings_version(); +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [partition_setup] Partitioned tables and maintain_partitions() +-- ══════════════════════════════════════════════════════════════════════════ +-- ============================================================================= +-- Migration: Time-partitioned tables with in-database retention +-- ============================================================================= +-- Converts the tables below to native range partitions and installs +-- maintain_partitions(), which pg_cron runs nightly (see +-- scripts/db/schedule-partition-maintenance.ts) to create partitions ahead +-- and drop those past retention. No extension is needed in this database. +-- +-- IMPORTANT: This creates a schema drift between Drizzle and the actual DB: +-- - Drizzle sees: regular tables with composite PKs +-- - PostgreSQL has: partitioned tables with composite PKs +-- +-- This is intentional. Standard ALTER TABLE operations (ADD COLUMN, etc.) +-- work fine on partitioned tables. Only avoid operations that recreate tables. +-- +-- - activities: partitioned by created_at (1 week, 90 days retention) +-- - seen_by: partitioned by created_at (1 week, 90 days retention) +-- - notifications: partitioned by created_at (1 week, 90 days retention) +-- +-- Any failure aborts the migration loudly: a swallowed error here previously +-- shipped databases where nothing was partitioned while everyone believed it was. +-- ============================================================================= + +-- pg_partman managed these partitions before; its partitions stay, its config goes. +DROP EXTENSION IF EXISTS pg_partman CASCADE; +DROP SCHEMA IF EXISTS partman CASCADE; + +CREATE OR REPLACE PROCEDURE public.maintain_partitions() LANGUAGE plpgsql AS $proc$ +DECLARE + cfg record; + sw record; + child record; + lo timestamptz; + hi timestamptz; + part text; +BEGIN + FOR cfg IN + SELECT * FROM (VALUES + ('activities', 'created_at', interval '1 week', interval '90 days'), + ('seen_by', 'created_at', interval '1 week', interval '90 days'), + ('notifications', 'created_at', interval '1 week', interval '90 days') + ) AS t(tbl, col, step, keep) + LOOP + lo := NULL; + FOR child IN + SELECT c.relname, + substring(pg_get_expr(c.relpartbound, c.oid) from 'TO \(''([^'']+)''\)')::timestamptz AS hi + FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = format('public.%I', cfg.tbl)::regclass + LOOP + IF child.hi IS NULL THEN + -- DEFAULT partition: no range to drop, so age out its rows directly + IF cfg.keep IS NOT NULL THEN + EXECUTE format('DELETE FROM %I WHERE %I < now() - $1', child.relname, cfg.col) USING cfg.keep; + END IF; + ELSIF cfg.keep IS NOT NULL AND child.hi < now() - cfg.keep THEN + EXECUTE format('DROP TABLE %I', child.relname); + ELSE + lo := greatest(lo, child.hi); + END IF; + END LOOP; + + -- Continue from the newest live partition, or start a fresh series at the current period + IF lo IS NULL OR lo < now() - coalesce(cfg.keep, interval '0') THEN + lo := date_trunc(CASE WHEN cfg.step >= interval '1 month' THEN 'month' ELSE 'week' END, now()); + END IF; + + WHILE lo < now() + 2 * cfg.step LOOP + hi := lo + cfg.step; + part := format('%s_p%s', cfg.tbl, to_char(lo, 'YYYYMMDD')); + EXECUTE format('CREATE TEMP TABLE moved (LIKE %I)', cfg.tbl); + EXECUTE format('WITH d AS (DELETE FROM %I WHERE %I >= $1 AND %I < $2 RETURNING *) INSERT INTO moved SELECT * FROM d', + cfg.tbl || '_default', cfg.col, cfg.col) USING lo, hi; + EXECUTE format('CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)', part, cfg.tbl, lo, hi); + EXECUTE format('INSERT INTO %I SELECT * FROM moved', cfg.tbl); + DROP TABLE moved; + lo := hi; + END LOOP; + END LOOP; + + -- Plain tables: retention by DELETE + FOR sw IN + SELECT * FROM (VALUES + ('sessions', 'expires_at', interval '30 days'), + ('tokens', 'expires_at', interval '30 days') + ) AS t(tbl, col, keep) + LOOP + EXECUTE format('DELETE FROM %I WHERE %I < now() - $1', sw.tbl, sw.col) USING sw.keep; + END LOOP; +END $proc$; + +DO $$ +DECLARE + ddl text; + pk_def text; + pk_cols text[]; + idx_defs text[]; + fk_defs text[]; + trg_defs text[]; +BEGIN + -- ========================================================================== + -- ACTIVITIES: convert to partitioned by RANGE (created_at) + -- ========================================================================== + + IF NOT EXISTS ( + SELECT 1 FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'activities' AND c.relnamespace = 'public'::regnamespace + ) THEN + -- 1a. Guard: PK must include the partition column + SELECT array_agg(a.attname::text ORDER BY x.ord) INTO pk_cols + FROM pg_constraint con + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, ord) ON true + JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = x.attnum + WHERE con.conrelid = 'public.activities'::regclass AND con.contype = 'p'; + IF pk_cols IS NULL OR NOT ('created_at' = ANY(pk_cols)) THEN + RAISE EXCEPTION 'activities: primary key (%) must include partition column created_at', pk_cols; + END IF; + + -- 1b. Guard: no non-PK unique constraints (cannot exist on the partitioned table) + IF EXISTS ( + SELECT 1 FROM pg_constraint con + WHERE con.conrelid = 'public.activities'::regclass AND con.contype = 'u' + ) THEN + RAISE EXCEPTION 'activities: unique constraints other than the PK cannot be carried onto a table partitioned by created_at'; + END IF; + + -- 2. Capture PK, FKs, non-constraint indexes, and triggers for replay after the + -- swap (earlier blocks may already have attached triggers, e.g. immutability) + SELECT pg_get_constraintdef(con.oid) INTO pk_def + FROM pg_constraint con + WHERE con.conrelid = 'public.activities'::regclass AND con.contype = 'p'; + SELECT COALESCE(array_agg(format('ALTER TABLE public.activities ADD CONSTRAINT %I %s', con.conname, pg_get_constraintdef(con.oid))), '{}') + INTO fk_defs + FROM pg_constraint con + WHERE con.conrelid = 'public.activities'::regclass AND con.contype = 'f'; + SELECT COALESCE(array_agg(pg_get_indexdef(i.indexrelid)), '{}') INTO idx_defs + FROM pg_index i + WHERE i.indrelid = 'public.activities'::regclass + AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid); + SELECT COALESCE(array_agg(pg_get_triggerdef(t.oid)), '{}') INTO trg_defs + FROM pg_trigger t + WHERE t.tgrelid = 'public.activities'::regclass AND NOT t.tgisinternal; + + -- 3. Move the original aside and create the partitioned table directly under the + -- final name, so child partitions get clean names (activities_p...). + -- The original's indexes keep their (schema-wide) names: safe, because no index + -- is created on the new table until the old one is dropped in step 5. + ALTER TABLE activities RENAME TO activities_old; + EXECUTE 'CREATE TABLE activities (LIKE activities_old INCLUDING ALL EXCLUDING INDEXES) PARTITION BY RANGE (created_at)'; + EXECUTE 'CREATE TABLE activities_default PARTITION OF activities DEFAULT'; + + -- 4. Copy data (identical column order via LIKE); every row lands in DEFAULT until + -- maintain_partitions() below creates the current ranges and moves rows over + EXECUTE 'INSERT INTO activities SELECT * FROM activities_old'; + + -- 5. Drop old (frees index/constraint names), replay PK + FKs + indexes + triggers + DROP TABLE activities_old; + + EXECUTE format('ALTER TABLE public.activities ADD %s', pk_def); + FOREACH ddl IN ARRAY fk_defs LOOP EXECUTE ddl; END LOOP; + FOREACH ddl IN ARRAY idx_defs LOOP EXECUTE ddl; END LOOP; + FOREACH ddl IN ARRAY trg_defs LOOP EXECUTE ddl; END LOOP; + + RAISE NOTICE 'activities converted to partitioned'; + END IF; + + -- ========================================================================== + -- SEEN_BY: convert to partitioned by RANGE (created_at) + -- ========================================================================== + + IF NOT EXISTS ( + SELECT 1 FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'seen_by' AND c.relnamespace = 'public'::regnamespace + ) THEN + -- 1a. Guard: PK must include the partition column + SELECT array_agg(a.attname::text ORDER BY x.ord) INTO pk_cols + FROM pg_constraint con + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, ord) ON true + JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = x.attnum + WHERE con.conrelid = 'public.seen_by'::regclass AND con.contype = 'p'; + IF pk_cols IS NULL OR NOT ('created_at' = ANY(pk_cols)) THEN + RAISE EXCEPTION 'seen_by: primary key (%) must include partition column created_at', pk_cols; + END IF; + + -- 1b. Guard: no non-PK unique constraints (cannot exist on the partitioned table) + IF EXISTS ( + SELECT 1 FROM pg_constraint con + WHERE con.conrelid = 'public.seen_by'::regclass AND con.contype = 'u' + ) THEN + RAISE EXCEPTION 'seen_by: unique constraints other than the PK cannot be carried onto a table partitioned by created_at'; + END IF; + + -- 2. Capture PK, FKs, non-constraint indexes, and triggers for replay after the + -- swap (earlier blocks may already have attached triggers, e.g. immutability) + SELECT pg_get_constraintdef(con.oid) INTO pk_def + FROM pg_constraint con + WHERE con.conrelid = 'public.seen_by'::regclass AND con.contype = 'p'; + SELECT COALESCE(array_agg(format('ALTER TABLE public.seen_by ADD CONSTRAINT %I %s', con.conname, pg_get_constraintdef(con.oid))), '{}') + INTO fk_defs + FROM pg_constraint con + WHERE con.conrelid = 'public.seen_by'::regclass AND con.contype = 'f'; + SELECT COALESCE(array_agg(pg_get_indexdef(i.indexrelid)), '{}') INTO idx_defs + FROM pg_index i + WHERE i.indrelid = 'public.seen_by'::regclass + AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid); + SELECT COALESCE(array_agg(pg_get_triggerdef(t.oid)), '{}') INTO trg_defs + FROM pg_trigger t + WHERE t.tgrelid = 'public.seen_by'::regclass AND NOT t.tgisinternal; + + -- 3. Move the original aside and create the partitioned table directly under the + -- final name, so child partitions get clean names (seen_by_p...). + -- The original's indexes keep their (schema-wide) names: safe, because no index + -- is created on the new table until the old one is dropped in step 5. + ALTER TABLE seen_by RENAME TO seen_by_old; + EXECUTE 'CREATE TABLE seen_by (LIKE seen_by_old INCLUDING ALL EXCLUDING INDEXES) PARTITION BY RANGE (created_at)'; + EXECUTE 'CREATE TABLE seen_by_default PARTITION OF seen_by DEFAULT'; + + -- 4. Copy data (identical column order via LIKE); every row lands in DEFAULT until + -- maintain_partitions() below creates the current ranges and moves rows over + EXECUTE 'INSERT INTO seen_by SELECT * FROM seen_by_old'; + + -- 5. Drop old (frees index/constraint names), replay PK + FKs + indexes + triggers + DROP TABLE seen_by_old; + + EXECUTE format('ALTER TABLE public.seen_by ADD %s', pk_def); + FOREACH ddl IN ARRAY fk_defs LOOP EXECUTE ddl; END LOOP; + FOREACH ddl IN ARRAY idx_defs LOOP EXECUTE ddl; END LOOP; + FOREACH ddl IN ARRAY trg_defs LOOP EXECUTE ddl; END LOOP; + + RAISE NOTICE 'seen_by converted to partitioned'; + END IF; + + -- ========================================================================== + -- NOTIFICATIONS: convert to partitioned by RANGE (created_at) + -- ========================================================================== + + IF NOT EXISTS ( + SELECT 1 FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'notifications' AND c.relnamespace = 'public'::regnamespace + ) THEN + -- 1a. Guard: PK must include the partition column + SELECT array_agg(a.attname::text ORDER BY x.ord) INTO pk_cols + FROM pg_constraint con + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS x(attnum, ord) ON true + JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = x.attnum + WHERE con.conrelid = 'public.notifications'::regclass AND con.contype = 'p'; + IF pk_cols IS NULL OR NOT ('created_at' = ANY(pk_cols)) THEN + RAISE EXCEPTION 'notifications: primary key (%) must include partition column created_at', pk_cols; + END IF; + + -- 1b. Guard: no non-PK unique constraints (cannot exist on the partitioned table) + IF EXISTS ( + SELECT 1 FROM pg_constraint con + WHERE con.conrelid = 'public.notifications'::regclass AND con.contype = 'u' + ) THEN + RAISE EXCEPTION 'notifications: unique constraints other than the PK cannot be carried onto a table partitioned by created_at'; + END IF; + + -- 2. Capture PK, FKs, non-constraint indexes, and triggers for replay after the + -- swap (earlier blocks may already have attached triggers, e.g. immutability) + SELECT pg_get_constraintdef(con.oid) INTO pk_def + FROM pg_constraint con + WHERE con.conrelid = 'public.notifications'::regclass AND con.contype = 'p'; + SELECT COALESCE(array_agg(format('ALTER TABLE public.notifications ADD CONSTRAINT %I %s', con.conname, pg_get_constraintdef(con.oid))), '{}') + INTO fk_defs + FROM pg_constraint con + WHERE con.conrelid = 'public.notifications'::regclass AND con.contype = 'f'; + SELECT COALESCE(array_agg(pg_get_indexdef(i.indexrelid)), '{}') INTO idx_defs + FROM pg_index i + WHERE i.indrelid = 'public.notifications'::regclass + AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid); + SELECT COALESCE(array_agg(pg_get_triggerdef(t.oid)), '{}') INTO trg_defs + FROM pg_trigger t + WHERE t.tgrelid = 'public.notifications'::regclass AND NOT t.tgisinternal; + + -- 3. Move the original aside and create the partitioned table directly under the + -- final name, so child partitions get clean names (notifications_p...). + -- The original's indexes keep their (schema-wide) names: safe, because no index + -- is created on the new table until the old one is dropped in step 5. + ALTER TABLE notifications RENAME TO notifications_old; + EXECUTE 'CREATE TABLE notifications (LIKE notifications_old INCLUDING ALL EXCLUDING INDEXES) PARTITION BY RANGE (created_at)'; + EXECUTE 'CREATE TABLE notifications_default PARTITION OF notifications DEFAULT'; + + -- 4. Copy data (identical column order via LIKE); every row lands in DEFAULT until + -- maintain_partitions() below creates the current ranges and moves rows over + EXECUTE 'INSERT INTO notifications SELECT * FROM notifications_old'; + + -- 5. Drop old (frees index/constraint names), replay PK + FKs + indexes + triggers + DROP TABLE notifications_old; + + EXECUTE format('ALTER TABLE public.notifications ADD %s', pk_def); + FOREACH ddl IN ARRAY fk_defs LOOP EXECUTE ddl; END LOOP; + FOREACH ddl IN ARRAY idx_defs LOOP EXECUTE ddl; END LOOP; + FOREACH ddl IN ARRAY trg_defs LOOP EXECUTE ddl; END LOOP; + + RAISE NOTICE 'notifications converted to partitioned'; + END IF; + + CALL public.maintain_partitions(); + RAISE NOTICE 'Partition setup complete.'; +END $$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [rls_setup] RLS, ownership, enabled RLS, grants +-- ══════════════════════════════════════════════════════════════════════════ +-- RLS (Row-Level Security) Setup +-- Configures table ownership, enabled (not forced) RLS, and grants. +-- Policies are defined in Drizzle schema files using pgPolicy(). +-- admin_role owns every RLS table and RLS is never forced, so the owner bypasses the policies +-- natively (migrations, seeds, maintenance, CDC seq stamping) without the BYPASSRLS attribute, +-- which managed providers such as Scaleway do not grant. runtime_role stays RLS-subject. +-- RLS enforces tenant-level isolation only; org-level isolation is application-layer (orgGuard). +-- Requires runtime_role and admin_role: a database migrated without them would run with no +-- ownership, no RLS and no grants, so their absence aborts the migration. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') + OR NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'admin_role') THEN + RAISE EXCEPTION 'RLS setup: runtime_role and admin_role must exist before migrations run (create-db-roles, or provider-managed users)'; + END IF; + + BEGIN + -- Table ownership and enabled (not forced) RLS + ALTER TABLE attachments OWNER TO admin_role; + ALTER TABLE labels OWNER TO admin_role; + ALTER TABLE tasks OWNER TO admin_role; + ALTER TABLE yjs_documents OWNER TO admin_role; + ALTER TABLE yjs_updates OWNER TO admin_role; + ALTER TABLE activities OWNER TO admin_role; + + ALTER TABLE attachments ENABLE ROW LEVEL SECURITY; + ALTER TABLE attachments NO FORCE ROW LEVEL SECURITY; + ALTER TABLE labels ENABLE ROW LEVEL SECURITY; + ALTER TABLE labels NO FORCE ROW LEVEL SECURITY; + ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; + ALTER TABLE tasks NO FORCE ROW LEVEL SECURITY; + ALTER TABLE yjs_documents ENABLE ROW LEVEL SECURITY; + ALTER TABLE yjs_documents NO FORCE ROW LEVEL SECURITY; + ALTER TABLE yjs_updates ENABLE ROW LEVEL SECURITY; + ALTER TABLE yjs_updates NO FORCE ROW LEVEL SECURITY; + + -- Grants: runtime_role (subject to RLS) + GRANT SELECT, INSERT, UPDATE, DELETE ON attachments TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON labels TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON tasks TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON yjs_documents TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON yjs_updates TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON organizations TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON workspaces TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON memberships TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON inactive_memberships TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON actors TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON service_accounts TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON api_keys TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON oauth_clients TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON signing_keys TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON oidc_payloads TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON users TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON sessions TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON devices TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON user_counters TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON tokens TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON passkeys TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON passkey_challenges TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON identities TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON totps TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON requests TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON emails TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON rate_limits TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON channel_counters TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON seen_by TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON notification_preferences TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON push_subscriptions TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON product_counters TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON domains TO runtime_role; + GRANT SELECT, INSERT, UPDATE, DELETE ON tenants TO runtime_role; + GRANT SELECT ON system_roles TO runtime_role; + GRANT SELECT ON activities TO runtime_role; + + -- Grants: admin_role (full access; also used by the CDC worker) + GRANT ALL ON ALL TABLES IN SCHEMA public TO admin_role; + GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO admin_role; + + -- Grants: pg_catalog usage for JSONB operators + GRANT USAGE ON SCHEMA pg_catalog TO runtime_role; + + RAISE NOTICE 'RLS setup complete.'; + EXCEPTION WHEN OTHERS THEN + -- Fail LOUDLY: swallowing this rolled back ownership, RLS and every grant in + -- one silent NOTICE: the app then boots with no table grants (every request 403s) + -- or, worse, without enforced RLS. + RAISE EXCEPTION 'RLS setup failed: % (SQLSTATE: %)', SQLERRM, SQLSTATE; + END; +END $$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [unlogged_setup] UNLOGGED tables +-- ══════════════════════════════════════════════════════════════════════════ +-- UNLOGGED Tables Setup +-- Converts ephemeral counter/rate-limit tables to UNLOGGED (skip WAL writes). +-- Idempotent: only alters tables not already UNLOGGED. +-- Gracefully skips if required roles are not yet created. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') THEN + RAISE NOTICE 'Skipping UNLOGGED setup - roles not available.'; + RETURN; + END IF; + + IF (SELECT relpersistence FROM pg_class WHERE relname = 'rate_limits') != 'u' THEN + ALTER TABLE rate_limits SET UNLOGGED; + RAISE NOTICE 'rate_limits set to UNLOGGED'; + END IF; + + IF (SELECT relpersistence FROM pg_class WHERE relname = 'user_counters') != 'u' THEN + ALTER TABLE user_counters SET UNLOGGED; + RAISE NOTICE 'user_counters set to UNLOGGED'; + END IF; + + IF (SELECT relpersistence FROM pg_class WHERE relname = 'channel_counters') != 'u' THEN + ALTER TABLE channel_counters SET UNLOGGED; + RAISE NOTICE 'channel_counters set to UNLOGGED'; + END IF; + + IF (SELECT relpersistence FROM pg_class WHERE relname = 'product_counters') != 'u' THEN + ALTER TABLE product_counters SET UNLOGGED; + RAISE NOTICE 'product_counters set to UNLOGGED'; + END IF; + + RAISE NOTICE 'UNLOGGED setup complete.'; +END $$; +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [publicat_cascade] drop the former publicAt cascade/inherit triggers (runtime owns publicAt) +-- ══════════════════════════════════════════════════════════════════════════ +-- publicAt distribution moved to the server runtime; drop the former triggers. +DROP TRIGGER IF EXISTS trg_cascade_public_at_from_project ON projects; +--> statement-breakpoint +DROP FUNCTION IF EXISTS cascade_public_at_from_project(); +--> statement-breakpoint +DROP TRIGGER IF EXISTS trg_inherit_public_at_tasks ON tasks; +--> statement-breakpoint +DROP TRIGGER IF EXISTS trg_inherit_public_at_attachments ON attachments; +--> statement-breakpoint +DROP FUNCTION IF EXISTS inherit_public_at_from_project(); +--> statement-breakpoint +-- ══════════════════════════════════════════════════════════════════════════ +-- [verify_side_effects] Verify, assert end state of all side-effect blocks +-- ══════════════════════════════════════════════════════════════════════════ +-- Side-effect verification +-- Asserts the end state of every previous block. A failed assertion aborts (and rolls +-- back) the whole migration instead of shipping a silently degraded database. + +DO $$ +DECLARE + missing text[] := '{}'; +BEGIN + -- Roles are a hard precondition: without them the RLS, trigger and grant blocks could not + -- have run, and a database that skipped them must never pass verification. + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') + OR NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'admin_role') THEN + RAISE EXCEPTION 'verify: runtime_role and admin_role must exist before migrations run (create-db-roles, or provider-managed users)'; + END IF; + + -- The runtime role must stay RLS-subject. + IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role' AND (rolbypassrls OR rolsuper)) THEN + missing := array_append(missing, 'role:runtime_role:bypasses-rls'); + END IF; + + -- Functions (created unconditionally by their blocks) + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'base_entity_immutable_keys') THEN + missing := array_append(missing, 'function:base_entity_immutable_keys'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'product_entity_immutable_keys') THEN + missing := array_append(missing, 'function:product_entity_immutable_keys'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'membership_immutable_keys') THEN + missing := array_append(missing, 'function:membership_immutable_keys'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'inactive_membership_immutable_keys') THEN + missing := array_append(missing, 'function:inactive_membership_immutable_keys'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'append_only_immutable_row') THEN + missing := array_append(missing, 'function:append_only_immutable_row'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'admin_only_write_row') THEN + missing := array_append(missing, 'function:admin_only_write_row'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'memberships_keep_org_admin') THEN + missing := array_append(missing, 'function:memberships_keep_org_admin'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'memberships_bump_bindings_version') THEN + missing := array_append(missing, 'function:memberships_bump_bindings_version'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'apply_count_deltas') THEN + missing := array_append(missing, 'function:apply_count_deltas'); END IF; + + -- Immutability, write-guard and membership rule triggers + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'organizations' + AND t.tgname = 'organizations_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:organizations_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'workspaces' + AND t.tgname = 'workspaces_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:workspaces_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'projects' + AND t.tgname = 'projects_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:projects_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'tasks' + AND t.tgname = 'tasks_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:tasks_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'labels' + AND t.tgname = 'labels_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:labels_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'attachments' + AND t.tgname = 'attachments_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:attachments_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'memberships' + AND t.tgname = 'memberships_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:memberships_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'inactive_memberships' + AND t.tgname = 'inactive_memberships_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:inactive_memberships_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'activities' + AND t.tgname = 'activities_immutable_keys_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:activities_immutable_keys_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'system_roles' + AND t.tgname = 'system_roles_admin_only_write_trigger' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:system_roles_admin_only_write_trigger'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'memberships' + AND t.tgname = 'memberships_keep_org_admin' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:memberships_keep_org_admin'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname = 'memberships' + AND t.tgname = 'memberships_bump_bindings_version' AND NOT t.tgisinternal + ) THEN missing := array_append(missing, 'trigger:memberships_bump_bindings_version'); END IF; + + -- Ownership and enabled (not forced) RLS + IF (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE relname = 'attachments' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'admin_role' THEN + missing := array_append(missing, 'owner:attachments'); END IF; + IF (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE relname = 'labels' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'admin_role' THEN + missing := array_append(missing, 'owner:labels'); END IF; + IF (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE relname = 'tasks' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'admin_role' THEN + missing := array_append(missing, 'owner:tasks'); END IF; + IF (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'admin_role' THEN + missing := array_append(missing, 'owner:yjs_documents'); END IF; + IF (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'admin_role' THEN + missing := array_append(missing, 'owner:yjs_updates'); END IF; + IF (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE relname = 'activities' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'admin_role' THEN + missing := array_append(missing, 'owner:activities'); END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_class WHERE relname = 'attachments' AND relnamespace = 'public'::regnamespace AND relrowsecurity AND NOT relforcerowsecurity + ) THEN missing := array_append(missing, 'rls-enabled-not-forced:attachments'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_class WHERE relname = 'labels' AND relnamespace = 'public'::regnamespace AND relrowsecurity AND NOT relforcerowsecurity + ) THEN missing := array_append(missing, 'rls-enabled-not-forced:labels'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_class WHERE relname = 'tasks' AND relnamespace = 'public'::regnamespace AND relrowsecurity AND NOT relforcerowsecurity + ) THEN missing := array_append(missing, 'rls-enabled-not-forced:tasks'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_class WHERE relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace AND relrowsecurity AND NOT relforcerowsecurity + ) THEN missing := array_append(missing, 'rls-enabled-not-forced:yjs_documents'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_class WHERE relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace AND relrowsecurity AND NOT relforcerowsecurity + ) THEN missing := array_append(missing, 'rls-enabled-not-forced:yjs_updates'); END IF; + + -- Policy contract (5 tables x 4 policies) + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'attachments' AND relnamespace = 'public'::regnamespace AND p.polname = 'attachments_select_policy' AND p.polcmd = 'r' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND pg_get_expr(p.polqual, p.polrelid) LIKE '%app.tenant_id%' AND pg_get_expr(p.polqual, p.polrelid) LIKE '%tenant_id)::text = current_setting%' + ) THEN missing := array_append(missing, 'policy:attachments_select_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'attachments' AND relnamespace = 'public'::regnamespace AND p.polname = 'attachments_insert_policy' AND p.polcmd = 'a' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:attachments_insert_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'attachments' AND relnamespace = 'public'::regnamespace AND p.polname = 'attachments_update_policy' AND p.polcmd = 'w' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:attachments_update_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'attachments' AND relnamespace = 'public'::regnamespace AND p.polname = 'attachments_delete_policy' AND p.polcmd = 'd' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:attachments_delete_policy'); END IF; + IF (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'attachments' AND relnamespace = 'public'::regnamespace) <> 4 THEN + missing := array_append(missing, 'policy-count:attachments'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'labels' AND relnamespace = 'public'::regnamespace AND p.polname = 'labels_select_policy' AND p.polcmd = 'r' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND pg_get_expr(p.polqual, p.polrelid) LIKE '%app.tenant_id%' AND pg_get_expr(p.polqual, p.polrelid) LIKE '%tenant_id)::text = current_setting%' + ) THEN missing := array_append(missing, 'policy:labels_select_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'labels' AND relnamespace = 'public'::regnamespace AND p.polname = 'labels_insert_policy' AND p.polcmd = 'a' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:labels_insert_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'labels' AND relnamespace = 'public'::regnamespace AND p.polname = 'labels_update_policy' AND p.polcmd = 'w' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:labels_update_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'labels' AND relnamespace = 'public'::regnamespace AND p.polname = 'labels_delete_policy' AND p.polcmd = 'd' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:labels_delete_policy'); END IF; + IF (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'labels' AND relnamespace = 'public'::regnamespace) <> 4 THEN + missing := array_append(missing, 'policy-count:labels'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'tasks' AND relnamespace = 'public'::regnamespace AND p.polname = 'tasks_select_policy' AND p.polcmd = 'r' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND pg_get_expr(p.polqual, p.polrelid) LIKE '%app.tenant_id%' AND pg_get_expr(p.polqual, p.polrelid) LIKE '%tenant_id)::text = current_setting%' + ) THEN missing := array_append(missing, 'policy:tasks_select_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'tasks' AND relnamespace = 'public'::regnamespace AND p.polname = 'tasks_insert_policy' AND p.polcmd = 'a' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:tasks_insert_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'tasks' AND relnamespace = 'public'::regnamespace AND p.polname = 'tasks_update_policy' AND p.polcmd = 'w' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:tasks_update_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'tasks' AND relnamespace = 'public'::regnamespace AND p.polname = 'tasks_delete_policy' AND p.polcmd = 'd' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:tasks_delete_policy'); END IF; + IF (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'tasks' AND relnamespace = 'public'::regnamespace) <> 4 THEN + missing := array_append(missing, 'policy-count:tasks'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_documents_select_policy' AND p.polcmd = 'r' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND pg_get_expr(p.polqual, p.polrelid) LIKE '%app.tenant_id%' AND pg_get_expr(p.polqual, p.polrelid) LIKE '%tenant_id)::text = current_setting%' + ) THEN missing := array_append(missing, 'policy:yjs_documents_select_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_documents_insert_policy' AND p.polcmd = 'a' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:yjs_documents_insert_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_documents_update_policy' AND p.polcmd = 'w' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:yjs_documents_update_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_documents_delete_policy' AND p.polcmd = 'd' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:yjs_documents_delete_policy'); END IF; + IF (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace) <> 4 THEN + missing := array_append(missing, 'policy-count:yjs_documents'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_updates_select_policy' AND p.polcmd = 'r' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND pg_get_expr(p.polqual, p.polrelid) LIKE '%app.tenant_id%' AND pg_get_expr(p.polqual, p.polrelid) LIKE '%tenant_id)::text = current_setting%' + ) THEN missing := array_append(missing, 'policy:yjs_updates_select_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_updates_insert_policy' AND p.polcmd = 'a' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:yjs_updates_insert_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_updates_update_policy' AND p.polcmd = 'w' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:yjs_updates_update_policy'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid + WHERE c.relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace AND p.polname = 'yjs_updates_delete_policy' AND p.polcmd = 'd' + AND p.polpermissive AND p.polroles = '{0}'::oid[] AND COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), pg_get_expr(p.polqual, p.polrelid)) = 'true' + ) THEN missing := array_append(missing, 'policy:yjs_updates_delete_policy'); END IF; + IF (SELECT count(*) FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'yjs_updates' AND relnamespace = 'public'::regnamespace) <> 4 THEN + missing := array_append(missing, 'policy-count:yjs_updates'); END IF; + + -- Grants per classification + IF NOT has_table_privilege('runtime_role', 'public.attachments', 'SELECT') THEN + missing := array_append(missing, 'grant:attachments:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.attachments', 'INSERT') THEN + missing := array_append(missing, 'grant:attachments:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.attachments', 'UPDATE') THEN + missing := array_append(missing, 'grant:attachments:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.attachments', 'DELETE') THEN + missing := array_append(missing, 'grant:attachments:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.labels', 'SELECT') THEN + missing := array_append(missing, 'grant:labels:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.labels', 'INSERT') THEN + missing := array_append(missing, 'grant:labels:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.labels', 'UPDATE') THEN + missing := array_append(missing, 'grant:labels:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.labels', 'DELETE') THEN + missing := array_append(missing, 'grant:labels:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tasks', 'SELECT') THEN + missing := array_append(missing, 'grant:tasks:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tasks', 'INSERT') THEN + missing := array_append(missing, 'grant:tasks:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tasks', 'UPDATE') THEN + missing := array_append(missing, 'grant:tasks:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tasks', 'DELETE') THEN + missing := array_append(missing, 'grant:tasks:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_documents', 'SELECT') THEN + missing := array_append(missing, 'grant:yjs_documents:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_documents', 'INSERT') THEN + missing := array_append(missing, 'grant:yjs_documents:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_documents', 'UPDATE') THEN + missing := array_append(missing, 'grant:yjs_documents:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_documents', 'DELETE') THEN + missing := array_append(missing, 'grant:yjs_documents:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_updates', 'SELECT') THEN + missing := array_append(missing, 'grant:yjs_updates:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_updates', 'INSERT') THEN + missing := array_append(missing, 'grant:yjs_updates:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_updates', 'UPDATE') THEN + missing := array_append(missing, 'grant:yjs_updates:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.yjs_updates', 'DELETE') THEN + missing := array_append(missing, 'grant:yjs_updates:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.organizations', 'SELECT') THEN + missing := array_append(missing, 'grant:organizations:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.organizations', 'INSERT') THEN + missing := array_append(missing, 'grant:organizations:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.organizations', 'UPDATE') THEN + missing := array_append(missing, 'grant:organizations:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.organizations', 'DELETE') THEN + missing := array_append(missing, 'grant:organizations:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.workspaces', 'SELECT') THEN + missing := array_append(missing, 'grant:workspaces:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.workspaces', 'INSERT') THEN + missing := array_append(missing, 'grant:workspaces:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.workspaces', 'UPDATE') THEN + missing := array_append(missing, 'grant:workspaces:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.workspaces', 'DELETE') THEN + missing := array_append(missing, 'grant:workspaces:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.projects', 'SELECT') THEN + missing := array_append(missing, 'grant:projects:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.projects', 'INSERT') THEN + missing := array_append(missing, 'grant:projects:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.projects', 'UPDATE') THEN + missing := array_append(missing, 'grant:projects:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.projects', 'DELETE') THEN + missing := array_append(missing, 'grant:projects:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.memberships', 'SELECT') THEN + missing := array_append(missing, 'grant:memberships:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.memberships', 'INSERT') THEN + missing := array_append(missing, 'grant:memberships:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.memberships', 'UPDATE') THEN + missing := array_append(missing, 'grant:memberships:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.memberships', 'DELETE') THEN + missing := array_append(missing, 'grant:memberships:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.inactive_memberships', 'SELECT') THEN + missing := array_append(missing, 'grant:inactive_memberships:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.inactive_memberships', 'INSERT') THEN + missing := array_append(missing, 'grant:inactive_memberships:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.inactive_memberships', 'UPDATE') THEN + missing := array_append(missing, 'grant:inactive_memberships:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.inactive_memberships', 'DELETE') THEN + missing := array_append(missing, 'grant:inactive_memberships:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.actors', 'SELECT') THEN + missing := array_append(missing, 'grant:actors:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.actors', 'INSERT') THEN + missing := array_append(missing, 'grant:actors:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.actors', 'UPDATE') THEN + missing := array_append(missing, 'grant:actors:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.actors', 'DELETE') THEN + missing := array_append(missing, 'grant:actors:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.service_accounts', 'SELECT') THEN + missing := array_append(missing, 'grant:service_accounts:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.service_accounts', 'INSERT') THEN + missing := array_append(missing, 'grant:service_accounts:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.service_accounts', 'UPDATE') THEN + missing := array_append(missing, 'grant:service_accounts:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.service_accounts', 'DELETE') THEN + missing := array_append(missing, 'grant:service_accounts:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.api_keys', 'SELECT') THEN + missing := array_append(missing, 'grant:api_keys:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.api_keys', 'INSERT') THEN + missing := array_append(missing, 'grant:api_keys:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.api_keys', 'UPDATE') THEN + missing := array_append(missing, 'grant:api_keys:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.api_keys', 'DELETE') THEN + missing := array_append(missing, 'grant:api_keys:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oauth_clients', 'SELECT') THEN + missing := array_append(missing, 'grant:oauth_clients:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oauth_clients', 'INSERT') THEN + missing := array_append(missing, 'grant:oauth_clients:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oauth_clients', 'UPDATE') THEN + missing := array_append(missing, 'grant:oauth_clients:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oauth_clients', 'DELETE') THEN + missing := array_append(missing, 'grant:oauth_clients:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.signing_keys', 'SELECT') THEN + missing := array_append(missing, 'grant:signing_keys:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.signing_keys', 'INSERT') THEN + missing := array_append(missing, 'grant:signing_keys:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.signing_keys', 'UPDATE') THEN + missing := array_append(missing, 'grant:signing_keys:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.signing_keys', 'DELETE') THEN + missing := array_append(missing, 'grant:signing_keys:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oidc_payloads', 'SELECT') THEN + missing := array_append(missing, 'grant:oidc_payloads:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oidc_payloads', 'INSERT') THEN + missing := array_append(missing, 'grant:oidc_payloads:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oidc_payloads', 'UPDATE') THEN + missing := array_append(missing, 'grant:oidc_payloads:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.oidc_payloads', 'DELETE') THEN + missing := array_append(missing, 'grant:oidc_payloads:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.users', 'SELECT') THEN + missing := array_append(missing, 'grant:users:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.users', 'INSERT') THEN + missing := array_append(missing, 'grant:users:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.users', 'UPDATE') THEN + missing := array_append(missing, 'grant:users:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.users', 'DELETE') THEN + missing := array_append(missing, 'grant:users:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.sessions', 'SELECT') THEN + missing := array_append(missing, 'grant:sessions:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.sessions', 'INSERT') THEN + missing := array_append(missing, 'grant:sessions:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.sessions', 'UPDATE') THEN + missing := array_append(missing, 'grant:sessions:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.sessions', 'DELETE') THEN + missing := array_append(missing, 'grant:sessions:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.devices', 'SELECT') THEN + missing := array_append(missing, 'grant:devices:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.devices', 'INSERT') THEN + missing := array_append(missing, 'grant:devices:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.devices', 'UPDATE') THEN + missing := array_append(missing, 'grant:devices:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.devices', 'DELETE') THEN + missing := array_append(missing, 'grant:devices:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.user_counters', 'SELECT') THEN + missing := array_append(missing, 'grant:user_counters:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.user_counters', 'INSERT') THEN + missing := array_append(missing, 'grant:user_counters:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.user_counters', 'UPDATE') THEN + missing := array_append(missing, 'grant:user_counters:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.user_counters', 'DELETE') THEN + missing := array_append(missing, 'grant:user_counters:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tokens', 'SELECT') THEN + missing := array_append(missing, 'grant:tokens:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tokens', 'INSERT') THEN + missing := array_append(missing, 'grant:tokens:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tokens', 'UPDATE') THEN + missing := array_append(missing, 'grant:tokens:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tokens', 'DELETE') THEN + missing := array_append(missing, 'grant:tokens:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkeys', 'SELECT') THEN + missing := array_append(missing, 'grant:passkeys:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkeys', 'INSERT') THEN + missing := array_append(missing, 'grant:passkeys:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkeys', 'UPDATE') THEN + missing := array_append(missing, 'grant:passkeys:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkeys', 'DELETE') THEN + missing := array_append(missing, 'grant:passkeys:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkey_challenges', 'SELECT') THEN + missing := array_append(missing, 'grant:passkey_challenges:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkey_challenges', 'INSERT') THEN + missing := array_append(missing, 'grant:passkey_challenges:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkey_challenges', 'UPDATE') THEN + missing := array_append(missing, 'grant:passkey_challenges:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.passkey_challenges', 'DELETE') THEN + missing := array_append(missing, 'grant:passkey_challenges:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.identities', 'SELECT') THEN + missing := array_append(missing, 'grant:identities:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.identities', 'INSERT') THEN + missing := array_append(missing, 'grant:identities:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.identities', 'UPDATE') THEN + missing := array_append(missing, 'grant:identities:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.identities', 'DELETE') THEN + missing := array_append(missing, 'grant:identities:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.totps', 'SELECT') THEN + missing := array_append(missing, 'grant:totps:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.totps', 'INSERT') THEN + missing := array_append(missing, 'grant:totps:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.totps', 'UPDATE') THEN + missing := array_append(missing, 'grant:totps:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.totps', 'DELETE') THEN + missing := array_append(missing, 'grant:totps:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.requests', 'SELECT') THEN + missing := array_append(missing, 'grant:requests:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.requests', 'INSERT') THEN + missing := array_append(missing, 'grant:requests:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.requests', 'UPDATE') THEN + missing := array_append(missing, 'grant:requests:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.requests', 'DELETE') THEN + missing := array_append(missing, 'grant:requests:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.emails', 'SELECT') THEN + missing := array_append(missing, 'grant:emails:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.emails', 'INSERT') THEN + missing := array_append(missing, 'grant:emails:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.emails', 'UPDATE') THEN + missing := array_append(missing, 'grant:emails:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.emails', 'DELETE') THEN + missing := array_append(missing, 'grant:emails:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.rate_limits', 'SELECT') THEN + missing := array_append(missing, 'grant:rate_limits:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.rate_limits', 'INSERT') THEN + missing := array_append(missing, 'grant:rate_limits:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.rate_limits', 'UPDATE') THEN + missing := array_append(missing, 'grant:rate_limits:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.rate_limits', 'DELETE') THEN + missing := array_append(missing, 'grant:rate_limits:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.channel_counters', 'SELECT') THEN + missing := array_append(missing, 'grant:channel_counters:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.channel_counters', 'INSERT') THEN + missing := array_append(missing, 'grant:channel_counters:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.channel_counters', 'UPDATE') THEN + missing := array_append(missing, 'grant:channel_counters:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.channel_counters', 'DELETE') THEN + missing := array_append(missing, 'grant:channel_counters:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.seen_by', 'SELECT') THEN + missing := array_append(missing, 'grant:seen_by:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.seen_by', 'INSERT') THEN + missing := array_append(missing, 'grant:seen_by:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.seen_by', 'UPDATE') THEN + missing := array_append(missing, 'grant:seen_by:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.seen_by', 'DELETE') THEN + missing := array_append(missing, 'grant:seen_by:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notifications', 'SELECT') THEN + missing := array_append(missing, 'grant:notifications:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notifications', 'INSERT') THEN + missing := array_append(missing, 'grant:notifications:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notifications', 'UPDATE') THEN + missing := array_append(missing, 'grant:notifications:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notifications', 'DELETE') THEN + missing := array_append(missing, 'grant:notifications:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notification_preferences', 'SELECT') THEN + missing := array_append(missing, 'grant:notification_preferences:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notification_preferences', 'INSERT') THEN + missing := array_append(missing, 'grant:notification_preferences:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notification_preferences', 'UPDATE') THEN + missing := array_append(missing, 'grant:notification_preferences:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.notification_preferences', 'DELETE') THEN + missing := array_append(missing, 'grant:notification_preferences:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.push_subscriptions', 'SELECT') THEN + missing := array_append(missing, 'grant:push_subscriptions:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.push_subscriptions', 'INSERT') THEN + missing := array_append(missing, 'grant:push_subscriptions:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.push_subscriptions', 'UPDATE') THEN + missing := array_append(missing, 'grant:push_subscriptions:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.push_subscriptions', 'DELETE') THEN + missing := array_append(missing, 'grant:push_subscriptions:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.product_counters', 'SELECT') THEN + missing := array_append(missing, 'grant:product_counters:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.product_counters', 'INSERT') THEN + missing := array_append(missing, 'grant:product_counters:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.product_counters', 'UPDATE') THEN + missing := array_append(missing, 'grant:product_counters:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.product_counters', 'DELETE') THEN + missing := array_append(missing, 'grant:product_counters:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.domains', 'SELECT') THEN + missing := array_append(missing, 'grant:domains:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.domains', 'INSERT') THEN + missing := array_append(missing, 'grant:domains:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.domains', 'UPDATE') THEN + missing := array_append(missing, 'grant:domains:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.domains', 'DELETE') THEN + missing := array_append(missing, 'grant:domains:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tenants', 'SELECT') THEN + missing := array_append(missing, 'grant:tenants:SELECT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tenants', 'INSERT') THEN + missing := array_append(missing, 'grant:tenants:INSERT'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tenants', 'UPDATE') THEN + missing := array_append(missing, 'grant:tenants:UPDATE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.tenants', 'DELETE') THEN + missing := array_append(missing, 'grant:tenants:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.system_roles', 'SELECT') THEN + missing := array_append(missing, 'grant:system_roles:SELECT'); END IF; + IF has_table_privilege('runtime_role', 'public.system_roles', 'INSERT') THEN + missing := array_append(missing, 'grant-excess:system_roles:INSERT'); END IF; + IF has_table_privilege('runtime_role', 'public.system_roles', 'UPDATE') THEN + missing := array_append(missing, 'grant-excess:system_roles:UPDATE'); END IF; + IF has_table_privilege('runtime_role', 'public.system_roles', 'DELETE') THEN + missing := array_append(missing, 'grant-excess:system_roles:DELETE'); END IF; + IF NOT has_table_privilege('runtime_role', 'public.activities', 'SELECT') THEN + missing := array_append(missing, 'grant:activities:SELECT'); END IF; + IF has_table_privilege('runtime_role', 'public.activities', 'INSERT') THEN + missing := array_append(missing, 'grant-excess:activities:INSERT'); END IF; + IF has_table_privilege('runtime_role', 'public.activities', 'UPDATE') THEN + missing := array_append(missing, 'grant-excess:activities:UPDATE'); END IF; + IF has_table_privilege('runtime_role', 'public.activities', 'DELETE') THEN + missing := array_append(missing, 'grant-excess:activities:DELETE'); END IF; + + -- UNLOGGED + IF (SELECT relpersistence FROM pg_class WHERE relname = 'rate_limits' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'u' THEN + missing := array_append(missing, 'unlogged:rate_limits'); END IF; + IF (SELECT relpersistence FROM pg_class WHERE relname = 'user_counters' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'u' THEN + missing := array_append(missing, 'unlogged:user_counters'); END IF; + IF (SELECT relpersistence FROM pg_class WHERE relname = 'channel_counters' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'u' THEN + missing := array_append(missing, 'unlogged:channel_counters'); END IF; + IF (SELECT relpersistence FROM pg_class WHERE relname = 'product_counters' AND relnamespace = 'public'::regnamespace) IS DISTINCT FROM 'u' THEN + missing := array_append(missing, 'unlogged:product_counters'); END IF; + + -- Partitioning and its maintenance procedure (and nothing partitioned beyond the configs) + IF NOT EXISTS ( + SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'activities' AND c.relnamespace = 'public'::regnamespace + ) THEN missing := array_append(missing, 'partitioned:activities'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'seen_by' AND c.relnamespace = 'public'::regnamespace + ) THEN missing := array_append(missing, 'partitioned:seen_by'); END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'notifications' AND c.relnamespace = 'public'::regnamespace + ) THEN missing := array_append(missing, 'partitioned:notifications'); END IF; + IF EXISTS ( + SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relnamespace = 'public'::regnamespace AND c.relname NOT IN ('activities', 'seen_by', 'notifications') + ) THEN missing := array_append(missing, 'unexpected-partitioned-table'); END IF; + IF NOT EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'maintain_partitions' AND pronamespace = 'public'::regnamespace) THEN + missing := array_append(missing, 'procedure:maintain_partitions'); END IF; + + -- CDC publication (15 tracked tables) + IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'cdc_pub') THEN + missing := array_append(missing, 'publication:cdc_pub'); + ELSIF (SELECT count(DISTINCT tablename) FROM pg_publication_tables WHERE pubname = 'cdc_pub') <> 15 THEN + missing := array_append(missing, 'publication-tables:cdc_pub'); + ELSIF (SELECT count(*) FROM pg_publication_tables WHERE pubname = 'cdc_pub' AND rowfilter IS NOT NULL) <> 0 THEN + missing := array_append(missing, 'publication-rowfilters:cdc_pub'); + END IF; + + IF array_length(missing, 1) > 0 THEN + RAISE EXCEPTION 'DB side-effect verification failed, missing: %', array_to_string(missing, ', '); + END IF; + + RAISE NOTICE 'Side-effect verification passed.'; +END $$; diff --git a/backend/drizzle/20261002100739_side_effects/snapshot.json b/backend/drizzle/20261002100739_side_effects/snapshot.json new file mode 100644 index 000000000..afb4d4b04 --- /dev/null +++ b/backend/drizzle/20261002100739_side_effects/snapshot.json @@ -0,0 +1,10665 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "869b6897-4e4f-451f-b792-55d5cc44961b", + "prevIds": [ + "5fc51ebc-6899-400b-b7fa-72ce65e36c7b" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "attachments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "identities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "rate_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "totps", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "domains", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "channel_counters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "product_counters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "labels", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "inactive_memberships", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "memberships", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notification_preferences", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oauth_clients", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oidc_payloads", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "signing_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "organizations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "projects", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "push_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "requests", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "seen_by", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "api_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "service_accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "system_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "tasks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "tenants", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "user_counters", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "workspaces", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "yjs_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "yjs_updates", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "table_name", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "changed_fields", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "activities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "bindings_version", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'attachment'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'New attachment'", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "keywords", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_by", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "public_bucket", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bucket_name", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "filename", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content_type", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "converted_content_type", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "keys", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "attachments" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id_hash", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notified_at", + "entityType": "columns", + "schema": "public", + "table": "devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'oauth'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuer", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "verified", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connection_id", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used_at", + "entityType": "columns", + "schema": "public", + "table": "identities" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge_hash", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "purpose", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "passkey_challenges" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'desktop'", + "generated": null, + "identity": null, + "name": "device_type", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_os", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "browser", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name_on_device", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "rate_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "points", + "entityType": "columns", + "schema": "public", + "table": "rate_limits" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expire", + "entityType": "columns", + "schema": "public", + "table": "rate_limits" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'regular'", + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'desktop'", + "generated": null, + "identity": null, + "name": "device_type", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_os", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "browser", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_strategy", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_hash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_subnet_hash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(2)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_country", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_asn", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id_hash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_by", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revocation_reason", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "impersonator_session_id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stepped_up_at", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stepped_up_via", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "single_use_token", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identity_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inactive_membership_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_path", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pending_sign_up", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "invoked_at", + "entityType": "columns", + "schema": "public", + "table": "tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used_step", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "totps" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "domain", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "verified", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verification_token", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_checked_at", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "domains" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_key", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "counts", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "channel_counters" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_id", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_type", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "view_count", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_viewed_at", + "entityType": "columns", + "schema": "public", + "table": "product_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'label'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'New label'", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "keywords", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_by", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'secondary'", + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "organization_tracked", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "labels" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_type", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'member'", + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rejected_at", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reminded_at", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workspace_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "inactive_memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_type", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'member'", + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "archived", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "muted", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workspace_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "memberships" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "mention_email", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "comment_email", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'weekly'", + "generated": null, + "identity": null, + "name": "digest", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_digest_at", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "notification_preferences" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "context_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_type", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_id", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "read_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailed_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "digested_at", + "entityType": "columns", + "schema": "public", + "table": "notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_hash", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "redirect_uris", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logo_uri", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_clients" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "grant_id", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "uid", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed_at", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oidc_payloads" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'RS256'", + "generated": null, + "identity": null, + "name": "alg", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_jwk", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_jwk", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retired_at", + "entityType": "columns", + "schema": "public", + "table": "signing_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'organization'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tools_config", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"id\"::text", + "type": "stored" + }, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "short_name", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "country", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "default_language", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[\"en\"]'", + "generated": null, + "identity": null, + "name": "languages", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notification_email", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logo_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "website_url", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "welcome_text", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "chat_support", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "organization_flags", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "setup_config", + "entityType": "columns", + "schema": "public", + "table": "organizations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'project'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tools_config", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"organization_id\"::text || '/' || \"id\"::text", + "type": "stored" + }, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "projects" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "p256dh", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiration_time", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "push_subscriptions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_id", + "entityType": "columns", + "schema": "public", + "table": "requests" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "product_type", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "seen_by" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(4)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last4", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_by", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "bindings", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "oauth_client_id", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "service_accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "system_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'task'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'New task'", + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stx", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "keywords", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_by", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "expandable", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(1000000)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "summary_length", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primary_label_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_order", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "status_changed_at", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "labels", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "assigned_to", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "checkbox_count", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "checked_count", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "attachments", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "tasks" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{\"quotas\":{\"user\":1000,\"organization\":1,\"workspace\":0,\"project\":0,\"task\":0,\"label\":0,\"attachment\":100,\"serviceAccount\":20,\"apiKey\":100},\"rateLimits\":{\"apiPointsPerHour\":1000},\"allowUnregisteredClients\":true}'", + "generated": null, + "identity": null, + "name": "restrictions", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "json", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "auth_strategies", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_id", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "subscription_status", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_plan", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "json", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_data", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "tenants" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "verified", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verified_at", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_verified_via", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_verified_at", + "entityType": "columns", + "schema": "public", + "table": "emails" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_started_at", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sign_in_at", + "entityType": "columns", + "schema": "public", + "table": "user_counters" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "mfa_required", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "user_flags", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'workspace'", + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbnail_url", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "banner_url", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "published_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_at", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "tools_config", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": { + "as": "\"organization_id\"::text || '/' || \"id\"::text", + "type": "stored" + }, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "workspaces" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "yjs_documents" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "always", + "name": "yjs_updates_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_type", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "entity_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "varchar(24)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tenant_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "organization_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activities_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activities_org_id_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "activities_entity_type_subject_id_index", + "entityType": "indexes", + "schema": "public", + "table": "activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_organization_id_seq_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "group_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_group_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "attachments_project_id_index", + "entityType": "indexes", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "notified_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_user_id_notified_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "devices_last_seen_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "identities_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issuer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subject", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "identities_kind_issuer_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "challenge_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkey_challenges_challenge_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "passkeys_credential_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_secret_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "ip_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_user_id_ip_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ip_subnet_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_ip_subnet_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "device_id_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_user_id_device_id_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "impersonator_session_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"impersonator_session_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "sessions_impersonator_session_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_secret_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "single_use_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_single_use_token_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "session_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"session_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "tokens_session_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "totps_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "totps" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "domains_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "domains" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "domain", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "domains_domain_idx", + "entityType": "indexes", + "schema": "public", + "table": "domains" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"mode\" = 'primary' AND \"deleted_at\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_project_primary_slug_unique", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_organization_id_seq_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "labels_project_id_index", + "entityType": "indexes", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_email_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "rejected_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "inactive_memberships_org_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "channel_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "role", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_channel_org_role_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "workspace_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "archived", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_workspace_user_archived_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "archived", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_project_user_archived_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "memberships_org_user_tenant_idx", + "entityType": "indexes", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "activity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_activity_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "read_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_unread_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_user_created_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subject_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "notifications_subject_index", + "entityType": "indexes", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "grant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_grant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_account_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "uid", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_uid_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oidc_payloads_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "oidc_payloads" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "signing_keys_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "signing_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" in ('current', 'next')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "signing_keys_one_per_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "signing_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_name_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "organizations_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_name_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "projects_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "push_subscriptions_user_index", + "entityType": "indexes", + "schema": "public", + "table": "push_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "requests_emails", + "entityType": "indexes", + "schema": "public", + "table": "requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "requests_created_at", + "entityType": "indexes", + "schema": "public", + "table": "requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lower(\"email\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"type\" in ('waitlist', 'newsletter')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "requests_unique_signup_email_type", + "entityType": "indexes", + "schema": "public", + "table": "requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "product_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_user_product_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "channel_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "product_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_user_channel_type_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "product_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_product_id_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "seen_by_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_hash_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_actor_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "service_accounts_tenant_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_organization_id_seq_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_project_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "project_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_project_status_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primary_label_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tasks_primary_label_id_index", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "labels", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "idx_tasks_labels_gin", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigned_to", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "idx_tasks_assigned_to_gin", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "attachments", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "idx_tasks_attachments_gin", + "entityType": "indexes", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_status_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subscription_status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "tenants_subscription_status_index", + "entityType": "indexes", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "emails_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "emails" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_name_index", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "email", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_email_index", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "users_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_name_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_created_at_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_tenant_id_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_organization_id_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_created_by_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "workspaces_updated_by_index", + "entityType": "indexes", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_docs_tenant", + "entityType": "indexes", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "organization_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_docs_org", + "entityType": "indexes", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "entity_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "entity_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_updates_doc", + "entityType": "indexes", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tenant_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_yjs_updates_tenant", + "entityType": "indexes", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "attachments_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "attachments_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "attachments_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "deleted_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "attachments_deleted_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "attachments_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "attachments_Ytb3N4m0pWsH_fkey", + "entityType": "fks", + "schema": "public", + "table": "attachments" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "devices_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "devices" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "oauth_accounts_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "identities" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "passkey_challenges_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "passkeys_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "revoked_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "sessions_revoked_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "impersonator_session_id" + ], + "schemaTo": "public", + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_impersonator_session_id_sessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "identity_id" + ], + "schemaTo": "public", + "tableTo": "identities", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_oauth_account_id_oauth_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "session_id" + ], + "schemaTo": "public", + "tableTo": "sessions", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_session_id_sessions_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tokens_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tokens" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "totps_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "totps" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "domains_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "domains" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "labels_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "labels_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "labels_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "deleted_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "labels_deleted_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "labels_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "labels_5EdeIYCO2iQy_fkey", + "entityType": "fks", + "schema": "public", + "table": "labels" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "inactive_memberships_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "workspace_id" + ], + "schemaTo": "public", + "tableTo": "workspaces", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_workspace_id_workspaces_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "inactive_memberships_gmmCA2ACknk4_fkey", + "entityType": "fks", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "memberships_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "memberships_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "memberships_updated_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "workspace_id" + ], + "schemaTo": "public", + "tableTo": "workspaces", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_workspace_id_workspaces_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "memberships_Yta3VHtyCTj4_fkey", + "entityType": "fks", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notification_preferences_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notification_preferences" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "notifications_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "notifications" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "oauth_clients_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "oauth_clients" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "organizations_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "organizations_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "organizations_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "projects_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "projects_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "projects_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "projects_UVK9MNjFoIk5_fkey", + "entityType": "fks", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "push_subscriptions_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "push_subscriptions" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "seen_by_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "seen_by" + }, + { + "nameExplicit": false, + "columns": [ + "actor_id" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "api_keys_actor_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "api_keys_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "revoked_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "api_keys_revoked_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "api_keys_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": false, + "columns": [ + "id" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "service_accounts_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "service_accounts_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "oauth_client_id" + ], + "schemaTo": "public", + "tableTo": "oauth_clients", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "service_accounts_oauth_client_id_oauth_clients_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "service_accounts_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "service_accounts_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "service_accounts" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "system_roles_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "system_roles" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "tasks_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tasks_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tasks_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "deleted_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tasks_deleted_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "project_id" + ], + "schemaTo": "public", + "tableTo": "projects", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tasks_project_id_projects_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "tasks_tenant_id_organization_id_organizations_tenant_id_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tasks" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "tenants_created_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "tenants" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "emails_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "emails" + }, + { + "nameExplicit": false, + "columns": [ + "id" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "users_id_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "users_updated_by_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "workspaces_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "created_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "workspaces_created_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "updated_by" + ], + "schemaTo": "public", + "tableTo": "actors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "workspaces_updated_by_actors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "workspaces_mcfltaO23EEl_fkey", + "entityType": "fks", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "yjs_documents_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "yjs_documents_HY8MgE0sbYNM_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_documents" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id" + ], + "schemaTo": "public", + "tableTo": "tenants", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "yjs_updates_tenant_id_tenants_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_updates" + }, + { + "nameExplicit": false, + "columns": [ + "tenant_id", + "organization_id" + ], + "schemaTo": "public", + "tableTo": "organizations", + "columnsTo": [ + "tenant_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "yjs_updates_Yt7qMI3dLu4u_fkey", + "entityType": "fks", + "schema": "public", + "table": "yjs_updates" + }, + { + "columns": [ + "id", + "created_at" + ], + "nameExplicit": false, + "name": "activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "activities" + }, + { + "columns": [ + "user_id", + "device_id_hash" + ], + "nameExplicit": false, + "name": "devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "devices" + }, + { + "columns": [ + "id", + "created_at" + ], + "nameExplicit": false, + "name": "notifications_pkey", + "entityType": "pks", + "schema": "public", + "table": "notifications" + }, + { + "columns": [ + "type", + "id" + ], + "nameExplicit": false, + "name": "oidc_payloads_pkey", + "entityType": "pks", + "schema": "public", + "table": "oidc_payloads" + }, + { + "columns": [ + "id", + "created_at" + ], + "nameExplicit": false, + "name": "seen_by_pkey", + "entityType": "pks", + "schema": "public", + "table": "seen_by" + }, + { + "columns": [ + "entity_type", + "entity_id" + ], + "nameExplicit": false, + "name": "yjs_documents_pkey", + "entityType": "pks", + "schema": "public", + "table": "yjs_documents" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "attachments_pkey", + "schema": "public", + "table": "attachments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "oauth_accounts_pkey", + "schema": "public", + "table": "identities", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "passkey_challenges_pkey", + "schema": "public", + "table": "passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "passkeys_pkey", + "schema": "public", + "table": "passkeys", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "rate_limits_pkey", + "schema": "public", + "table": "rate_limits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tokens_pkey", + "schema": "public", + "table": "tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "totps_pkey", + "schema": "public", + "table": "totps", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "domains_pkey", + "schema": "public", + "table": "domains", + "entityType": "pks" + }, + { + "columns": [ + "channel_key" + ], + "nameExplicit": false, + "name": "context_counters_pkey", + "schema": "public", + "table": "channel_counters", + "entityType": "pks" + }, + { + "columns": [ + "product_id" + ], + "nameExplicit": false, + "name": "product_counters_pkey", + "schema": "public", + "table": "product_counters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "labels_pkey", + "schema": "public", + "table": "labels", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "inactive_memberships_pkey", + "schema": "public", + "table": "inactive_memberships", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "memberships_pkey", + "schema": "public", + "table": "memberships", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "notification_preferences_pkey", + "schema": "public", + "table": "notification_preferences", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "oauth_clients_pkey", + "schema": "public", + "table": "oauth_clients", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "signing_keys_pkey", + "schema": "public", + "table": "signing_keys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "organizations_pkey", + "schema": "public", + "table": "organizations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "projects_pkey", + "schema": "public", + "table": "projects", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "push_subscriptions_pkey", + "schema": "public", + "table": "push_subscriptions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "requests_pkey", + "schema": "public", + "table": "requests", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "api_keys_pkey", + "schema": "public", + "table": "api_keys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "service_accounts_pkey", + "schema": "public", + "table": "service_accounts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "system_roles_pkey", + "schema": "public", + "table": "system_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tasks_pkey", + "schema": "public", + "table": "tasks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "tenants_pkey", + "schema": "public", + "table": "tenants", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "emails_pkey", + "schema": "public", + "table": "emails", + "entityType": "pks" + }, + { + "columns": [ + "user_id" + ], + "nameExplicit": false, + "name": "user_counters_pkey", + "schema": "public", + "table": "user_counters", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspaces_pkey", + "schema": "public", + "table": "workspaces", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "yjs_updates_pkey", + "schema": "public", + "table": "yjs_updates", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "email", + "channel_id" + ], + "nullsNotDistinct": false, + "name": "inactive_memberships_tenant_email_ctx", + "entityType": "uniques", + "schema": "public", + "table": "inactive_memberships" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "user_id", + "channel_id" + ], + "nullsNotDistinct": false, + "name": "memberships_unique_channel", + "entityType": "uniques", + "schema": "public", + "table": "memberships" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id" + ], + "nullsNotDistinct": false, + "name": "organizations_tenant_id_key", + "entityType": "uniques", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "id" + ], + "nullsNotDistinct": false, + "name": "organizations_tenant_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "organizations" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "id" + ], + "nullsNotDistinct": false, + "name": "projects_tenant_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "projects" + }, + { + "nameExplicit": true, + "columns": [ + "tenant_id", + "id" + ], + "nullsNotDistinct": false, + "name": "workspaces_tenant_id_unique", + "entityType": "uniques", + "schema": "public", + "table": "workspaces" + }, + { + "nameExplicit": false, + "columns": [ + "domain" + ], + "nullsNotDistinct": false, + "name": "domains_domain_key", + "schema": "public", + "table": "domains", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "organizations_slug_key", + "schema": "public", + "table": "organizations", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "projects_slug_key", + "schema": "public", + "table": "projects", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "endpoint" + ], + "nullsNotDistinct": false, + "name": "push_subscriptions_endpoint_key", + "schema": "public", + "table": "push_subscriptions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "nullsNotDistinct": false, + "name": "system_roles_user_id_key", + "schema": "public", + "table": "system_roles", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "emails_email_key", + "schema": "public", + "table": "emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "users_slug_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "nullsNotDistinct": false, + "name": "workspaces_slug_key", + "schema": "public", + "table": "workspaces", + "entityType": "uniques" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"attachments\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n AND (\"attachments\".\"deleted_at\" IS NULL OR current_setting('app.include_deleted', true) = 'true')\n ", + "withCheck": null, + "name": "attachments_select_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "attachments_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "attachments_update_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "attachments_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "attachments" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"labels\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n AND (\"labels\".\"deleted_at\" IS NULL OR current_setting('app.include_deleted', true) = 'true')\n ", + "withCheck": null, + "name": "labels_select_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "labels_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "labels_update_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "labels_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "labels" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"tasks\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n AND (\"tasks\".\"deleted_at\" IS NULL OR current_setting('app.include_deleted', true) = 'true')\n ", + "withCheck": null, + "name": "tasks_select_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "tasks_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "tasks_update_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "tasks_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "tasks" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"yjs_documents\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n \n ", + "withCheck": null, + "name": "yjs_documents_select_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "yjs_documents_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_documents_update_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_documents_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_documents" + }, + { + "as": "PERMISSIVE", + "for": "SELECT", + "roles": [ + "public" + ], + "using": "\n \n COALESCE(current_setting('app.tenant_id', true), '') != ''\n AND \"yjs_updates\".\"tenant_id\" = current_setting('app.tenant_id', true)::text\n\n \n ", + "withCheck": null, + "name": "yjs_updates_select_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + }, + { + "as": "PERMISSIVE", + "for": "INSERT", + "roles": [ + "public" + ], + "using": null, + "withCheck": "true", + "name": "yjs_updates_insert_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + }, + { + "as": "PERMISSIVE", + "for": "UPDATE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_updates_update_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + }, + { + "as": "PERMISSIVE", + "for": "DELETE", + "roles": [ + "public" + ], + "using": "true", + "withCheck": null, + "name": "yjs_updates_delete_policy", + "entityType": "policies", + "schema": "public", + "table": "yjs_updates" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/backend/emails/README.md b/backend/emails/README.md index 18f76bcf1..07d56cf9e 100644 --- a/backend/emails/README.md +++ b/backend/emails/README.md @@ -9,7 +9,14 @@ A template is a `defineEmailTemplate()` definition with two parts: - `translate(lng, statics)`: pure function returning every translated string (plus any pass-through statics the component needs). Must include `subject`. - `component(props)`: a dumb React shell built from `components/` and `components/primitives/`. No i18n calls. -`defineEmailTemplate` types `component()` to exactly what `translate()` returns (plus per-recipient placeholder strings), so the two cannot drift. Each definition carries a `preview: { statics, recipient }` field with sample data, type-checked against its own props. Export the template from [index.ts](index.ts) and register its preview slug in [preview-fixtures.ts](preview-fixtures.ts). +`defineEmailTemplate` types `component()` to exactly what `translate()` returns (plus per-recipient placeholder strings), so the two cannot drift. Each definition carries a `preview: { statics, recipient }` field with sample data, type-checked against its own props. Export the template from [index.ts](index.ts), register its preview slug in [preview-fixtures.ts](preview-fixtures.ts) and add a story for it in `frontend/src/stories/email-templates.stories.tsx`. + +Two components carry the shared markup: + +- `EmailMessage` is the whole email for a standard message: greeting, one translated HTML paragraph, an action button and a fine-print note, each optional except the paragraph. Most templates are only this. +- `EmailLayout` is the frame around every email: sender avatar, header, body panel, unsubscribe link, logo and footer. A template whose body is more than one paragraph (welcome, newsletter, digest) passes its own children to it. + +Headers are translated HTML, like bodies. Add a prop to either component only when a template needs it. ## Escaping diff --git a/backend/emails/components/email-avatar.tsx b/backend/emails/components/email-avatar.tsx index b245f9e70..ac725128f 100644 --- a/backend/emails/components/email-avatar.tsx +++ b/backend/emails/components/email-avatar.tsx @@ -11,10 +11,10 @@ export const EmailAvatar = ({ name, type = 'user' }: { name?: string | null; typ let initials = 'U'; if (name) { const words = name.split(' '); - initials = - words.length > 1 - ? words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase() - : words[0].charAt(0).toUpperCase(); + initials = words + .slice(0, 2) + .map((word) => word.charAt(0).toUpperCase()) + .join(''); } if (type === 'organization') initials = 'O'; @@ -37,5 +37,3 @@ export const EmailAvatar = ({ name, type = 'user' }: { name?: string | null; typ ); }; - -export const Template = EmailAvatar; diff --git a/backend/emails/components/email-body.tsx b/backend/emails/components/email-body.tsx index 54da92db6..abca18b66 100644 --- a/backend/emails/components/email-body.tsx +++ b/backend/emails/components/email-body.tsx @@ -26,5 +26,3 @@ export const EmailBody = ({ children }: { children: React.ReactNode }): JSX.Elem ); - -export const Template = EmailBody; diff --git a/backend/emails/components/email-button.tsx b/backend/emails/components/email-button.tsx index 42c413754..be1b95423 100644 --- a/backend/emails/components/email-button.tsx +++ b/backend/emails/components/email-button.tsx @@ -22,5 +22,3 @@ export const EmailButton = ({ ButtonText, href }: { ButtonText: string; href: st ); - -export const Template = EmailButton; diff --git a/backend/emails/components/email-container.tsx b/backend/emails/components/email-container.tsx index e4ad04a6c..3da43be55 100644 --- a/backend/emails/components/email-container.tsx +++ b/backend/emails/components/email-container.tsx @@ -8,13 +8,7 @@ interface EmailContainerProps { children: React.ReactNode; } -export const EmailContainer = ({ - previewText, - bodyStyle, - containerStyle, - headChildren, - children, -}: EmailContainerProps) => ( +export const EmailContainer = ({ previewText, bodyStyle, containerStyle, headChildren, children }: EmailContainerProps) => ( {headChildren} {previewText} @@ -27,11 +21,7 @@ export const EmailContainer = ({ ...bodyStyle, }} > - - {children} - + {children} ); - -export const Template = EmailContainer; diff --git a/backend/emails/components/email-footer.tsx b/backend/emails/components/email-footer.tsx index 08c51c22a..0c793f355 100644 --- a/backend/emails/components/email-footer.tsx +++ b/backend/emails/components/email-footer.tsx @@ -14,18 +14,9 @@ export const EmailFooter = ({ supportText }: { supportText: string }) => ( padding: '0 1.5rem', }} > - {appConfig.name}・{appConfig.company.streetAddress}・{appConfig.company.city}・{appConfig.company.country},{' '} - {appConfig.company.postcode}・ - + {appConfig.name}・{appConfig.company.streetAddress}・{appConfig.company.city}・{appConfig.company.country}, {appConfig.company.postcode}・ + {supportText} ); - -export const Template = EmailFooter; diff --git a/backend/emails/components/email-header.tsx b/backend/emails/components/email-header.tsx index e022c4f20..6150fb4c7 100644 --- a/backend/emails/components/email-header.tsx +++ b/backend/emails/components/email-header.tsx @@ -15,5 +15,3 @@ export const EmailHeader = ({ headerText }: { headerText: string | React.ReactNo {typeof headerText === 'string' ?
{headerText}
: headerText} ); - -export const Template = EmailHeader; diff --git a/backend/emails/components/email-layout.tsx b/backend/emails/components/email-layout.tsx new file mode 100644 index 000000000..13baf85fd --- /dev/null +++ b/backend/emails/components/email-layout.tsx @@ -0,0 +1,50 @@ +import { avatarRowStyle, smallTextStyle } from '../styles'; +import { EmailAvatar } from './email-avatar'; +import { EmailBody } from './email-body'; +import { EmailContainer } from './email-container'; +import { EmailFooter } from './email-footer'; +import { EmailHeader } from './email-header'; +import { EmailLogo } from './email-logo'; +import { Column, Link, Row } from './primitives'; +import { SafeHtml } from './safe-html'; + +export interface EmailLayoutProps { + previewText: string; + /** Translated HTML: the i18n instance escaped every interpolated value. */ + headerHtml?: string; + /** Drawn as an initials avatar above the header. */ + avatarName?: string; + /** Widens the column for long content, such as a newsletter or a digest. */ + wide?: boolean; + headChildren?: React.ReactNode; + /** Rendered as the last line of the body panel. */ + unsubscribe?: { label: string; href: string }; + supportText: string; + children: React.ReactNode; +} + +/** The frame every email shares: optional avatar and header, the body panel, logo and footer. */ +export const EmailLayout = ({ previewText, headerHtml, avatarName, wide, headChildren, unsubscribe, supportText, children }: EmailLayoutProps) => ( + + {avatarName && ( + + + + + + )} + {headerHtml && } />} + + {children} + {unsubscribe && ( +
+ + {unsubscribe.label} + +
+ )} +
+ + +
+); diff --git a/backend/emails/components/email-logo.tsx b/backend/emails/components/email-logo.tsx index 23c075211..f65be20d3 100644 --- a/backend/emails/components/email-logo.tsx +++ b/backend/emails/components/email-logo.tsx @@ -13,26 +13,14 @@ export const EmailLogo = ({ style }: { style?: React.CSSProperties }): JSX.Eleme href={appConfig.aboutUrl} target="_blank" rel="noreferrer" - style={{ - display: 'inline-block', - marginTop: '2rem', - textDecoration: 'none', - ...style, - }} + style={{ display: 'inline-block', marginTop: '2rem', textDecoration: 'none', ...style }} > {appConfig.name} ); - -export const Template = EmailLogo; diff --git a/backend/emails/components/email-message.tsx b/backend/emails/components/email-message.tsx new file mode 100644 index 000000000..7bc9e0b7b --- /dev/null +++ b/backend/emails/components/email-message.tsx @@ -0,0 +1,27 @@ +import { greetingStyle, noteStyle } from '../styles'; +import { EmailButton } from './email-button'; +import { EmailLayout, type EmailLayoutProps } from './email-layout'; +import { EmailText } from './email-text'; +import { SafeHtml } from './safe-html'; + +interface EmailMessageProps extends Omit { + /** Left out when empty. */ + greeting?: string; + /** Translated HTML: the i18n instance escaped every interpolated value. */ + bodyHtml: string; + /** Plain-text fine print under the action, such as when a link expires. */ + note?: string; + action?: { label: string; href: string }; +} + +/** The standard transactional email: greeting, one translated paragraph, an optional action and a note. */ +export const EmailMessage = ({ greeting, bodyHtml, note, action, ...layout }: EmailMessageProps) => ( + + {greeting && {greeting}} + + + + {action && } + {note && {note}} + +); diff --git a/backend/emails/components/email-text.tsx b/backend/emails/components/email-text.tsx index 172818f08..63db00a8d 100644 --- a/backend/emails/components/email-text.tsx +++ b/backend/emails/components/email-text.tsx @@ -5,11 +5,5 @@ import { Text as JsxText } from './primitives'; * with our preferred email typography defaults. */ export const EmailText = ({ style, ...props }: React.ComponentProps) => ( - + ); - -export const Template = EmailText; diff --git a/backend/emails/components/index.ts b/backend/emails/components/index.ts index 790fd32a0..47a7d9a80 100644 --- a/backend/emails/components/index.ts +++ b/backend/emails/components/index.ts @@ -4,6 +4,8 @@ export { EmailButton } from './email-button'; export { EmailContainer } from './email-container'; export { EmailFooter } from './email-footer'; export { EmailHeader } from './email-header'; +export { EmailLayout } from './email-layout'; export { EmailLogo } from './email-logo'; +export { EmailMessage } from './email-message'; export { EmailText } from './email-text'; export { SafeHtml, type SafeHtmlPolicy } from './safe-html'; diff --git a/backend/emails/components/primitives/button.tsx b/backend/emails/components/primitives/button.tsx index 50f191df9..d0a4e5cd5 100644 --- a/backend/emails/components/primitives/button.tsx +++ b/backend/emails/components/primitives/button.tsx @@ -48,10 +48,7 @@ export const Button: JsxEmailComponent = ({ width: '100%', } as const; - const borderStyles = { - border: `${borderSize}px solid ${borderColor}`, - msoBorderAlt: 'none', - }; + const borderStyles = { border: `${borderSize}px solid ${borderColor}`, msoBorderAlt: 'none' }; const propStyles = { ...(borderColor ? borderStyles : {}), @@ -60,14 +57,7 @@ export const Button: JsxEmailComponent = ({ }; return ( - +
{/* VML Fallback for mso clients; raw HTML, so the link and text are escaped here */} @@ -97,10 +87,7 @@ export const Button: JsxEmailComponent = ({ cellPadding={0} cellSpacing={0} role="presentation" - style={{ - border: `${borderSize ?? '0'}px solid ${borderColor ?? 'inherit'}`, - borderRadius, - }} + style={{ border: `${borderSize ?? '0'}px solid ${borderColor ?? 'inherit'}`, borderRadius }} >
= ({ bgcolor={backgroundColor} width={width} height={height} - style={{ - borderRadius, - height, - maxWidth: width, - textAlign: 'center', - width, - }} + style={{ borderRadius, height, maxWidth: width, textAlign: 'center', width }} > = ({
) : ( - + {children} )} diff --git a/backend/emails/components/primitives/conditional.tsx b/backend/emails/components/primitives/conditional.tsx index 3d62435e0..b9843e7dd 100644 --- a/backend/emails/components/primitives/conditional.tsx +++ b/backend/emails/components/primitives/conditional.tsx @@ -5,11 +5,7 @@ declare module 'react/jsx-runtime' { namespace JSX { interface IntrinsicElements { 'jsx-email-cond': React.DetailedHTMLProps< - React.HTMLAttributes & { - 'data-expression'?: string; - 'data-head'?: boolean; - 'data-mso'?: boolean; - }, + React.HTMLAttributes & { 'data-expression'?: string; 'data-head'?: boolean; 'data-mso'?: boolean }, HTMLElement >; } diff --git a/backend/emails/components/primitives/container.tsx b/backend/emails/components/primitives/container.tsx index 69a67e349..26dfd4866 100644 --- a/backend/emails/components/primitives/container.tsx +++ b/backend/emails/components/primitives/container.tsx @@ -30,10 +30,7 @@ export const Container: JsxEmailComponent = ({ cellPadding="0" border={0} {...props} - style={{ - ...(disableDefaultStyle ? {} : { maxWidth: `${containerWidth}px` }), - ...style, - }} + style={{ ...(disableDefaultStyle ? {} : { maxWidth: `${containerWidth}px` }), ...style }} > @@ -41,11 +38,7 @@ export const Container: JsxEmailComponent = ({ - ', - }} - /> + ' }} /> ); diff --git a/backend/emails/components/primitives/head.tsx b/backend/emails/components/primitives/head.tsx index 4677e9020..228b3ff9c 100644 --- a/backend/emails/components/primitives/head.tsx +++ b/backend/emails/components/primitives/head.tsx @@ -11,14 +11,10 @@ export const Head: JsxEmailComponent = ({ children, enableFormatDetec - {!enableFormatDetection && ( - - )} + {!enableFormatDetection && } - {!enableFormatDetection && ( - - )} + {!enableFormatDetection && } {children} diff --git a/backend/emails/components/primitives/heading.tsx b/backend/emails/components/primitives/heading.tsx index e38ccca8a..2093357f4 100644 --- a/backend/emails/components/primitives/heading.tsx +++ b/backend/emails/components/primitives/heading.tsx @@ -42,19 +42,7 @@ export const withMargin = (props: Margin) => { return mergedStyles; }; -export const Heading: JsxEmailComponent = ({ - as: Tag = 'h1', - children, - style, - m, - mx, - my, - mt, - mr, - mb, - ml, - ...props -}) => ( +export const Heading: JsxEmailComponent = ({ as: Tag = 'h1', children, style, m, mx, my, mt, mr, mb, ml, ...props }) => ( {children} diff --git a/backend/emails/components/primitives/hr.tsx b/backend/emails/components/primitives/hr.tsx index 0778456b3..eecb31d29 100644 --- a/backend/emails/components/primitives/hr.tsx +++ b/backend/emails/components/primitives/hr.tsx @@ -3,21 +3,8 @@ import type { BaseProps, JsxEmailComponent } from '../../renderer/types.js'; export interface HrProps extends BaseProps<'hr'> {} export const Hr: JsxEmailComponent = ({ disableDefaultStyle, style, ...props }) => { - return ( -
- ); + const defaultStyle = disableDefaultStyle ? {} : { border: 'none', borderTop: '1px solid #eaeaea', width: '100%' }; + return
; }; Hr.displayName = 'Hr'; diff --git a/backend/emails/components/primitives/html.tsx b/backend/emails/components/primitives/html.tsx index bb4c61343..2854ca494 100644 --- a/backend/emails/components/primitives/html.tsx +++ b/backend/emails/components/primitives/html.tsx @@ -4,23 +4,12 @@ export interface HtmlProps extends BaseProps<'html'> { enableVML?: boolean; } -export const Html: JsxEmailComponent = ({ - children, - lang = 'en', - dir = 'ltr', - enableVML = true, - ...props -}) => ( +export const Html: JsxEmailComponent = ({ children, lang = 'en', dir = 'ltr', enableVML = true, ...props }) => ( {children} diff --git a/backend/emails/components/primitives/img.tsx b/backend/emails/components/primitives/img.tsx index e735125f5..c465dd82f 100644 --- a/backend/emails/components/primitives/img.tsx +++ b/backend/emails/components/primitives/img.tsx @@ -3,19 +3,8 @@ import type { BaseProps, JsxEmailComponent } from '../../renderer/types.js'; export interface ImgProps extends BaseProps<'img'> {} export const Img: JsxEmailComponent = ({ alt, disableDefaultStyle, height, src, style, width, ...props }) => { - return ( - {alt} - ); + const defaultStyle = disableDefaultStyle ? {} : { border: 'none', display: 'block', outline: 'none', textDecoration: 'none' }; + return {alt}; }; Img.displayName = 'Img'; diff --git a/backend/emails/components/primitives/link.tsx b/backend/emails/components/primitives/link.tsx index c559743be..052f7649f 100644 --- a/backend/emails/components/primitives/link.tsx +++ b/backend/emails/components/primitives/link.tsx @@ -5,16 +5,8 @@ type RootProps = BaseProps<'a'>; export interface LinkProps extends RootProps {} export const Link: JsxEmailComponent = ({ disableDefaultStyle, style, target, ...props }) => { - return ( - - ); + const defaultStyle = disableDefaultStyle ? {} : { color: '#067df7', textDecoration: 'none' }; + return ; }; Link.displayName = 'Link'; diff --git a/backend/emails/components/primitives/raw.tsx b/backend/emails/components/primitives/raw.tsx index 09834c048..e13c48615 100644 --- a/backend/emails/components/primitives/raw.tsx +++ b/backend/emails/components/primitives/raw.tsx @@ -6,12 +6,7 @@ declare module 'react/jsx-runtime' { namespace JSX { interface IntrinsicElements { 'jsx-email-raw': React.DetailedHTMLProps< - React.HTMLAttributes & { - dangerouslySetInnerHTML?: { - __html: string; - }; - 'data-skip'?: string; - }, + React.HTMLAttributes & { dangerouslySetInnerHTML?: { __html: string }; 'data-skip'?: string }, HTMLElement >; } diff --git a/backend/emails/components/primitives/row.tsx b/backend/emails/components/primitives/row.tsx index 6bd567f92..3892765d1 100644 --- a/backend/emails/components/primitives/row.tsx +++ b/backend/emails/components/primitives/row.tsx @@ -4,22 +4,11 @@ export interface RowProps extends BaseProps<'table'> {} export const Row: JsxEmailComponent = ({ children, disableDefaultStyle, style, ...props }) => { if (props.cellPadding || props.cellSpacing) { - console.warn( - 'Use of the `cellPadding` and `cellSpacing` properties are discouraged due to inconsistencies between email clients', - ); + console.warn('Use of the `cellPadding` and `cellSpacing` properties are discouraged due to inconsistencies between email clients'); } return ( - +
{children} diff --git a/backend/emails/components/primitives/section.tsx b/backend/emails/components/primitives/section.tsx index 7460033fd..890f901af 100644 --- a/backend/emails/components/primitives/section.tsx +++ b/backend/emails/components/primitives/section.tsx @@ -3,16 +3,7 @@ import type { BaseProps, JsxEmailComponent } from '../../renderer/types.js'; export interface SectionProps extends Omit, 'cellPadding' | 'cellSpacing'> {} export const Section: JsxEmailComponent = ({ children, style, ...props }) => ( -
+
diff --git a/backend/emails/components/primitives/text.tsx b/backend/emails/components/primitives/text.tsx index 987d248f1..e5e987bc2 100644 --- a/backend/emails/components/primitives/text.tsx +++ b/backend/emails/components/primitives/text.tsx @@ -3,15 +3,8 @@ import type { BaseProps, JsxEmailComponent } from '../../renderer/types.js'; export interface TextProps extends BaseProps<'p'> {} export const Text: JsxEmailComponent = ({ disableDefaultStyle, style, ...props }) => { - return ( -

- ); + const defaultStyle = disableDefaultStyle ? {} : { fontSize: '14px', lineHeight: '24px', margin: '16px 0' }; + return

; }; Text.displayName = 'Text'; diff --git a/backend/emails/components/safe-html.tsx b/backend/emails/components/safe-html.tsx index dc87506ad..aa45dd9b7 100644 --- a/backend/emails/components/safe-html.tsx +++ b/backend/emails/components/safe-html.tsx @@ -3,14 +3,9 @@ import sanitizeHtml, { type IOptions } from 'sanitize-html'; /** Short translated strings: inline emphasis, line break and safe links only. */ const inlinePolicy: IOptions = { allowedTags: ['strong', 'em', 'b', 'i', 'u', 'br', 'span', 'a'], - allowedAttributes: { - a: ['href', 'rel', 'target'], - span: ['style'], - }, + allowedAttributes: { a: ['href', 'rel', 'target'], span: ['style'] }, allowedSchemes: ['http', 'https', 'mailto'], - transformTags: { - a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }), - }, + transformTags: { a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }) }, }; /** Long-form rich text: wider tag set, URL schemes still restricted to safe values. */ @@ -59,9 +54,7 @@ const richTextPolicy: IOptions = { td: ['colspan', 'rowspan'], }, allowedSchemes: ['http', 'https', 'mailto', 'data'], - transformTags: { - a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }), - }, + transformTags: { a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }) }, }; const policies = { inline: inlinePolicy, richText: richTextPolicy } as const; @@ -88,5 +81,3 @@ export const SafeHtml = ({ html, policy, as: Tag = 'span', className }: SafeHtml // biome-ignore lint/security/noDangerouslySetInnerHtml: input is sanitized via sanitize-html allowlist policy return ; }; - -export const Template = SafeHtml; diff --git a/backend/emails/preview-fixtures.ts b/backend/emails/preview-fixtures.ts index 83c8f8d0e..7003d1770 100644 --- a/backend/emails/preview-fixtures.ts +++ b/backend/emails/preview-fixtures.ts @@ -1,3 +1,6 @@ +import { commentEmail } from '#/modules/notification/emails/comment-email'; +import { digestEmail } from '#/modules/notification/emails/digest-email'; +import { mentionEmail } from '#/modules/notification/emails/mention-email'; import { accountExistsEmail, accountSecurityEmail, @@ -38,6 +41,9 @@ const previewTemplates = { 'request-was-sent': requestResponseEmail, 'request-was-sent-admin': requestInfoEmail, 'step-up': stepUpEmail, + mention: mentionEmail, + comment: commentEmail, + digest: digestEmail, // biome-ignore lint/suspicious/noExplicitAny: registry holds defs with differing generic params } satisfies Record>; diff --git a/backend/emails/preview-route.ts b/backend/emails/preview-route.ts index f7d188059..4c6b21545 100644 --- a/backend/emails/preview-route.ts +++ b/backend/emails/preview-route.ts @@ -5,8 +5,7 @@ import { renderEmailPreview } from './render-preview'; const app = new Hono(); -const isPreviewName = (value: string): value is (typeof emailPreviewNames)[number] => - Object.hasOwn(emailPreviewFixtures, value); +const isPreviewName = (value: string): value is (typeof emailPreviewNames)[number] => Object.hasOwn(emailPreviewFixtures, value); app.get('/', (c) => { const rows = emailPreviewNames diff --git a/backend/emails/render-preview.ts b/backend/emails/render-preview.ts index 9fc2f5d52..522055858 100644 --- a/backend/emails/render-preview.ts +++ b/backend/emails/render-preview.ts @@ -9,10 +9,7 @@ export interface RenderEmailPreviewOptions { } /** Uses the real render pipeline, so preview output matches what the mailer sends. */ -export async function renderEmailPreview( - name: EmailPreviewName, - { lng, placeholders = false }: RenderEmailPreviewOptions, -) { +export async function renderEmailPreview(name: EmailPreviewName, { lng, placeholders = false }: RenderEmailPreviewOptions) { // The cast to the loose fixture type stops `translate`'s parameter collapsing to `never`. const fixture = emailPreviewFixtures[name] as EmailPreviewFixture | undefined; if (!fixture) throw new Error(`Unknown email preview: ${name}`); @@ -21,9 +18,7 @@ export async function renderEmailPreview( const { subject, ...componentProps } = translated; const recipientProps = placeholders - ? Object.fromEntries( - Object.keys(fixture.recipient).map((key) => [key, brevoPlaceholder(key, { ...fixture.def.htmlParams })]), - ) + ? Object.fromEntries(Object.keys(fixture.recipient).map((key) => [key, brevoPlaceholder(key, { ...fixture.def.htmlParams })])) : fixture.recipient; const html = await render(fixture.def.component({ ...componentProps, ...recipientProps })); diff --git a/backend/emails/renderer/constants.ts b/backend/emails/renderer/constants.ts index 37d8791b6..ad56bbc66 100644 --- a/backend/emails/renderer/constants.ts +++ b/backend/emails/renderer/constants.ts @@ -60,19 +60,4 @@ export const BooleanAttributes = new Set([ export const EmptyObject = Object.freeze({}); // https://www.w3.org/TR/html/syntax.html#void-elements -export const VoidElements = new Set([ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -]); +export const VoidElements = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']); diff --git a/backend/emails/renderer/jsx-to-string.ts b/backend/emails/renderer/jsx-to-string.ts index 45aa364b5..08c052d44 100644 --- a/backend/emails/renderer/jsx-to-string.ts +++ b/backend/emails/renderer/jsx-to-string.ts @@ -131,12 +131,7 @@ export async function jsxToString(element: ReactNode): Promise { return suspenseResult; } } else if (isReactForwardRef(type)) { - return jsxToString( - (type as { render: (props: unknown, ref: unknown) => ReactNode }).render( - props, - (props as { ref: unknown }).ref, - ), - ); + return jsxToString(type.render(props, (props as { ref: unknown }).ref)); } else if ((type as { $$typeof?: symbol }).$$typeof) { const key = Symbol.keyFor((type as { $$typeof: symbol }).$$typeof); if (key === 'react.provider') { @@ -156,9 +151,7 @@ function isIterable(node: unknown): node is Iterable { return typeof node === 'object' && node !== null && Symbol.iterator in node; } -function isReactForwardRef( - type: unknown, -): type is { $$typeof: symbol; render: (props: unknown, ref: unknown) => ReactNode } { +function isReactForwardRef(type: unknown): type is { $$typeof: symbol; render: (props: unknown, ref: unknown) => ReactNode } { return ( typeof type === 'object' && type !== null && diff --git a/backend/emails/renderer/render.ts b/backend/emails/renderer/render.ts index ed1c61061..b15c47234 100644 --- a/backend/emails/renderer/render.ts +++ b/backend/emails/renderer/render.ts @@ -27,11 +27,7 @@ export const renderPlainText = async (component: React.ReactElement, options?: P { format: 'skip', selector: 'img' }, { format: 'skip', selector: '[data-skip="true"]' }, { options: { linkBrackets: false }, selector: 'a' }, - { - format: 'raw', - options: {}, - selector: 'jsx-email-raw', - }, + { format: 'raw', options: {}, selector: 'jsx-email-raw' }, ...(selectors || []), ], ...options, @@ -39,16 +35,14 @@ export const renderPlainText = async (component: React.ReactElement, options?: P }; export const render = async (component: React.ReactElement, options?: RenderOptions) => { - if (options?.plainText) - return renderPlainText(component, typeof options.plainText === 'object' ? options.plainText : {}); + if (options?.plainText) return renderPlainText(component, typeof options.plainText === 'object' ? options.plainText : {}); const html = await jsxToString(component); return processHtml(html); }; const processHtml = async (html: string) => { - const docType = - ''; + const docType = ''; const movePlugin = await getMovePlugin(); const rawPlugin = await getRawPlugin(); const conditionalPlugin = await getConditionalPlugin(); @@ -67,9 +61,7 @@ const processHtml = async (html: string) => { .use(stringify, { allowDangerousCharacters: true, allowDangerousHtml: true, - characterReferences: { - useNamedReferences: true, - }, + characterReferences: { useNamedReferences: true }, closeEmptyElements: true, collapseEmptyAttributes: true, }) diff --git a/backend/emails/renderer/types.ts b/backend/emails/renderer/types.ts index 455f67bbf..47794978a 100644 --- a/backend/emails/renderer/types.ts +++ b/backend/emails/renderer/types.ts @@ -2,9 +2,7 @@ import type { HtmlToTextOptions } from 'html-to-text'; import type React from 'react'; /** Vendored from jsx-email v3.2.1 (MIT), excluding CLI-only types and globals. */ -export type BaseProps = React.ComponentPropsWithoutRef & { - disableDefaultStyle?: boolean; -}; +export type BaseProps = React.ComponentPropsWithoutRef & { disableDefaultStyle?: boolean }; export type JsxEmailComponent> = React.FC>; diff --git a/backend/emails/templates/account-exists.tsx b/backend/emails/templates/account-exists.tsx index 0544b5e47..e943a6549 100644 --- a/backend/emails/templates/account-exists.tsx +++ b/backend/emails/templates/account-exists.tsx @@ -1,16 +1,6 @@ import { appConfig } from 'shared'; -import { - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { greetingStyle } from '../styles'; import { defineEmailTemplate } from '../types'; const appName = appConfig.name; @@ -28,7 +18,7 @@ export const accountExistsEmail = defineEmailTemplate()({ return { subject: i18n.t('backend:email.account_exists.subject', { lng, appName, ...plainText }), previewText: i18n.t('backend:email.account_exists.preview', { lng, appName, ...plainText }), - headerText: i18n.t('backend:email.account_exists.title', { lng, appName, ...plainText }), + headerHtml: i18n.t('backend:email.account_exists.title', { lng, appName }), hiText: name ? i18n.t('backend:email.hi', { lng, name, ...plainText }) : '', bodyHtml: i18n.t('backend:email.account_exists.text', { lng, appName }), buttonText: i18n.t('c:sign_in', { lng }), @@ -36,24 +26,17 @@ export const accountExistsEmail = defineEmailTemplate()({ supportText: i18n.t('backend:email.support_email', { lng }), }; }, - component({ previewText, headerText, hiText, bodyHtml, buttonText, signInUrl, supportText }) { + component({ previewText, headerHtml, hiText, bodyHtml, buttonText, signInUrl, supportText }) { return ( - - - - {hiText && {hiText}} - - - - - - - - + ); }, - preview: { - statics: { name: 'Emily' }, - recipient: {}, - }, + preview: { statics: { name: 'Emily' }, recipient: {} }, }); diff --git a/backend/emails/templates/account-security.tsx b/backend/emails/templates/account-security.tsx index 8fda54ed8..0adc9510f 100644 --- a/backend/emails/templates/account-security.tsx +++ b/backend/emails/templates/account-security.tsx @@ -1,5 +1,5 @@ import { appConfig } from 'shared'; -import { EmailBody, EmailContainer, EmailFooter, EmailHeader, EmailLogo, EmailText, SafeHtml } from '../components'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; import { defineEmailTemplate } from '../types'; @@ -32,31 +32,18 @@ export const accountSecurityEmail = defineEmailTemplate() const baseProps = { lng, appName: appConfig.name }; // The location line exists only when a country is known; the text keys splice it in unescaped ({{- location}}), and // the country inside it was escaped when the line was translated. - const location = details?.country - ? i18n.t('backend:email.account_security.location', { ...baseProps, country: details.country }) - : ''; + const location = details?.country ? i18n.t('backend:email.account_security.location', { ...baseProps, country: details.country }) : ''; return { subject: i18n.t(`backend:email.account_security.${type}.title`, { ...baseProps, ...details, ...plainText }), previewText: i18n.t('backend:email.account_security.preview', { ...baseProps, name, ...plainText }), - headerText: i18n.t(`backend:email.account_security.${type}.title`, { ...baseProps, ...plainText }), + headerHtml: i18n.t(`backend:email.account_security.${type}.title`, baseProps), // Details can carry request-derived text (route, browser, names); the body renders as HTML, so they stay escaped. bodyHtml: i18n.t(`backend:email.account_security.${type}.text`, { ...baseProps, ...details, location }), supportText: i18n.t('backend:email.support_email', { lng }), }; }, - component({ previewText, headerText, bodyHtml, supportText }) { - return ( - - - - - - - - - - - ); + component({ previewText, headerHtml, bodyHtml, supportText }) { + return ; }, preview: { statics: { diff --git a/backend/emails/templates/magic-link.tsx b/backend/emails/templates/magic-link.tsx index a1d7a3f6e..aab72eb0c 100644 --- a/backend/emails/templates/magic-link.tsx +++ b/backend/emails/templates/magic-link.tsx @@ -1,16 +1,6 @@ import { appConfig } from 'shared'; -import { - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient } from '../types'; const appName = appConfig.name; @@ -27,7 +17,7 @@ export const magicLinkEmail = defineEmailTemplate - - - {hiText && {hiText}} - - - - - - - - + ); }, - preview: { - statics: { magicLinkUrl: 'https://example.com/magic', name: 'Emily', isNewUser: false }, - recipient: {}, - }, + preview: { statics: { magicLinkUrl: 'https://example.com/magic', name: 'Emily', isNewUser: false }, recipient: {} }, }); diff --git a/backend/emails/templates/member-added.tsx b/backend/emails/templates/member-added.tsx index 63a4922a8..7534745c0 100644 --- a/backend/emails/templates/member-added.tsx +++ b/backend/emails/templates/member-added.tsx @@ -1,23 +1,10 @@ import { appConfig, type EntityRole, hierarchy } from 'shared'; -import { - EmailAvatar, - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; -import { Column, Row } from '../components/primitives'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { avatarRowStyle, greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; interface MemberAddedStatic { senderName: string; - senderThumbnailUrl: string | null; entityName: string; role: EntityRole; } @@ -27,7 +14,7 @@ type MemberAddedRecipient = EmailRecipient & { name: string; entityLink: string const appName = appConfig.name; export const memberAddedEmail = defineEmailTemplate()({ - translate(lng, { senderName, senderThumbnailUrl, entityName, role }, param = plainParam) { + translate(lng, { senderName, entityName, role }, param = plainParam) { return { subject: i18n.t('backend:email.member_added.subject', { lng, entityName, ...plainText }), previewText: i18n.t('backend:email.member_added.preview', { lng, entityName, appName, ...plainText }), @@ -37,42 +24,23 @@ export const memberAddedEmail = defineEmailTemplate - {senderName && ( - - - - - - )} - - } /> - - {name && {hiText}} - - - - - - - - - - + ); }, preview: { - statics: { - senderName: 'John', - senderThumbnailUrl: null, - entityName: 'Acme', - role: hierarchy.getLeastPrivilegedRole('organization'), - }, + statics: { senderName: 'John', entityName: 'Acme', role: hierarchy.getLeastPrivilegedRole('organization') }, recipient: { name: 'Emily', entityLink: 'https://example.com/acme' }, }, }); diff --git a/backend/emails/templates/member-invite-with-token.tsx b/backend/emails/templates/member-invite-with-token.tsx index 6755ed007..fac7b92cd 100644 --- a/backend/emails/templates/member-invite-with-token.tsx +++ b/backend/emails/templates/member-invite-with-token.tsx @@ -1,23 +1,10 @@ import { appConfig, type EntityRole, hierarchy } from 'shared'; -import { - EmailAvatar, - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; -import { Column, Row } from '../components/primitives'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { avatarRowStyle, greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; interface MemberInviteWithTokenStatic { senderName: string; - senderThumbnailUrl: string | null; entityName: string; role: EntityRole; } @@ -27,11 +14,8 @@ type MemberInviteWithTokenRecipient = EmailRecipient & { name: string; inviteLin const appName = appConfig.name; /** For new users, who need the token; existing users get member-invite. */ -export const memberInviteWithTokenEmail = defineEmailTemplate< - MemberInviteWithTokenStatic, - MemberInviteWithTokenRecipient ->()({ - translate(lng, { senderName, senderThumbnailUrl, entityName, role }, param = plainParam) { +export const memberInviteWithTokenEmail = defineEmailTemplate()({ + translate(lng, { senderName, entityName, role }, param = plainParam) { return { subject: i18n.t('backend:email.member_invite.subject', { lng, entityName, ...plainText }), previewText: i18n.t('backend:email.member_invite.preview', { lng, entityName, appName, ...plainText }), @@ -42,53 +26,24 @@ export const memberInviteWithTokenEmail = defineEmailTemplate< buttonText: i18n.t('c:join', { lng }), supportText: i18n.t('backend:email.support_email', { lng }), senderName, - senderThumbnailUrl, }; }, - component({ - previewText, - headerHtml, - hiText, - bodyHtml, - inviteExpires, - buttonText, - supportText, - senderName, - name, - inviteLink, - }) { + component({ previewText, headerHtml, hiText, bodyHtml, inviteExpires, buttonText, supportText, senderName, inviteLink }) { return ( - - {senderName && ( - - - - - - )} - - } /> - - {name && {hiText}} - - {inviteExpires} - - - - - - - - + ); }, preview: { - statics: { - senderName: 'John', - senderThumbnailUrl: null, - entityName: 'Acme', - role: hierarchy.getLeastPrivilegedRole('organization'), - }, + statics: { senderName: 'John', entityName: 'Acme', role: hierarchy.getLeastPrivilegedRole('organization') }, recipient: { name: 'Emily', inviteLink: 'https://example.com/invite' }, }, }); diff --git a/backend/emails/templates/member-invite.tsx b/backend/emails/templates/member-invite.tsx index e320213ae..e53f10373 100644 --- a/backend/emails/templates/member-invite.tsx +++ b/backend/emails/templates/member-invite.tsx @@ -1,23 +1,10 @@ import { appConfig, type EntityRole, hierarchy } from 'shared'; -import { - EmailAvatar, - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; -import { Column, Row } from '../components/primitives'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { avatarRowStyle, greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; interface MemberInviteStatic { senderName: string; - senderThumbnailUrl: string | null; entityName: string; role: EntityRole; } @@ -28,7 +15,7 @@ const appName = appConfig.name; /** For existing users; new users get member-invite-with-token. */ export const memberInviteEmail = defineEmailTemplate()({ - translate(lng, { senderName, senderThumbnailUrl, entityName, role }, param = plainParam) { + translate(lng, { senderName, entityName, role }, param = plainParam) { return { subject: i18n.t('backend:email.member_invite.subject', { lng, entityName, ...plainText }), previewText: i18n.t('backend:email.member_invite.preview', { lng, entityName, appName, ...plainText }), @@ -38,52 +25,23 @@ export const memberInviteEmail = defineEmailTemplate - {senderName && ( - - - - - - )} - - } /> - - {name && {hiText}} - - - - - - - - - - + ); }, preview: { - statics: { - senderName: 'John', - senderThumbnailUrl: null, - entityName: 'Acme', - role: hierarchy.getLeastPrivilegedRole('organization'), - }, + statics: { senderName: 'John', entityName: 'Acme', role: hierarchy.getLeastPrivilegedRole('organization') }, recipient: { name: 'Emily', memberInviteLink: 'https://example.com/invite' }, }, }); diff --git a/backend/emails/templates/newsletter.tsx b/backend/emails/templates/newsletter.tsx index aa3a53803..3ebbff760 100644 --- a/backend/emails/templates/newsletter.tsx +++ b/backend/emails/templates/newsletter.tsx @@ -1,7 +1,6 @@ -import { EmailBody, EmailContainer, EmailFooter, EmailHeader, EmailLogo, EmailText, SafeHtml } from '../components'; -import { Link } from '../components/primitives'; +import { EmailLayout, EmailText, SafeHtml } from '../components'; import { i18n } from '../i18n'; -import { newsletterContentStyles, smallTextStyle } from '../styles'; +import { newsletterContentStyles } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; interface NewsletterStatic { @@ -25,27 +24,17 @@ export const newsletterEmail = defineEmailTemplate{newsletterContentStyles}} + unsubscribe={{ label: unsubscribeText, href: unsubscribeLink }} + supportText={supportText} > - } /> - - {testEmail && 'THIS IS A TEST'} - - - -

- - {unsubscribeText} - -
- - - - - + {testEmail && THIS IS A TEST} + + ); }, preview: { diff --git a/backend/emails/templates/oauth-verification.tsx b/backend/emails/templates/oauth-verification.tsx index e4d42c118..1308ff367 100644 --- a/backend/emails/templates/oauth-verification.tsx +++ b/backend/emails/templates/oauth-verification.tsx @@ -1,16 +1,6 @@ import { appConfig } from 'shared'; -import { - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; const appName = appConfig.name; @@ -20,45 +10,34 @@ interface OAuthVerificationStatic { verificationLink: string; providerEmail: string; providerName: string; + /** A sign-up whose account is created at the click; otherwise a provider account connecting to an existing one. */ + isNewUser: boolean; } -export const oauthVerificationEmail = defineEmailTemplate< - OAuthVerificationStatic, - EmailRecipient & { email: string } ->()({ - translate(lng, { name, verificationLink, providerEmail, providerName }, param = plainParam) { +export const oauthVerificationEmail = defineEmailTemplate()({ + translate(lng, { name, verificationLink, providerEmail, providerName, isNewUser }, param = plainParam) { + const keyBase = isNewUser ? 'backend:email.oauth_verification.signup' : 'backend:email.oauth_verification'; return { - subject: i18n.t('backend:email.oauth_verification.subject', { lng, appName, ...plainText }), - previewText: i18n.t('backend:email.oauth_verification.preview', { appName, lng, providerName, ...plainText }), - headerText: i18n.t('backend:email.oauth_verification.preview', { appName, lng, providerName, ...plainText }), + subject: i18n.t(`${keyBase}.subject`, { lng, appName, ...plainText }), + previewText: i18n.t(`${keyBase}.preview`, { appName, lng, providerName, ...plainText }), + headerHtml: i18n.t(`${keyBase}.preview`, { appName, lng, providerName }), hiText: name ? i18n.t('backend:email.hi', { lng, name, ...plainText }) : '', - bodyHtml: i18n.t('backend:email.oauth_verification.text', { - lng, - appName, - email: param('email'), - providerEmail, - providerName, - name, - }), - buttonText: i18n.t('backend:email.oauth_verification.verify', { lng, providerName, ...plainText }), + bodyHtml: i18n.t(`${keyBase}.text`, { lng, appName, email: param('email'), providerEmail, providerName, name }), + buttonText: i18n.t(`${keyBase}.verify`, { lng, providerName, ...plainText }), supportText: i18n.t('backend:email.support_email', { lng }), verificationLink, }; }, - component({ previewText, headerText, hiText, bodyHtml, buttonText, verificationLink, supportText }) { + component({ previewText, headerHtml, hiText, bodyHtml, buttonText, verificationLink, supportText }) { return ( - - - - {hiText && {hiText}} - - - - - - - - + ); }, preview: { @@ -67,6 +46,7 @@ export const oauthVerificationEmail = defineEmailTemplate< name: 'Emily', providerEmail: 'jane@gmail.com', providerName: 'Google', + isNewUser: false, }, recipient: {}, }, diff --git a/backend/emails/templates/request-was-sent-admin.tsx b/backend/emails/templates/request-was-sent-admin.tsx index 97284370c..096c708e1 100644 --- a/backend/emails/templates/request-was-sent-admin.tsx +++ b/backend/emails/templates/request-was-sent-admin.tsx @@ -1,5 +1,5 @@ import { appConfig } from 'shared'; -import { EmailBody, EmailContainer, EmailFooter, EmailHeader, EmailLogo, EmailText, SafeHtml } from '../components'; +import { EmailLayout, EmailText } from '../components'; import { i18n } from '../i18n'; import { defineEmailTemplate } from '../types'; import type { RequestType } from './request-was-sent'; @@ -24,20 +24,11 @@ export const requestInfoEmail = defineEmailTemplate()({ }, component({ subject, headerHtml, email, message, supportText }) { return ( - - } /> - - Email: {email} - {message && {message}} - - - - - + + Email: {email} + {message && {message}} + ); }, - preview: { - statics: { type: 'contact', email: 'test@example.com', message: 'Hello', subject: 'New contact request' }, - recipient: {}, - }, + preview: { statics: { type: 'contact', email: 'test@example.com', message: 'Hello', subject: 'New contact request' }, recipient: {} }, }); diff --git a/backend/emails/templates/request-was-sent.tsx b/backend/emails/templates/request-was-sent.tsx index fcc25b78e..3b26f5819 100644 --- a/backend/emails/templates/request-was-sent.tsx +++ b/backend/emails/templates/request-was-sent.tsx @@ -1,6 +1,6 @@ import { appConfig } from 'shared'; import type { requestTypeEnum } from '#/modules/requests/requests-db'; -import { EmailBody, EmailContainer, EmailFooter, EmailHeader, EmailLogo, EmailText, SafeHtml } from '../components'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; import { defineEmailTemplate } from '../types'; @@ -15,34 +15,14 @@ interface RequestResponseStatic { export const requestResponseEmail = defineEmailTemplate()({ translate(lng, { type }) { return { - subject: i18n.t('backend:email.request.subject', { - lng, - appName: appConfig.name, - requestType: type, - ...plainText, - }), + subject: i18n.t('backend:email.request.subject', { lng, appName: appConfig.name, requestType: type, ...plainText }), headerHtml: i18n.t(`backend:email.${type}_request.title`, { lng }), bodyHtml: i18n.t(`backend:email.${type}_request.text`, { lng, appName: appConfig.name }), supportText: i18n.t('backend:email.support_email', { lng }), }; }, component({ subject, headerHtml, bodyHtml, supportText }) { - return ( - - } /> - - - - - - - - - - ); - }, - preview: { - statics: { type: 'contact', message: null }, - recipient: {}, + return ; }, + preview: { statics: { type: 'contact', message: null }, recipient: {} }, }); diff --git a/backend/emails/templates/step-up.tsx b/backend/emails/templates/step-up.tsx index 6c9cb11f6..2c4dee75a 100644 --- a/backend/emails/templates/step-up.tsx +++ b/backend/emails/templates/step-up.tsx @@ -1,16 +1,6 @@ import { appConfig } from 'shared'; -import { - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient } from '../types'; const appName = appConfig.name; @@ -25,7 +15,7 @@ export const stepUpEmail = defineEmailTemplate - - - {hiText && {hiText}} - - - - - - - - + ); }, - preview: { - statics: { stepUpUrl: 'https://example.com/step-up', name: 'Emily' }, - recipient: {}, - }, + preview: { statics: { stepUpUrl: 'https://example.com/step-up', name: 'Emily' }, recipient: {} }, }); diff --git a/backend/emails/templates/system-invite.tsx b/backend/emails/templates/system-invite.tsx index d831c27b1..8d4f03a2e 100644 --- a/backend/emails/templates/system-invite.tsx +++ b/backend/emails/templates/system-invite.tsx @@ -1,23 +1,10 @@ import { appConfig } from 'shared'; -import { - EmailAvatar, - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../components'; -import { Column, Row } from '../components/primitives'; +import { EmailMessage } from '../components'; import { i18n, plainText } from '../i18n'; -import { avatarRowStyle, greetingStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; interface SystemInviteStatic { senderName: string; - senderThumbnailUrl: string | null; } type SystemInviteRecipient = EmailRecipient & { name: string; inviteLink: string }; @@ -26,7 +13,7 @@ const appName = appConfig.name; /** System-level invitation, for new users. */ export const systemInviteEmail = defineEmailTemplate()({ - translate(lng, { senderName, senderThumbnailUrl }, param = plainParam) { + translate(lng, { senderName }, param = plainParam) { return { subject: i18n.t('backend:email.system_invite.subject', { lng, appName, ...plainText }), previewText: i18n.t('backend:email.system_invite.preview', { appName, lng, ...plainText }), @@ -37,48 +24,21 @@ export const systemInviteEmail = defineEmailTemplate - {senderName && ( - - - - - - )} - - } /> - - {name && {hiText}} - - {inviteExpires} - - - - - - - - + ); }, - preview: { - statics: { senderName: 'John', senderThumbnailUrl: null }, - recipient: { name: 'Emily', inviteLink: 'https://example.com/invite' }, - }, + preview: { statics: { senderName: 'John' }, recipient: { name: 'Emily', inviteLink: 'https://example.com/invite' } }, }); diff --git a/backend/emails/templates/welcome.tsx b/backend/emails/templates/welcome.tsx index 96d945c89..28ef43c16 100644 --- a/backend/emails/templates/welcome.tsx +++ b/backend/emails/templates/welcome.tsx @@ -1,9 +1,9 @@ import { appConfig } from 'shared'; import welcomeConfig from '../../../json/text-blocks.json'; -import { EmailAvatar, EmailBody, EmailContainer, EmailFooter, EmailLogo, EmailText } from '../components'; -import { Column, Link, Row } from '../components/primitives'; +import { EmailLayout, EmailText } from '../components'; +import { Link } from '../components/primitives'; import { i18n, plainText } from '../i18n'; -import { avatarRowStyle, greetingStyle, smallTextStyle } from '../styles'; +import { greetingStyle, smallTextStyle } from '../styles'; import { defineEmailTemplate, type EmailRecipient, plainParam } from '../types'; type WelcomeRecipient = EmailRecipient & { name: string }; @@ -32,61 +32,35 @@ export const welcomeEmailTemplate = defineEmailTemplate, W supportText: i18n.t('backend:email.support_email', { lng }), }; }, - component({ - previewText, - hiText, - intro, - stepsHeading, - steps, - ps, - signOff, - founderName, - founderRole, - supportText, - name, - }) { + component({ previewText, hiText, intro, stepsHeading, steps, ps, signOff, founderName, founderRole, supportText }) { return ( - - - - - - + + {hiText} - - {name && {hiText}} + {intro.map((paragraph) => ( + {paragraph} + ))} - {intro.map((paragraph) => ( - {paragraph} + {stepsHeading} +
    + {steps.map((step) => ( +
  1. + + {step.label} + +
  2. ))} +
- {stepsHeading} -
    - {steps.map((step) => ( -
  1. - - {step.label} - -
  2. - ))} -
+ {ps} - {ps} - - {signOff} - {founderName} - - {founderRole}, {appName} - -
- - - -
+ {signOff} + {founderName} + + {founderRole}, {appName} + + ); }, - preview: { - statics: {}, - recipient: { name: 'Emily' }, - }, + preview: { statics: {}, recipient: { name: 'Emily' } }, }); diff --git a/backend/emails/types.ts b/backend/emails/types.ts index c131b8687..910da823d 100644 --- a/backend/emails/types.ts +++ b/backend/emails/types.ts @@ -18,9 +18,7 @@ export type RecipientProps = { * Per-recipient values that are HTML the app built itself, every user-derived fragment escaped, keyed to the * `SafeHtml` policy the mailer sanitizes them with. Brevo prints these as they are; every other param it escapes. */ -export type HtmlParams = Partial< - Record, SafeHtmlPolicy> ->; +export type HtmlParams = Partial, SafeHtmlPolicy>>; /** * The Brevo placeholder for a per-recipient value: `{{params.}}`, which Brevo fills HTML-escaped, or @@ -35,9 +33,7 @@ export const brevoPlaceholder = (key: string, htmlParams: Partial}}` by hand. */ -export type ParamPlaceholder = ( - key: RecipientKey | 'email', -) => string; +export type ParamPlaceholder = (key: RecipientKey | 'email') => string; /** The placeholder outside a send (previews, tests): `{{params.}}`. */ export const plainParam = (key: string): string => brevoPlaceholder(key); @@ -59,11 +55,7 @@ export interface EmailTemplateDef, TRecipient ex * Pre-compute all translated strings (+ pass-through statics the component needs). Must include `subject`. Text that * names a per-recipient value gets its placeholder from `param`. */ - translate( - lng: string, - statics: TStatic, - param?: ParamPlaceholder, - ): { subject: string } & Record; + translate(lng: string, statics: TStatic, param?: ParamPlaceholder): { subject: string } & Record; /** React shell receiving translate() output and per-recipient display props. No i18n calls. */ component(props: Record): React.ReactElement; /** Sample data to render this template in previews and tests. */ diff --git a/backend/package.json b/backend/package.json index 7d2013906..47694ed45 100644 --- a/backend/package.json +++ b/backend/package.json @@ -51,23 +51,20 @@ "@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/sdk-trace-base": "^2.11.0", "@opentelemetry/semantic-conventions": "^1.43.0", - "jose": "^6.2.12", "jsdom": "^30.1.1", - "oidc-provider": "9.12.2", - "pg": "^8.23.0", + "pg": "^8.23.1", "pino": "^10.3.1", "pino-opentelemetry-transport": "^4.0.2", - "pino-pretty": "^13.1.3", - "web-push": "^3.6.7" + "pino-pretty": "^13.1.3" }, "devDependencies": { "@asteasolutions/zod-to-openapi": "^9.1.0", - "@aws-sdk/client-s3": "^3.1141.0", - "@aws-sdk/s3-request-presigner": "^3.1141.0", + "@aws-sdk/client-s3": "^3.1144.0", + "@aws-sdk/s3-request-presigner": "^3.1144.0", "@blocknote/core": "^0.55.0", "@blocknote/server-util": "^0.55.0", "@faker-js/faker": "^10.6.0", - "@hono/node-server": "^2.1.1", + "@hono/node-server": "^2.1.3", "@hono/otel": "^1.2.0", "@hono/zod-openapi": "^1.6.3", "@isaacs/ttlcache": "^2.1.5", @@ -81,42 +78,42 @@ "@types/oidc-provider": "9.12.1", "@types/pg": "^8.23.1", "@types/react": "19.3.0", - "@types/sanitize-html": "^2.16.1", + "@types/sanitize-html": "^2.16.2", "@types/web-push": "^3.6.4", - "@types/ws": "^8.18.1", + "@types/ws": "^8.18.2", "@typescript/native-preview": "7.0.0-dev.20260707.2", "cdc-worker": "workspace:*", "cross-env": "^10.1.0", "drizzle-kit": "1.0.0-rc.5-ab785fc", "drizzle-orm": "1.0.0-rc.3", "enforce-unique": "^1.3.0", - "hono": "^4.13.10", + "hono": "^4.13.12", "html-to-text": "^10.0.1", "i18next": "^26.4.2", "isbot": "^5.2.2", + "jose": "^6.2.12", "maxmind": "^5.0.7", "nanoid": "^6.0.1", "oauth4webapi": "^3.8.8", + "oidc-provider": "9.12.2", "ora": "^9.4.1", - "pg-boss": "^12.35.0", - "pg-logical-replication": "^2.5.0", + "pg-boss": "^12.35.1", "picocolors": "^1.1.1", "rate-limiter-flexible": "^11.2.1", "react": "^19.3.0", - "react-dom": "^19.3.0", "rehype": "^13.0.2", "rehype-stringify": "^10.0.1", - "sanitize-html": "^2.17.7", + "sanitize-html": "^2.18.0", "sdk": "workspace:*", "shared": "workspace:*", "slugify": "^1.6.9", "tsup": "^8.5.1", "tsx": "^4.23.15", - "typescript": "^6.0.3", - "ua-parser-js": "^2.0.10", + "typescript": "6.0.3", "uuidv7": "^1.2.1", "vite": "^8.3.1", - "vitest": "^5.0.2", + "vitest": "^5.0.3", + "web-push": "^3.6.7", "ws": "^8.22.0", "yjs-worker": "workspace:*", "zod": "^4.6.5" diff --git a/backend/scripts/migrations/10-membership-rules.migration.ts b/backend/scripts/migrations/10-membership-rules.migration.ts index daaf7a59e..0ddb97e5b 100644 --- a/backend/scripts/migrations/10-membership-rules.migration.ts +++ b/backend/scripts/migrations/10-membership-rules.migration.ts @@ -1,4 +1,4 @@ -import { keepOrganizationAdminConstraint, keepOrganizationAdminSQL } from '#/db/membership-rules'; +import { bumpBindingsVersionSQL, bumpBindingsVersionTrigger, keepOrganizationAdminConstraint, keepOrganizationAdminSQL } from '#/db/membership-rules'; import type { SideEffectBlock, SideEffectProducer } from '../types'; /** Creates the membership rule triggers from `db/membership-rules.ts`, where the verify block reads what to assert. */ @@ -8,13 +8,18 @@ async function run(): Promise { const migrationSql = `-- Membership rules -- An organization keeps at least one '${adminRole}' membership. -${sql}`; +${sql} +--> statement-breakpoint + +-- Every membership write gives its user's actors.bindings_version a new value (the membership cache key). + +${bumpBindingsVersionSQL()}`; return { tag: 'membership_rules', - title: 'Membership rules, an organization keeps an admin', + title: 'Membership rules, an organization keeps an admin, bindings versions', sql: migrationSql, - notes: [`Trigger: ${keepOrganizationAdminConstraint} (admin role '${adminRole}')`], + notes: [`Trigger: ${keepOrganizationAdminConstraint} (admin role '${adminRole}')`, `Trigger: ${bumpBindingsVersionTrigger}`], }; } diff --git a/backend/scripts/seeds/00-init.seed.ts b/backend/scripts/seeds/00-init.seed.ts index 196f62687..0dc39e282 100644 --- a/backend/scripts/seeds/00-init.seed.ts +++ b/backend/scripts/seeds/00-init.seed.ts @@ -1,7 +1,7 @@ import type { SeedScript } from '../types'; import { getSeedDb } from '#/db/db'; import { emailsTable } from '#/modules/user/emails-db'; -import { insertUsers } from '#/modules/user/helpers/insert-users'; +import { insertUsers } from '#/modules/user/user-queries'; import { usersTable } from '#/modules/user/user-db'; import { env } from '#/env'; import pc from 'picocolors'; @@ -52,7 +52,7 @@ export const initSeed = async () => { const adminId = isProduction ? undefined : defaultAdminUser.id; const adminRecord = mockAdmin(adminId, adminEmail); - const [adminUser] = await insertUsers(db, [adminRecord], { onConflictDoNothing: true }); + const [adminUser] = await insertUsers({ var: { db } }, { users: [adminRecord], onConflictDoNothing: true }); // Insert system role row into the database await db.insert(systemRolesTable).values({ userId: adminUser.id, role: 'admin' }).onConflictDoNothing(); diff --git a/backend/scripts/seeds/10-organization.seed.ts b/backend/scripts/seeds/10-organization.seed.ts index 1a7f03b33..412615e8c 100644 --- a/backend/scripts/seeds/10-organization.seed.ts +++ b/backend/scripts/seeds/10-organization.seed.ts @@ -9,7 +9,7 @@ import { emailsTable } from '#/modules/user/emails-db'; import { InsertMembershipModel, membershipsTable } from '#/modules/memberships/memberships-db'; import { OrganizationModel, organizationsTable } from '#/modules/organization/organization-db'; import { tenantsTable } from '#/modules/tenants/tenants-db'; -import { insertUsers } from '#/modules/user/helpers/insert-users'; +import { insertUsers } from '#/modules/user/user-queries'; import { UserModel, usersTable } from '#/modules/user/user-db'; import { getMembershipOrderOffset, mockChannelMembership } from '#/modules/memberships/memberships-mocks'; import { mockOrganization } from '#/modules/organization/organization-mocks'; @@ -104,7 +104,7 @@ export const organizationsSeed = async () => { const userRecords = mockMany(() => mockUser(), MEMBERS_COUNT); const users: UserModel[] = []; for (const batch of toBatches(userRecords)) { - users.push(...(await insertUsers(db, batch, { onConflictDoNothing: true }))); + users.push(...(await insertUsers({ var: { db } }, { users: batch, onConflictDoNothing: true }))); } // Make email row for each user, then insert into the database diff --git a/backend/scripts/seeds/20-attachment.seed.ts b/backend/scripts/seeds/20-attachment.seed.ts index 908ae26b7..e4b32e99c 100644 --- a/backend/scripts/seeds/20-attachment.seed.ts +++ b/backend/scripts/seeds/20-attachment.seed.ts @@ -1,13 +1,13 @@ import type { SeedScript } from '../types'; import { faker } from '@faker-js/faker'; import { appConfig } from 'shared'; +import { deriveDocument } from 'shared/utils/derive-description-core'; import { noteSpinnerWarning, startSpinner, succeedSpinner, warnSpinner } from '#/utils/console'; import { getSeedDb } from '#/db/db'; import { attachmentsTable } from '#/modules/attachment/attachment-db'; import { seedAttachmentPlacements } from '#/modules/attachment/helpers/attachment-placement'; import { organizationsTable } from '#/modules/organization/organization-db'; import { mockStx, mockUuid, setMockContext, withFakerSeed } from '#/mocks'; -import { keywordsFromDocument } from '#/utils/description-document'; import { defaultAdminUser } from '../fixtures'; import { textDocument } from './description-document'; import { seedAssets } from './seed-assets'; @@ -101,7 +101,7 @@ export const attachmentsSeed = async () => { stx: mockStx(), description, // The update op derives keywords from the document; a row without one keeps filler search text. - keywords: description ? keywordsFromDocument(description) : faker.lorem.words(3), + keywords: description ? deriveDocument(description).keywords : faker.lorem.words(3), filename: asset.filename, name: extIndex > 0 ? asset.filename.slice(0, extIndex) : asset.filename, contentType: asset.contentType, diff --git a/backend/scripts/seeds/55-notifications.seed.ts b/backend/scripts/seeds/55-notifications.seed.ts index 207885626..faca316d6 100644 --- a/backend/scripts/seeds/55-notifications.seed.ts +++ b/backend/scripts/seeds/55-notifications.seed.ts @@ -28,7 +28,8 @@ const isNotificationsSeeded = async () => { /** * Gives the admin an inbox: per organization, a member edits a few seeded attachments and mentions * the admin in their description (`createdBy` is immutable, `updatedBy` records the editor as the - * fan-out would), and the matching `mention` rows are inserted as the fan-out would write them. + * fan-out would), and the matching `mention` rows are inserted as the fan-out, which reads the + * mention from that description, would write them. * Attachment rows and inbox rows therefore agree, so the bell, the description caption and the * `/n` link all work on seeded data. */ @@ -83,7 +84,6 @@ export const notificationsSeed = async () => { .update(attachmentsTable) .set({ updatedBy: member.userId, - mentions: [admin.id], description: mentionDocument(admin, `could you have a look at ${attachment.name}?`), }) .where(eq(attachmentsTable.id, attachment.id)); diff --git a/backend/src/core/context.ts b/backend/src/core/context.ts index ff6797e94..278af3ceb 100644 --- a/backend/src/core/context.ts +++ b/backend/src/core/context.ts @@ -34,9 +34,7 @@ export type Actor = UserActor | ServiceActor; export type ActorBinding = Actor['bindings'][number]; /** Minimal context for query functions that only need a database connection. */ -export type DbContext = { - var: Pick; -}; +export type DbContext = { var: Pick }; /** * Someone acting inside a tenant, whatever proved them: no user row, so it stays callable with an API key or an access token. @@ -48,17 +46,13 @@ export type ActorContext = { }; /** An actor inside a resolved organization: what `orgGuard` guarantees. */ -export type OrgContext = { - var: ActorContext['var'] & Pick; -}; +export type OrgContext = { var: ActorContext['var'] & Pick }; /** * A signed-in user: everything in `OrgContext` plus `user`, `memberships` and the session. `userGuard` guarantees the * actor is a `UserActor`; the type keeps the union because Hono hands every handler the same `Env`. */ -export type UserContext = { - var: Omit; -}; +export type UserContext = { var: Omit }; /** * Request variables; the derived contexts pick from them, narrowest first: `DbContext` (a connection), diff --git a/backend/src/core/error.ts b/backend/src/core/error.ts index 206d28c02..defc1e02d 100644 --- a/backend/src/core/error.ts +++ b/backend/src/core/error.ts @@ -31,12 +31,7 @@ export class AppError extends Error { entityType?: ErrorSchemaType['entityType']; meta?: ErrorMeta; - constructor( - status: ErrorSchemaType['status'], - type: ErrorKey, - severity: ErrorSchemaType['severity'], - opts?: AppErrorOpts, - ) { + constructor(status: ErrorSchemaType['status'], type: ErrorKey, severity: ErrorSchemaType['severity'], opts?: AppErrorOpts) { const i18nOpts = { ns: ['appError', 'error'], defaultValue: opts?.name ?? 'Unknown error' }; const messageFallback = opts?.message ?? i18n.t(type, i18nOpts); super(i18n.t(`${type}.text`, { ...i18nOpts, defaultValue: messageFallback })); diff --git a/backend/src/core/mcp-tool-registry.test.ts b/backend/src/core/mcp-tool-registry.test.ts deleted file mode 100644 index 727a34443..000000000 --- a/backend/src/core/mcp-tool-registry.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { z } from '@hono/zod-openapi'; -import { describe, expect, it } from 'vitest'; -import type { OrgContext } from '#/core/context'; -import { getMcpTools, registerMcpTool } from './mcp-tool-registry'; - -const ctx = {} as OrgContext; -const spec = { - enabled: true, - description: 'x', - approvalRequired: false, - category: 'test', - entity: 'attachment' as const, -}; - -const registered = (name: string) => { - const tool = getMcpTools().find((candidate) => candidate.name === name); - if (!tool) throw new Error(`${name} not registered`); - return tool; -}; - -describe('registerMcpTool', () => { - it("derives the input from params minus the route's own ids plus the query, and splits them back for execute", async () => { - const calls: unknown[] = []; - registerMcpTool( - { - operationId: 'listThings', - method: 'get', - request: { - params: z.object({ tenantId: z.string(), organizationId: z.string(), id: z.string() }), - query: z.object({ q: z.string().optional(), limit: z.string().regex(/^\d+$/).transform(Number).optional() }), - }, - }, - { - ...spec, - execute: async (_ctx, input) => { - calls.push(input); - return null; - }, - }, - ); - const tool = registered('listThings'); - expect(tool.scope).toBe('attachment:read'); - expect(Object.keys((tool.inputSchema as z.ZodObject).shape)).toEqual(['id', 'q', 'limit']); - - await tool.run(ctx, { id: 'thing-1', q: 'hi', limit: '5' }); - // Query values validate through the route's own schema, so its coercions apply. - expect(calls[0]).toEqual({ params: { id: 'thing-1' }, query: { q: 'hi', limit: 5 }, body: undefined }); - await expect(tool.run(ctx, { id: 'thing-1', limit: 'five' })).rejects.toThrow(); - }); - - it('nests an array body under items and rebuilds the sync transaction per item', async () => { - const calls: unknown[] = []; - registerMcpTool( - { - operationId: 'createThings', - method: 'post', - request: { - params: z.object({ tenantId: z.string(), organizationId: z.string() }), - body: { - content: { - 'application/json': { - schema: z.array( - z.object({ name: z.string(), stx: z.object({ mutationId: z.string(), sourceId: z.string() }) }), - ), - }, - }, - }, - }, - }, - { - ...spec, - execute: async (_ctx, input) => { - calls.push(input); - return null; - }, - }, - ); - const tool = registered('createThings'); - expect(tool.scope).toBe('attachment:write'); - expect(tool.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false, idempotentHint: false }); - expect(Object.keys((tool.inputSchema as z.ZodObject).shape)).toEqual(['items']); - - // A sync transaction the model sends is replaced by the server's own, item by item. - await tool.run(ctx, { items: [{ name: 'a', stx: { mutationId: 'model', sourceId: 'model' } }, { name: 'b' }] }); - const { body } = calls[0] as { body: { name: string; stx: { mutationId: string; sourceId: string } }[] }; - expect(body.map((item) => item.name)).toEqual(['a', 'b']); - expect(body.map((item) => item.stx.sourceId)).toEqual(['server', 'server']); - expect(body[0].stx.mutationId).not.toBe('model'); - }); - - it('refuses a second registration of the same operation', () => { - expect(() => - registerMcpTool({ operationId: 'listThings', method: 'get' }, { ...spec, execute: async () => null }), - ).toThrow(); - }); -}); diff --git a/backend/src/core/mcp-tool-registry.ts b/backend/src/core/mcp-tool-registry.ts deleted file mode 100644 index 3f2c28309..000000000 --- a/backend/src/core/mcp-tool-registry.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { z } from '@hono/zod-openapi'; -import { type AccessScope, type AccessScopedEntityType, accessScopes, type EntityActionType } from 'shared'; -import type { OrgContext } from '#/core/context'; -import type { ToolInput, ToolRoute, XToolMetadata } from '#/core/openapi-extensions'; -import { createServerStx, createServerStxStamping } from '#/core/stx/create-server-stx'; - -/** A route exposed to MCP clients: named and documented by the route, run through its operation in-process. */ -export interface McpTool { - name: string; - description: string; - /** What the route acts on and how; the scope a token must carry follows from it (`:read` | `:write`). */ - entity: AccessScopedEntityType; - action: EntityActionType; - scope: AccessScope; - /** MCP tool annotations, derived from the HTTP method. */ - annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean }; - approvalRequired: boolean; - /** What the model sees: the route's request parts merged, sync transaction left out. */ - inputSchema: z.ZodType; - /** Validates against the route's own schemas, rebuilds the sync transaction, and calls the route's `execute`. */ - run: (ctx: OrgContext, args: unknown) => Promise; -} - -const tools: McpTool[] = []; - -/** Route parameters the MCP endpoint already resolved; never model input. */ -const routeParams = ['tenantId', 'organizationId']; - -const anyObject = (schema: unknown): schema is z.ZodObject => schema instanceof z.ZodObject; - -/** - * The sync transaction (`stx`) is trusted server metadata, never model input: `modelSchema` leaves it out of what the - * model sees, `withServerStx` puts a server-built one back before the route's own schema validates the body. - */ -function splitStx(schema: z.ZodType): { modelSchema: z.ZodType; withServerStx: (value: unknown) => unknown } { - if (anyObject(schema) && 'stx' in schema.shape) { - // Rebuilt from the shape (`.omit()` refuses refined objects); the route's own schema still validates the call. - const { stx: _stx, ...shape } = schema.shape; - return { - modelSchema: z.object(shape), - withServerStx: (value) => { - const record = value as Record; - const ops = record.ops; - const stx = - ops && typeof ops === 'object' ? createServerStxStamping(ops as Record) : createServerStx(); - return { ...record, stx }; - }, - }; - } - if (schema instanceof z.ZodArray) { - const inner = splitStx(schema.element as z.ZodType); - if (inner.modelSchema !== schema.element) { - return { - modelSchema: z.array(inner.modelSchema), - withServerStx: (value) => (value as unknown[]).map(inner.withServerStx), - }; - } - } - return { modelSchema: schema, withServerStx: (value) => value }; -} - -/** Called by `createXRoute` for every route carrying an enabled `x-tool`; the mcp module reads the result. */ -export function registerMcpTool( - route: ToolRoute & { request?: Req }, - meta: XToolMetadata, -): void { - if (tools.some((tool) => tool.name === route.operationId)) - throw new Error(`[MCP] Tool ${route.operationId} is registered twice`); - - const paramsSchema = route.request?.params; - const params = anyObject(paramsSchema) - ? paramsSchema.omit( - Object.fromEntries(routeParams.filter((key) => key in paramsSchema.shape).map((key) => [key, true])), - ) - : z.object({}); - const querySchema = route.request?.query; - const query = anyObject(querySchema) ? querySchema : z.object({}); - const media = route.request?.body?.content?.['application/json']; - const jsonBody = media && 'schema' in media ? media.schema : undefined; - const body = jsonBody instanceof z.ZodType ? jsonBody : undefined; - const modelBody = body ? splitStx(body) : undefined; - const bodyIsObject = anyObject(modelBody?.modelSchema); - // A non-object body (a batch of items) nests under one key so it cannot collide with params or query. - const bodyKey = - modelBody && !bodyIsObject ? (modelBody.modelSchema instanceof z.ZodArray ? 'items' : 'body') : undefined; - - const inputSchema = z.object({ - ...params.shape, - ...query.shape, - ...(bodyIsObject && anyObject(modelBody?.modelSchema) ? modelBody.modelSchema.shape : {}), - ...(bodyKey && modelBody ? { [bodyKey]: modelBody.modelSchema } : {}), - }); - const paramKeys = Object.keys(params.shape); - const queryKeys = Object.keys(query.shape); - const method = route.method.toLowerCase(); - const isRead = method === 'get'; - const action: EntityActionType = isRead ? 'read' : 'update'; - - tools.push({ - name: route.operationId, - description: meta.description, - entity: meta.entity, - action, - scope: accessScopes.required(meta.entity, action), - annotations: { readOnlyHint: isRead, destructiveHint: method === 'delete', idempotentHint: method !== 'post' }, - approvalRequired: meta.approvalRequired, - inputSchema, - run: async (ctx, args) => { - const record = (args ?? {}) as Record; - const pick = (keys: string[]) => - Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]])); - const rest = Object.fromEntries( - Object.entries(record).filter(([key]) => !paramKeys.includes(key) && !queryKeys.includes(key)), - ); - // Each part validates against the route's own schema, so a tool call obeys the same contract as a request. - const rawBody = modelBody ? (bodyKey ? rest[bodyKey] : rest) : undefined; - const input = { - params: params.parse(pick(paramKeys)), - query: query.parse(pick(queryKeys)), - body: body && modelBody ? body.parse(modelBody.withServerStx(rawBody)) : undefined, - }; - // The parts were validated by the route's own schemas, which is what `ToolInput` describes. - return meta.execute(ctx, input as unknown as ToolInput); - }, - }); -} - -/** Every MCP tool any route registered; scope is enforced at call time so a client can discover what to step up to. */ -export function getMcpTools(): readonly McpTool[] { - return tools; -} diff --git a/backend/src/core/openapi-extensions.ts b/backend/src/core/openapi-extensions.ts index 977cfd7f7..48ce0b28e 100644 --- a/backend/src/core/openapi-extensions.ts +++ b/backend/src/core/openapi-extensions.ts @@ -1,22 +1,6 @@ -import type { RouteConfig, z } from '@hono/zod-openapi'; -import type { Context, MiddlewareHandler } from 'hono'; -import type { AccessScopedEntityType, appConfig } from 'shared'; -import type { BaseAuthStrategies, BaseOAuthProviders } from 'shared/config-builder/types'; -import type { Env, OrgContext } from '#/core/context'; - -/** Services that can gate a route, derived from appConfig.services. */ -export type ServiceGate = keyof typeof appConfig.services; - -/** - * The sign-in method an auth route belongs to: a strategy, or `{ oauth: provider }` for one OAuth provider. A route whose - * strategy depends on the request (a token's type) names it per request. `null` keeps a route reachable while its - * strategy is switched off, for cleanup such as deleting a factor. - */ -export type StrategyGate = - | BaseAuthStrategies - | { oauth: BaseOAuthProviders } - | null - | ((ctx: Context) => BaseAuthStrategies | null); +import type { MiddlewareHandler } from 'hono'; +import type { AccessScopedEntityType, ConfigSwitch } from 'shared'; +import type { Env } from '#/core/context'; export type MiddlewareArray = readonly MiddlewareHandler[]; @@ -30,44 +14,18 @@ export type ExtensionMetadata = { kind: 'middleware' | 'metadata'; }; -/** Add new extensions here to expose them in the OpenAPI spec. */ +/** Add new extensions here to expose them in the OpenAPI spec. In the order they apply to a request. */ export const extensionMap = { - 'x-guard': { - id: 'xGuard', - description: 'Authorization middleware applied to the endpoint', - required: true, - kind: 'middleware', - }, - 'x-rate-limiter': { - id: 'xRateLimiter', - description: 'Rate limiting rules applied to the endpoint', - required: false, - kind: 'middleware', - }, - 'x-cache': { - id: 'xCache', - description: 'Caching strategy applied to the endpoint', - required: false, - kind: 'middleware', - }, - 'x-tool': { - id: 'xTool', - description: 'MCP tool registration metadata', - required: false, - kind: 'metadata', - }, - 'x-service': { - id: 'x-service', - description: 'Service gating the endpoint; route returns 404 when the service is disabled', - required: false, - kind: 'metadata', - }, - 'x-strategy': { - id: 'x-strategy', - description: 'Sign-in method the endpoint belongs to; refused while that method is switched off', + 'x-enabled-by': { + id: 'xEnabledBy', + description: 'Config switch the endpoint belongs to: a service, a sign-in method or an OAuth provider; refused before the guards while it is off', required: false, kind: 'metadata', }, + 'x-guard': { id: 'xGuard', description: 'Authorization middleware applied to the endpoint', required: true, kind: 'middleware' }, + 'x-rate-limiter': { id: 'xRateLimiter', description: 'Rate limiting rules applied to the endpoint', required: false, kind: 'middleware' }, + 'x-cache': { id: 'xCache', description: 'Caching strategy applied to the endpoint', required: false, kind: 'middleware' }, + 'x-tool': { id: 'xTool', description: 'MCP tool registration metadata', required: false, kind: 'metadata' }, } as const satisfies Record; export type ExtensionType = keyof typeof extensionMap; @@ -85,63 +43,29 @@ export type XMiddlewareHandler = MiddlewareHandler & { export type SpecificationExtensions = Record; /** Value metadata for individual extension values (e.g., each limiter or guard) */ -export type ExtensionValueMetadata = { - name?: string; - description: string; -}; +export type ExtensionValueMetadata = { name?: string; description: string }; -export type ExtensionEntry = { - key: string; - id: string; - description: string; - values?: Record; -}; - -/** The route fields a tool is derived from. */ -export type ToolRoute = { operationId: string; method: string; request?: RouteConfig['request'] }; - -type InferSchema = S extends z.ZodType ? z.infer : Record; -type JsonBodySchema = Req extends { body: { content: { 'application/json': { schema: infer S } } } } ? S : never; +export type ExtensionEntry = { key: string; id: string; description: string; values?: Record }; -/** - * What a tool's `execute` receives: the route's request parts, each validated by the route's own schema, with - * `tenantId` / `organizationId` resolved by the MCP endpoint and the sync transaction rebuilt server-side. - */ -export type ToolInput = { - params: Omit, 'tenantId' | 'organizationId'>; - query: InferSchema; - body: [JsonBodySchema] extends [never] ? undefined : InferSchema>; -}; - -/** What the OpenAPI spec shows under `x-tool`; `execute` never leaves the process. */ -export type XToolSpec = { - /** Whether this route is exposed as an MCP tool */ - enabled: boolean; - /** LLM-friendly description of what this tool does */ +/** A route opts in as an MCP tool by carrying this; the input schema derives from the route's `request`. */ +export type XTool = { + /** What the tool does, written for a model */ description: string; - /** Whether user approval is required before execution (write MCP tools) */ + /** Whether the client asks its user before running the tool */ approvalRequired: boolean; - category: string; /** The entity the route acts on: with the method it names the scope a token needs (`:read` | `:write`). */ entity: AccessScopedEntityType; }; -/** A route opts in as an MCP tool by carrying this; the input schema derives from the route's `request`. */ -export type XToolMetadata = XToolSpec & { - /** The route's operation, called in-process with the MCP request's context and the validated request parts. */ - execute: (ctx: OrgContext, input: ToolInput) => Promise; -}; - /** When adding an extension to `extensionMap`, add its prop here too. */ -export type XMiddlewareOptions = { +export type XMiddlewareOptions = { + /** The config switch the route belongs to; `createXRoute` refuses the route while it is off, before its guards. */ + xEnabledBy?: ConfigSwitch; xGuard: MiddlewareArray; xRateLimiter?: MiddlewareArray; xCache?: MiddlewareArray; - 'x-tool'?: XToolMetadata; - /** Route 404s when the service is disabled. */ - 'x-service'?: ServiceGate; - /** Route is refused while its sign-in method is switched off in `appConfig`. */ - 'x-strategy'?: StrategyGate; + /** Exposes the route as an MCP tool. */ + xTool?: XTool; }; export type ExtensionPropId = keyof XMiddlewareOptions; @@ -151,11 +75,16 @@ export const collectExtensionMiddleware = (config: Record): Mid .filter(({ kind }) => kind === 'middleware') .flatMap(({ id }) => (config[id] as MiddlewareHandler[]) ?? []); -/** Get middleware extension prop IDs from the map (e.g., ['xGuard', 'xRateLimiter']). Metadata extensions use raw keys and are not stripped. */ -export const getExtensionPropIds = (): string[] => - Object.values(extensionMap) - .filter(({ kind }) => kind === 'middleware') - .map(({ id }) => id); +/** The route prop ids of every extension (`xGuard`, `xRateLimiter`, `xCache`, `xTool`, `xEnabledBy`), kept out of the spec. */ +export const getExtensionPropIds = (): string[] => Object.values(extensionMap).map(({ id }) => id); + +/** The metadata extensions a route declares, under their spec keys (`xTool` as `x-tool`, `xEnabledBy` as `x-enabled-by`). */ +export const createMetadataExtensions = (config: Record): Record => + Object.fromEntries( + Object.entries(extensionMap) + .filter(([, { id, kind }]) => kind === 'metadata' && config[id] !== undefined) + .map(([key, { id }]) => [key, config[id]]), + ); export function createSpecificationExtensions(getValue: (key: ExtensionType) => string[]): SpecificationExtensions { const keys = (Object.keys(extensionMap) as ExtensionType[]).filter((key) => extensionMap[key].kind === 'middleware'); @@ -163,9 +92,7 @@ export function createSpecificationExtensions(getValue: (key: ExtensionType) => } /** @param valueMetadata - keyed by `"extensionType:functionName"`. */ -export function buildExtensionEntries( - valueMetadata: Map, -): ExtensionEntry[] { +export function buildExtensionEntries(valueMetadata: Map): ExtensionEntry[] { return Object.entries(extensionMap).map(([key, metadata]) => { const values: Record = {}; for (const [mapKey, meta] of valueMetadata) { @@ -175,10 +102,6 @@ export function buildExtensionEntries( } } - return { - key, - ...metadata, - ...(Object.keys(values).length > 0 ? { values } : {}), - }; + return { key, ...metadata, ...(Object.keys(values).length > 0 ? { values } : {}) }; }); } diff --git a/backend/src/core/openapi-registration.ts b/backend/src/core/openapi-registration.ts index cc234acb7..dc1c1c6af 100644 --- a/backend/src/core/openapi-registration.ts +++ b/backend/src/core/openapi-registration.ts @@ -8,9 +8,9 @@ import { normalizeOpenApiDocument, validateOpenApiDocument } from '#/core/openap import { getExtensionValueMetadata } from '#/core/x-middleware'; import { authCookieName } from '#/modules/auth/general/helpers/cookie'; import { membershipBaseSchema } from '#/modules/memberships/memberships-schema'; -import { booleanTransformSchema, errorResponses, productBaseSchema, registerAllErrorResponses } from '#/schemas'; +import { errorResponses, productBaseSchema, registerAllErrorResponses } from '#/schemas'; import { channelBaseSchema } from '#/schemas/entity-base'; -import { organizationMinimalBaseSchema, userMinimalBaseSchema } from '#/schemas/minimal-base'; +import { userMinimalBaseSchema } from '#/schemas/minimal-base'; import { streamNotificationSchema } from '#/schemas/stream-schemas'; import { stxBaseSchema } from '#/schemas/sync-transaction-schemas'; import { userBaseSchema } from '#/schemas/user-schema-base'; @@ -29,12 +29,7 @@ const registerOpenApiDocs = async (app: OpenAPIHono) => { const openApiConfig = { servers: [{ url: appConfig.backendUrl }], - info: { - title: `${appConfig.name} API`, - version: appConfig.apiVersion, - description: appConfig.apiDescription, - 'x-extensions': extensions, - }, + info: { title: `${appConfig.name} API`, version: appConfig.apiVersion, description: appConfig.apiDescription, 'x-extensions': extensions }, openapi: '3.1.0', // Tag registry provides ordered tags with optional 3.2.0 fields (summary, parent, kind, externalDocs). tags: getRegisteredTags().map((t) => ({ @@ -82,13 +77,11 @@ const registerOpenApiDocs = async (app: OpenAPIHono) => { // Register base schemas (not auto-registered as they're only used for extending other schemas) registry.register('UserMinimalBase', userMinimalBaseSchema); - registry.register('OrganizationMinimalBase', organizationMinimalBaseSchema); registry.register('UserBase', userBaseSchema); registry.register('ChannelBase', channelBaseSchema); registry.register('ProductBase', productBaseSchema); registry.register('MembershipBase', membershipBaseSchema); registry.register('StxBase', stxBaseSchema); - registry.register('BooleanQueryValue', booleanTransformSchema); registry.register('StreamNotification', streamNotificationSchema); registerAllErrorResponses(registry, errorResponses); diff --git a/backend/src/core/openapi-tag-registry.ts b/backend/src/core/openapi-tag-registry.ts index b4d21a189..7be32dc98 100644 --- a/backend/src/core/openapi-tag-registry.ts +++ b/backend/src/core/openapi-tag-registry.ts @@ -34,37 +34,16 @@ export const registerTag = (tag: OpenApiTag): OpenApiTag => { export const getRegisteredTags = (): OpenApiTag[] => [...tagRegistry.values()]; /** Default owner tags, registered eagerly so module tags can reference them as parents. */ -registerTag({ - tag: 'cella', - kind: 'owner', - description: 'Core modules provided by cella.', -}); +registerTag({ tag: 'cella', kind: 'owner', description: 'Core modules provided by cella.' }); -registerTag({ - tag: 'app', - kind: 'owner', - description: 'Application-specific modules.', -}); +registerTag({ tag: 'app', kind: 'owner', description: 'Application-specific modules.' }); // Schema tags group OpenAPI components in the docs UI: components opt in through `x-tags`, unmatched ones use the default. -registerTag({ - tag: 'data', - kind: 'schema', - default: true, - description: 'Complete data schemas', -}); +registerTag({ tag: 'data', kind: 'schema', default: true, description: 'Complete data schemas' }); -registerTag({ - tag: 'base', - kind: 'schema', - description: 'Schemas with base fields only', -}); +registerTag({ tag: 'base', kind: 'schema', description: 'Schemas with base fields only' }); -registerTag({ - tag: 'errors', - kind: 'schema', - description: 'Error schemas', -}); +registerTag({ tag: 'errors', kind: 'schema', description: 'Error schemas' }); // Entity-kind tags declare an operation's entity scope: a route adds `'channel'` or `'product'` to its `tags`. registerTag({ @@ -80,8 +59,4 @@ registerTag({ }); // Per-operation hide: a route adds `'internal'` to its `tags` to drop from docs while staying in the SDK. -registerTag({ - tag: 'internal', - kind: 'hidden', - description: 'Operations hidden from the public API reference.', -}); +registerTag({ tag: 'internal', kind: 'hidden', description: 'Operations hidden from the public API reference.' }); diff --git a/backend/src/core/openapi-validation.ts b/backend/src/core/openapi-validation.ts index d938c47b0..5d55eb3d5 100644 --- a/backend/src/core/openapi-validation.ts +++ b/backend/src/core/openapi-validation.ts @@ -21,9 +21,7 @@ const validateSchemaTags = (doc: Record) => { if (!Array.isArray(tags)) throw new Error(`Schema "${name}" has non-array x-tags`); for (const t of tags) { if (typeof t !== 'string' || !known.has(t)) { - throw new Error( - `Schema "${name}" references unknown x-tag "${String(t)}". Register it in openapi-tag-registry.ts.`, - ); + throw new Error(`Schema "${name}" references unknown x-tag "${String(t)}". Register it in openapi-tag-registry.ts.`); } } } diff --git a/backend/src/core/schema-evolution/lens-seam.ts b/backend/src/core/schema-evolution/lens-seam.ts index db8fdacaa..f7105ba20 100644 --- a/backend/src/core/schema-evolution/lens-seam.ts +++ b/backend/src/core/schema-evolution/lens-seam.ts @@ -17,9 +17,7 @@ export function normalizeCreateItem(entityType: Lens /** A required canonical field becomes an alias-or-canonical requirement; the static type is unchanged. */ export function widenBodySchema>(entityType: LensEntityType, schema: T): T { - const pairs = Object.entries(widenedOpsKeyMap(entityType)).filter( - ([from, to]) => to in schema.shape && !(from in schema.shape), - ); + const pairs = Object.entries(widenedOpsKeyMap(entityType)).filter(([from, to]) => to in schema.shape && !(from in schema.shape)); if (pairs.length === 0) return schema; let widened: z.ZodObject = schema; @@ -35,11 +33,7 @@ export function widenBodySchema>(entityType return widened.superRefine((val: Record, ctx) => { for (const [from, to] of requiredPairs) { if (val[from] === undefined && val[to] === undefined) { - ctx.addIssue({ - code: 'custom', - message: `Either "${to}" or its legacy alias "${from}" must be provided`, - path: [to], - }); + ctx.addIssue({ code: 'custom', message: `Either "${to}" or its legacy alias "${from}" must be provided`, path: [to] }); } } }) as unknown as T; diff --git a/backend/src/core/schema-evolution/tests/lens-seam.test.ts b/backend/src/core/schema-evolution/tests/lens-seam.test.ts index 4f3b56a5b..97d32400a 100644 --- a/backend/src/core/schema-evolution/tests/lens-seam.test.ts +++ b/backend/src/core/schema-evolution/tests/lens-seam.test.ts @@ -9,11 +9,7 @@ vi.mock('shared/schema-evolution', async (importOriginal) => { return { ...actual, widenedOpsKeyMap: (entityType: LensEntityType) => (entityType === 'attachment' ? { name: 'title' } : {}), - normalizeOps: ( - entityType: LensEntityType, - ops: Record, - stx: { fieldTimestamps?: Record }, - ) => { + normalizeOps: (entityType: LensEntityType, ops: Record, stx: { fieldTimestamps?: Record }) => { if (entityType !== 'attachment') return { ops, stx, unknownFields: [] }; // Synthetic expand rename: canonicalize name → title, mirror-write the twin. const nextOps = { ...ops }; @@ -39,12 +35,7 @@ import { _resetHLC, compareHLC } from '#/core/stx/hlc'; import { resolveServerUpdateOps, resolveUpdateOps } from '#/core/stx/resolve-update'; // Replayed writes keep their client timestamps, which is what the lens expectations below observe. -const stx = (fieldTimestamps: Record) => ({ - mutationId: 'm1', - sourceId: 's1', - fieldTimestamps, - replayed: true, -}); +const stx = (fieldTimestamps: Record) => ({ mutationId: 'm1', sourceId: 's1', fieldTimestamps, replayed: true }); const hlc = '100:0001:aaaaa'; afterEach(() => _resetHLC()); @@ -71,9 +62,7 @@ describe('createUpdateSchema widening', () => { it('rejects malformed and unrelated HLC entries', () => { expect(() => schema.parse({ ops: { title: 'x' }, stx: stx({ title: 'invalid' }) })).toThrow(/Invalid HLC/); - expect(() => schema.parse({ ops: { title: 'x' }, stx: stx({ title: hlc, other: hlc }) })).toThrow( - /does not match a scalar op/, - ); + expect(() => schema.parse({ ops: { title: 'x' }, stx: stx({ title: hlc, other: hlc }) })).toThrow(/does not match a scalar op/); }); it('accepts an AWSet delta without an HLC', () => { @@ -81,9 +70,7 @@ describe('createUpdateSchema widening', () => { expect(deltaSchema.parse({ ops: { labels: { add: ['a'] } }, stx: stx({}) }).ops).toEqual({ labels: { add: ['a'], remove: [] }, }); - expect(() => deltaSchema.parse({ ops: { labels: { add: ['a'] } }, stx: stx({ labels: hlc }) })).toThrow( - /does not match a scalar op/, - ); + expect(() => deltaSchema.parse({ ops: { labels: { add: ['a'] } }, stx: stx({ labels: hlc }) })).toThrow(/does not match a scalar op/); }); }); @@ -100,17 +87,8 @@ describe('arrayDeltaSchema', () => { it.each([ { add: ['not-an-id'] }, { add: ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001'] }, - { - add: [ - '00000000-0000-4000-8000-000000000001', - '00000000-0000-4000-8000-000000000002', - '00000000-0000-4000-8000-000000000003', - ], - }, - { - add: ['00000000-0000-4000-8000-000000000001'], - remove: ['00000000-0000-4000-8000-000000000001'], - }, + { add: ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000003'] }, + { add: ['00000000-0000-4000-8000-000000000001'], remove: ['00000000-0000-4000-8000-000000000001'] }, ])('rejects an invalid delta %j', (input) => { expect(deltaSchema.safeParse(input).success).toBe(false); }); diff --git a/backend/src/core/schema-evolution/update-schema.ts b/backend/src/core/schema-evolution/update-schema.ts index 63e3dddab..96d09db29 100644 --- a/backend/src/core/schema-evolution/update-schema.ts +++ b/backend/src/core/schema-evolution/update-schema.ts @@ -11,36 +11,23 @@ import { widenBodySchema } from './lens-seam'; export function createUpdateSchema(entityType: ProductEntityType, opsShape: T) { const partialOps = widenBodySchema(entityType, z.object(opsShape).partial()); - return z - .object({ - ops: partialOps, - stx: stxBaseSchema, - }) - .superRefine((val, ctx) => { - const opEntries = Object.entries(val.ops); - const opsByField = new Map(opEntries); - if (opEntries.length === 0) { - ctx.addIssue({ code: 'custom', message: 'At least one op must be provided', path: ['ops'] }); - } + return z.object({ ops: partialOps, stx: stxBaseSchema }).superRefine((val, ctx) => { + const opEntries = Object.entries(val.ops); + const opsByField = new Map(opEntries); + if (opEntries.length === 0) { + ctx.addIssue({ code: 'custom', message: 'At least one op must be provided', path: ['ops'] }); + } - for (const [field, value] of opEntries) { - if (!isArrayDelta(value) && !(field in val.stx.fieldTimestamps)) { - ctx.addIssue({ - code: 'custom', - message: `Missing HLC timestamp for scalar op "${field}"`, - path: ['stx', 'fieldTimestamps', field], - }); - } + for (const [field, value] of opEntries) { + if (!isArrayDelta(value) && !(field in val.stx.fieldTimestamps)) { + ctx.addIssue({ code: 'custom', message: `Missing HLC timestamp for scalar op "${field}"`, path: ['stx', 'fieldTimestamps', field] }); } + } - for (const field of Object.keys(val.stx.fieldTimestamps)) { - if (!opsByField.has(field) || isArrayDelta(opsByField.get(field))) { - ctx.addIssue({ - code: 'custom', - message: `Timestamp "${field}" does not match a scalar op`, - path: ['stx', 'fieldTimestamps', field], - }); - } + for (const field of Object.keys(val.stx.fieldTimestamps)) { + if (!opsByField.has(field) || isArrayDelta(opsByField.get(field))) { + ctx.addIssue({ code: 'custom', message: `Timestamp "${field}" does not match a scalar op`, path: ['stx', 'fieldTimestamps', field] }); } - }); + } + }); } diff --git a/backend/src/core/stx/array-delta.ts b/backend/src/core/stx/array-delta.ts index 720fde2b3..d55021964 100644 --- a/backend/src/core/stx/array-delta.ts +++ b/backend/src/core/stx/array-delta.ts @@ -7,23 +7,15 @@ export const arrayDeltaSchema = >(itemSchema: T, max .max(maxItems) .refine((items) => new Set(items).size === items.length, 'Delta items must be unique'); - return z - .object({ - add: itemsSchema.default([]), - remove: itemsSchema.default([]), - }) - .superRefine(({ add, remove }, ctx) => { - const removed = new Set(remove); - if (add.some((item) => removed.has(item))) { - ctx.addIssue({ code: 'custom', message: 'The same item cannot be added and removed' }); - } - }); + return z.object({ add: itemsSchema.default([]), remove: itemsSchema.default([]) }).superRefine(({ add, remove }, ctx) => { + const removed = new Set(remove); + if (add.some((item) => removed.has(item))) { + ctx.addIssue({ code: 'custom', message: 'The same item cannot be added and removed' }); + } + }); }; -export type ArrayDelta = { - add: string[]; - remove: string[]; -}; +export type ArrayDelta = { add: string[]; remove: string[] }; /** Runtime check: is this value a set delta (`{ add, remove }`)? */ export function isArrayDelta(value: unknown): value is ArrayDelta { diff --git a/backend/src/core/stx/build-stx.ts b/backend/src/core/stx/build-stx.ts index 0c3e72b5f..8ee8e2bd1 100644 --- a/backend/src/core/stx/build-stx.ts +++ b/backend/src/core/stx/build-stx.ts @@ -27,10 +27,5 @@ export function buildStx(stx: StxBase, entity?: { stx: StxBase }, acceptedFieldN // Every user-driven update writes updatedAt, which is how the CDC worker tells user edits from its own writes. const changedFields = acceptedFieldNames && entity ? [...acceptedFieldNames, 'updatedAt'] : acceptedFieldNames; - return { - mutationId: stx.mutationId, - sourceId: stx.sourceId, - fieldTimestamps: mergedTimestamps, - ...(changedFields && { changedFields }), - }; + return { mutationId: stx.mutationId, sourceId: stx.sourceId, fieldTimestamps: mergedTimestamps, ...(changedFields && { changedFields }) }; } diff --git a/backend/src/core/stx/create-server-stx.ts b/backend/src/core/stx/create-server-stx.ts index 2e9c782ab..2b5c5b04c 100644 --- a/backend/src/core/stx/create-server-stx.ts +++ b/backend/src/core/stx/create-server-stx.ts @@ -5,11 +5,7 @@ import { generateServerHLC } from './hlc'; /** Trusted server mutation metadata. Creates use it directly; `resolveServerUpdateOps` adds field timestamps. */ export function createServerStx(): StxBase { - return { - mutationId: uuidv7(), - sourceId: 'server', - fieldTimestamps: {}, - }; + return { mutationId: uuidv7(), sourceId: 'server', fieldTimestamps: {} }; } /** diff --git a/backend/src/core/stx/field-versions.ts b/backend/src/core/stx/field-versions.ts index cec282efe..590cb7f52 100644 --- a/backend/src/core/stx/field-versions.ts +++ b/backend/src/core/stx/field-versions.ts @@ -5,10 +5,7 @@ function isPrimitive(value: unknown): value is string | number | boolean | null } /** Drops primitive fields equal to the stored value; arrays and objects pass through without deep comparison. */ -export function filterNoOpFields>( - entityData: Record, - incomingFields: T, -): T { +export function filterNoOpFields>(entityData: Record, incomingFields: T): T { const result = {} as Record; for (const [key, value] of Object.entries(incomingFields)) { if (isPrimitive(value) && entityData[key] === value) continue; diff --git a/backend/src/core/stx/hlc.ts b/backend/src/core/stx/hlc.ts index b462a3b40..22c8c959b 100644 --- a/backend/src/core/stx/hlc.ts +++ b/backend/src/core/stx/hlc.ts @@ -16,11 +16,7 @@ let lastCounter = 0n; export function parseHLC(value: string): ParsedHLC | null { const match = hlcPattern.exec(value); if (!match) return null; - return { - timestamp: BigInt(match[1]), - counter: BigInt(match[2]), - source: match[3], - }; + return { timestamp: BigInt(match[1]), counter: BigInt(match[2]), source: match[3] }; } export function isValidHLC(value: string): boolean { diff --git a/backend/src/core/stx/tests/build-stx.test.ts b/backend/src/core/stx/tests/build-stx.test.ts index b92683bdd..0157b7a56 100644 --- a/backend/src/core/stx/tests/build-stx.test.ts +++ b/backend/src/core/stx/tests/build-stx.test.ts @@ -18,11 +18,7 @@ describe('buildStx', () => { describe('update (with entity + acceptedFieldNames)', () => { it('merges incoming HLC timestamps for accepted fields and keeps the incoming mutationId and sourceId', () => { const entity = { - stx: { - mutationId: 'old', - sourceId: 'old', - fieldTimestamps: { name: '100:0001:aaaaa', status: '200:0001:bbbbb' }, - }, + stx: { mutationId: 'old', sourceId: 'old', fieldTimestamps: { name: '100:0001:aaaaa', status: '200:0001:bbbbb' } }, }; const stx = { mutationId: 'mut-1', sourceId: 'src-1', fieldTimestamps: { name: '300:0001:ccccc' } }; const result = buildStx(stx, entity, ['name']); @@ -46,18 +42,10 @@ describe('buildStx', () => { stx: { mutationId: 'old', sourceId: 'old', - fieldTimestamps: { - name: '100:0001:aaaaa', - status: '200:0001:bbbbb', - description: '150:0001:aaaaa', - }, + fieldTimestamps: { name: '100:0001:aaaaa', status: '200:0001:bbbbb', description: '150:0001:aaaaa' }, }, }; - const stx = { - mutationId: 'mut-1', - sourceId: 'src-1', - fieldTimestamps: { name: '300:0001:ccccc', description: '350:0001:ccccc' }, - }; + const stx = { mutationId: 'mut-1', sourceId: 'src-1', fieldTimestamps: { name: '300:0001:ccccc', description: '350:0001:ccccc' } }; const result = buildStx(stx, entity, ['name', 'description']); expect(result.fieldTimestamps.name).toBe('300:0001:ccccc'); diff --git a/backend/src/core/stx/tests/field-versions.test.ts b/backend/src/core/stx/tests/field-versions.test.ts index 0bc0a2b7d..a41a7f374 100644 --- a/backend/src/core/stx/tests/field-versions.test.ts +++ b/backend/src/core/stx/tests/field-versions.test.ts @@ -53,16 +53,8 @@ describe('resolveFieldConflicts', () => { it('handles multiple fields with mixed results', () => { const incoming = { name: 'A', status: 'B', description: 'C' }; - const incomingTs = { - name: '300:0001:aaaaa', - status: '100:0001:aaaaa', - description: '250:0001:aaaaa', - }; - const storedTs = { - name: '200:0001:bbbbb', - status: '200:0001:bbbbb', - description: '200:0001:bbbbb', - }; + const incomingTs = { name: '300:0001:aaaaa', status: '100:0001:aaaaa', description: '250:0001:aaaaa' }; + const storedTs = { name: '200:0001:bbbbb', status: '200:0001:bbbbb', description: '200:0001:bbbbb' }; const result = resolveFieldConflicts(incoming, incomingTs, storedTs); expect(result).toEqual({ name: 'A', description: 'C' }); diff --git a/backend/src/core/stx/tests/hlc.test.ts b/backend/src/core/stx/tests/hlc.test.ts index 201e70df5..8b7f62cae 100644 --- a/backend/src/core/stx/tests/hlc.test.ts +++ b/backend/src/core/stx/tests/hlc.test.ts @@ -6,11 +6,7 @@ describe('HLC wire format', () => { afterEach(() => _resetHLC()); it('parses canonical timestamps', () => { - expect(parseHLC('1700000000000:0007:0abcd')).toEqual({ - timestamp: 1700000000000n, - counter: 7n, - source: '0abcd', - }); + expect(parseHLC('1700000000000:0007:0abcd')).toEqual({ timestamp: 1700000000000n, counter: 7n, source: '0abcd' }); expect(isValidHLC('1700000000000:0007:0abcd')).toBe(true); }); diff --git a/backend/src/core/x-middleware.ts b/backend/src/core/x-middleware.ts index 77c992f5a..26ef1643b 100644 --- a/backend/src/core/x-middleware.ts +++ b/backend/src/core/x-middleware.ts @@ -25,40 +25,26 @@ const extensionValueMetadata = new Map extensionValueMetadata; /** Sets `.name`, `.__extensionType` and `.__description` for OpenAPI introspection. */ -export const xMiddleware = ( - options: XMiddlewareOptions, - fn: MiddlewareFunction, -): XMiddlewareHandler => { +export const xMiddleware = (options: XMiddlewareOptions, fn: MiddlewareFunction): XMiddlewareHandler => { const { functionName, type, name, description, security } = options; if (description) { extensionValueMetadata.set(`${type}:${functionName}`, { name, description }); } - const middleware = Object.assign(createMiddleware(fn), { - __extensionType: type, - __description: description, - __security: security, - }); + const middleware = Object.assign(createMiddleware(fn), { __extensionType: type, __description: description, __security: security }); // name requires Object.defineProperty since function.name is read-only in JS. Object.defineProperty(middleware, 'name', { value: functionName, writable: false }); return middleware; }; /** For composed middlewares such as `every()`. */ -export const setMiddlewareExtension = ( - middleware: MiddlewareHandler, - options: XMiddlewareOptions, -): XMiddlewareHandler => { +export const setMiddlewareExtension = (middleware: MiddlewareHandler, options: XMiddlewareOptions): XMiddlewareHandler => { const { functionName, type, name, description, security } = options; if (description) { extensionValueMetadata.set(`${type}:${functionName}`, { name, description }); } - const extended = Object.assign(middleware, { - __extensionType: type, - __description: description, - __security: security, - }); + const extended = Object.assign(middleware, { __extensionType: type, __description: description, __security: security }); Object.defineProperty(extended, 'name', { value: functionName, writable: false }); return extended; }; diff --git a/backend/src/core/x-routes.test.ts b/backend/src/core/x-routes.test.ts new file mode 100644 index 000000000..fd15866ef --- /dev/null +++ b/backend/src/core/x-routes.test.ts @@ -0,0 +1,113 @@ +import { OpenAPIHono, z } from '@hono/zod-openapi'; +import { appConfig } from 'shared'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import type { Env } from '#/core/context'; +import { AppError } from '#/core/error'; +import { xMiddleware } from '#/core/x-middleware'; +import { createXRoute, createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { publicGuard } from '#/middlewares/guard'; +import { errorResponseRefs } from '#/schemas'; + +const itemSchema = z.object({ id: z.string() }); + +describe('route helpers', () => { + const routes = createXRoutes(['things', 'app'], { + getThing: xRoute({ + method: 'get', + path: '/{id}', + xGuard: [publicGuard], + summary: 'Get thing', + description: 'Returns a thing.', + request: { params: itemSchema }, + responses: { 200: json('Thing', itemSchema, { id: 'a' }) }, + }), + createThing: xRoute({ + operationId: 'thingCreate', + method: 'post', + path: '/', + xGuard: [publicGuard], + summary: 'Create thing', + description: 'Creates a thing.', + request: { body: jsonBody(itemSchema) }, + responses: { 201: json('Created', itemSchema) }, + }), + }); + + it('names a route by its key, first, and places the module tags right before the summary', () => { + const keys = Object.keys(routes.getThing).filter((key) => key !== 'security'); + expect(keys.slice(0, 2)).toEqual(['operationId', 'method']); + expect(keys.indexOf('tags')).toBe(keys.indexOf('summary') - 1); + expect(routes.getThing).toMatchObject({ operationId: 'getThing', tags: ['things', 'app'] }); + }); + + it('keeps an operationId the route sets', () => { + expect(routes.createThing.operationId).toBe('thingCreate'); + }); + + it('appends the error responses to every route, after its own', () => { + expect(Object.keys(routes.getThing.responses)).toEqual(['200', ...Object.keys(errorResponseRefs)]); + expect(routes.createThing.responses).toMatchObject({ 201: { description: 'Created' }, ...errorResponseRefs }); + }); + + it('builds a JSON response with its example only when given, and a required JSON body', () => { + expect(json('Thing', itemSchema)).toEqual({ + description: 'Thing', + content: { 'application/json': { schema: itemSchema } }, + }); + expect(routes.getThing.responses[200].content['application/json']).toEqual({ schema: itemSchema, example: { id: 'a' } }); + expect(routes.createThing.request.body).toEqual({ + required: true, + content: { 'application/json': { schema: itemSchema } }, + }); + }); + + it('appends the error responses in createXRoute too, where a route spreading them keeps one copy', () => { + const route = createXRoute({ + operationId: 'single', + method: 'get', + path: '/single', + xGuard: [publicGuard], + tags: ['things'], + summary: 'Single', + responses: { 204: { description: 'Done' }, ...errorResponseRefs }, + }); + expect(Object.keys(route.responses)).toEqual(['204', ...Object.keys(errorResponseRefs)]); + }); + + it('refuses a route whose config switch is off before its guards, and documents the switch as x-enabled-by', async () => { + const guardRan = vi.fn(); + const guard = xMiddleware({ functionName: 'recordingGuard', type: 'x-guard' }, async (_ctx, next) => { + guardRan(); + await next(); + }); + const route = createXRoute({ + operationId: 'switched', + method: 'get', + path: '/switched', + xEnabledBy: { service: 'mcp' }, + xGuard: [guard], + tags: ['things'], + summary: 'Switched', + responses: { 204: { description: 'Done' } }, + }); + expect(route).toMatchObject({ 'x-enabled-by': { service: 'mcp' } }); + expect(route).not.toHaveProperty('xEnabledBy'); + + const app = new OpenAPIHono(); + app.openapi(route, (ctx) => ctx.body(null, 204)); + app.onError((error, ctx) => ctx.json({ status: error instanceof AppError ? error.status : 500 })); + + const enabled = appConfig.services.mcp.enabled; + onTestFinished(() => { + appConfig.services.mcp.enabled = enabled; + }); + appConfig.services.mcp.enabled = false; + expect(await (await app.request('/switched')).json()).toEqual({ status: 404 }); + expect(guardRan).not.toHaveBeenCalled(); + + // Positive control: switched on, the guard runs and the route answers. + appConfig.services.mcp.enabled = true; + expect((await app.request('/switched')).status).toBe(204); + expect(guardRan).toHaveBeenCalledOnce(); + }); +}); diff --git a/backend/src/core/x-routes.ts b/backend/src/core/x-routes.ts index b3c105143..fcbe8a354 100644 --- a/backend/src/core/x-routes.ts +++ b/backend/src/core/x-routes.ts @@ -1,104 +1,54 @@ -import { createRoute } from '@hono/zod-openapi'; +import { createRoute, type z } from '@hono/zod-openapi'; import type { MiddlewareHandler } from 'hono'; -import { appConfig } from 'shared'; import type { Env } from '#/core/context'; -import { AppError } from '#/core/error'; -import { registerMcpTool } from '#/core/mcp-tool-registry'; -import type { ServiceGate, StrategyGate, XMiddlewareHandler } from '#/core/openapi-extensions'; import { collectExtensionMiddleware, + createMetadataExtensions, createSpecificationExtensions, type ExtensionPropId, getExtensionPropIds, + type XMiddlewareHandler, type XMiddlewareOptions, - type XToolMetadata, } from '#/core/openapi-extensions'; - -/** Runs before guards so a disabled service 404s without exposing auth behavior. Read per request. */ -const createServiceGate = - (service: ServiceGate): MiddlewareHandler => - async (_ctx, next) => { - if (appConfig.services[service]?.enabled === false) throw new AppError(404, 'route_not_found', 'warn'); - await next(); - }; - -/** - * Refuses an auth route while its sign-in method is off, before any guard runs, so no handler can forget the check. - * Read per request: the enabled strategies can change at runtime (tests switch them). - */ -const createStrategyGate = - (gate: Exclude): MiddlewareHandler => - async (ctx, next) => { - if (typeof gate === 'object') { - const provider = gate.oauth; - if ( - !appConfig.enabledAuthStrategies.includes('oauth') || - !appConfig.enabledOAuthProviders.some((p) => p === provider) - ) { - throw new AppError(400, 'unsupported_oauth', 'error', { meta: { strategy: provider } }); - } - } else { - const strategy = typeof gate === 'function' ? gate(ctx) : gate; - if (strategy && !appConfig.enabledAuthStrategies.includes(strategy)) { - throw new AppError(400, 'forbidden_strategy', 'error', { meta: { strategy } }); - } - } - await next(); - }; +import { configSwitchGate } from '#/middlewares/config-switch'; +import { errorResponseRefs } from '#/schemas/error-response-schemas'; /** - * A route that answers 302 is a browser navigation: whatever refuses the request, a gate, a limiter or the handler, - * answers with a redirect to the error page (`appErrorHandler`). Set before anything can refuse; a handler may point - * it elsewhere. + * A route that answers 302 is a browser navigation: whatever refuses the request, the switch gate, a guard, a limiter + * or the handler, answers with a redirect to the error page (`appErrorHandler`). Set before anything can refuse; a + * handler may point it elsewhere. */ const errorPageMiddleware: MiddlewareHandler = async (ctx, next) => { ctx.set('errorPagePath', '/auth/error'); await next(); }; -const strategyLabel = (gate: StrategyGate): string => { - if (gate === null) return 'none'; - if (typeof gate === 'function') return 'per-request'; - return typeof gate === 'object' ? `oauth:${gate.oauth}` : gate; -}; - type RouteOptions = Parameters[0] & XMiddlewareOptions & { operationId: string }; -type Route

& { path: P }> = ReturnType< - typeof createRoute> +/** The route `createXRoute` returns: its own responses plus the error `$ref`s every route answers with. */ +type Route

& { path: P }> = ReturnType< + typeof createRoute & { responses: typeof errorResponseRefs }> >; /** - * Wraps `createRoute` with extension middleware (xGuard, xRateLimiter), documented in OpenAPI as `x-*` properties. + * Wraps `createRoute` with extension middleware, documented in OpenAPI as `x-*` properties. A request passes, in this + * order: the error page (a 302 route), the config switch gate (`xEnabledBy`), `xGuard`, `xRateLimiter`, `xCache`, the + * route's own `middleware`, then validation and the handler. The switch needs no caller, so a route that is off + * answers before any guard shows how it authenticates; `middleware` runs after the guards and may read the actor. + * The error responses (`errorResponseRefs`) are appended to every route's own. * @link https://github.com/honojs/middleware/tree/main/packages/zod-openapi#configure-middleware-for-each-endpoint */ -export const createXRoute = < - P extends string, - Req extends RouteOptions['request'], - R extends Omit & { path: P }, ->( - // `x-tool.execute` is typed from this route's own `request`, so an operation receives the request parts it expects. - config: R & { request?: Req; 'x-tool'?: XToolMetadata }, -): Route => { +export const createXRoute =

& { path: P }>(config: R): Route => { const extensionMiddleware = collectExtensionMiddleware(config); - const existing = config.middleware - ? Array.isArray(config.middleware) - ? config.middleware - : [config.middleware] - : []; - - // The error page first, so every refusal finds it; then the service gate (from declarative `x-service`), so disabled - // services 404 before guards; the strategy gate next. + const existing = [config.middleware ?? []].flat(); + + // The error page first, so every refusal finds it, the switch gate's included. const errorPage = '302' in config.responses ? [errorPageMiddleware] : []; - const service = config['x-service'] as ServiceGate | undefined; - const serviceGate = service ? [createServiceGate(service)] : []; - const strategy = config['x-strategy'] as StrategyGate | undefined; - const strategyGate = strategy ? [createStrategyGate(strategy)] : []; - const middleware = [...errorPage, ...serviceGate, ...strategyGate, ...extensionMiddleware, ...existing]; + const switchGate = config.xEnabledBy ? [configSwitchGate(config.xEnabledBy)] : []; + const middleware = [...errorPage, ...switchGate, ...extensionMiddleware, ...existing]; const xMiddlewares = middleware.filter( - (mw): mw is XMiddlewareHandler => - '__extensionType' in mw && typeof (mw as XMiddlewareHandler).__extensionType === 'string', + (mw): mw is XMiddlewareHandler => '__extensionType' in mw && typeof (mw as XMiddlewareHandler).__extensionType === 'string', ); const specificationExtensions = createSpecificationExtensions((key) => xMiddlewares.filter((mw) => mw.__extensionType === key && mw.name).map((mw) => mw.name), @@ -107,27 +57,59 @@ export const createXRoute = < // Security follows the guard: the first guard that declares schemes decides; a route with none is cookie-only. const security = xMiddlewares.find((mw) => mw.__security !== undefined)?.__security ?? [{ cookieAuth: [] }]; - // Strip extension props to prevent them leaking as null in OpenAPI + // Extension props leave the route config: middleware runs, metadata returns under its `x-*` key. const extensionPropIds = getExtensionPropIds(); - const cleanConfig = Object.fromEntries( - Object.entries(config).filter(([key]) => !extensionPropIds.includes(key)), - ) as Omit; - - // The spec names the strategy as a label: a per-request gate is code, never document content. - if (strategy !== undefined) Object.assign(cleanConfig, { 'x-strategy': strategyLabel(strategy) }); - - // A route carrying `x-tool` registers itself as an MCP tool; the spec keeps the metadata, never `execute`. - const tool = config['x-tool']; - if (tool?.enabled) { - registerMcpTool({ operationId: config.operationId, method: config.method, request: config.request }, tool); - const { execute: _execute, ...spec } = tool; - Object.assign(cleanConfig, { 'x-tool': spec }); - } - - return createRoute({ - security, - ...cleanConfig, - middleware, - ...specificationExtensions, - }); + const cleanConfig = { + ...Object.fromEntries(Object.entries(config).filter(([key]) => !extensionPropIds.includes(key))), + ...createMetadataExtensions(config), + } as Omit; + + return createRoute({ security, ...cleanConfig, responses: { ...config.responses, ...errorResponseRefs }, middleware, ...specificationExtensions }); +}; + +/** A JSON response: `ctx.json(data, status)` is typed from `schema`. */ +export type JsonResponse = { + description: string; + content: { 'application/json': { schema: S; example?: unknown } }; }; + +export const json = (description: string, schema: S, example?: unknown): JsonResponse => ({ + description, + content: { 'application/json': example === undefined ? { schema } : { schema, example } }, +}); + +/** A required JSON request body: `ctx.req.valid('json')` is typed from `schema`. */ +export type JsonBody = { required: true; content: { 'application/json': { schema: S } } }; + +export const jsonBody = (schema: S): JsonBody => ({ + required: true, + content: { 'application/json': { schema } }, +}); + +/** Route options before `createXRoutes` fills in `operationId` (the key) and `tags` (the module's). */ +type DraftOptions = Omit & { operationId?: string; tags?: string[] }; + +type Draft = DraftOptions & { path: string }; + +/** The route `createXRoutes` makes of a draft: what `createXRoute` returns for it. */ +type XRoute = ReturnType & { responses: typeof errorResponseRefs }>>; + +/** One route of a `createXRoutes` module: keeps its literal types. */ +export const xRoute =

(config: R) => config; + +/** + * Finishes a module's routes with `createXRoute`: `operationId` defaults to the route's key and `tags` to the module's, + * set before `summary` so the document keeps its key order. + */ +export const createXRoutes = >(tags: string[], drafts: T): { [K in keyof T]: XRoute } => + Object.fromEntries( + Object.entries(drafts).map(([key, draft]) => { + const entries: [string, unknown][] = Object.entries(draft); + if (!draft.tags) { + const summary = entries.findIndex(([name]) => name === 'summary'); + entries.splice(summary === -1 ? entries.length : summary, 0, ['tags', tags]); + } + if (!draft.operationId) entries.unshift(['operationId', key]); + return [key, createXRoute(Object.fromEntries(entries) as Parameters[0])]; + }), + ) as { [K in keyof T]: XRoute }; diff --git a/backend/src/db/create-connection.ts b/backend/src/db/create-connection.ts index 3f10a5f5d..a637f3194 100644 --- a/backend/src/db/create-connection.ts +++ b/backend/src/db/create-connection.ts @@ -31,17 +31,14 @@ const KEEP_ALIVE_IDLE_MS = 30_000; * @param options - Pool size, TLS CA, the `DEBUG` flag and the connect timeout. * @returns The client; its pool opens a connection on the first query. */ -export const createPgConnection = ( - url: string, - { max, sslCa, debug = false, connectionTimeoutMillis = 10_000 }: CreatePgConnectionOptions, -): PgDB => +export const createPgConnection = (url: string, { max, sslCa, debug = false, connectionTimeoutMillis = 10_000 }: CreatePgConnectionOptions): PgDB => pgDrizzle({ connection: { connectionString: stripPostgresSslParams(url), connectionTimeoutMillis, max, ssl: verifiedPostgresSsl(url, sslCa), - // Long-lived pooled connections (the auth invalidation LISTEN, the job lock) sit idle for minutes. + // Long-lived pooled connections (the job lock) sit idle for minutes. keepAlive: true, keepAliveInitialDelayMillis: KEEP_ALIVE_IDLE_MS, }, diff --git a/backend/src/db/db.ts b/backend/src/db/db.ts index 6c7025102..3af896a19 100644 --- a/backend/src/db/db.ts +++ b/backend/src/db/db.ts @@ -10,8 +10,7 @@ export const migrateConfig = { migrationsFolder: 'drizzle', migrationsSchema: 'd // In production we require a verified TLS connection to the managed PostgreSQL. const sslCa = resolvePostgresSslCa(env.DATABASE_SSL_CA, env.NODE_ENV === 'production' && !env.NODB); -const connect = (connectionString: string, max: number): PgDB => - createPgConnection(connectionString, { max, sslCa, debug: env.DEBUG }); +const connect = (connectionString: string, max: number): PgDB => createPgConnection(connectionString, { max, sslCa, debug: env.DEBUG }); /** Probes exempt from the NODB throw: `prepared.ts` reads `select`, the pool probe reads `$client`. */ const noDbProbeKeys: ReadonlySet = new Set(['select', '$client']); @@ -20,9 +19,7 @@ const createNoDbStub = (): DB => new Proxy({} as DB, { get(_target, property) { if (noDbProbeKeys.has(property)) return undefined; - throw new Error( - `Database access ("${String(property)}") attempted while NODB is set. This process runs without a database connection.`, - ); + throw new Error(`Database access ("${String(property)}") attempted while NODB is set. This process runs without a database connection.`); }, }); @@ -30,8 +27,7 @@ const createNoDbStub = (): DB => export const baseDb: DB = env.NODB ? createNoDbStub() : connect(env.DATABASE_URL, env.DATABASE_POOL_MAX); /** Only a pg Pool counts its clients and hands out a connection of its own; the NODB probe yields undefined. */ -const isPool = (client: unknown): client is Pool => - typeof client === 'object' && client !== null && 'totalCount' in client; +const isPool = (client: unknown): client is Pool => typeof client === 'object' && client !== null && 'totalCount' in client; /** Waiting clients relative to pool size (0 = idle, 1 or more = queueing). Feeds the sync spread window. */ export const dbPoolPressure = (): number => { @@ -42,8 +38,9 @@ export const dbPoolPressure = (): number => { }; /** - * A connection of its own from the runtime pool, for a session that outlives one query, such as a LISTEN. The caller - * ends it with `release(true)`, which destroys it, so no session state goes back to the pool. + * A connection of its own from the runtime pool, for a session that outlives one query, such as a transaction driven + * statement by statement. The caller ends it with `release(true)`, which destroys it, so no session state goes back to + * the pool. * @param onError - Attached before the connection is handed out: a checked-out client has no error listener, and an * unhandled one ends the process. * @returns The checked-out client. diff --git a/backend/src/db/immutability-triggers.ts b/backend/src/db/immutability-triggers.ts index eecbebdfa..9a8e830d9 100644 --- a/backend/src/db/immutability-triggers.ts +++ b/backend/src/db/immutability-triggers.ts @@ -5,9 +5,7 @@ import { entityTables } from '#/tables'; // Immutable column sets const BASE_ENTITY_COLUMNS = ['id', 'tenant_id', 'entity_type', 'created_at', 'created_by'] as const; -const MEMBERSHIP_CHANNEL_ID_COLUMNS = appConfig.channelEntityTypes.map((type) => - toColumnName(appConfig.entityIdColumnKeys[type]), -); +const MEMBERSHIP_CHANNEL_ID_COLUMNS = appConfig.channelEntityTypes.map((type) => toColumnName(appConfig.entityIdColumnKeys[type])); const BASE_MEMBERSHIP_COLUMNS = ['tenant_id', 'channel_id', 'channel_type', ...MEMBERSHIP_CHANNEL_ID_COLUMNS] as const; /** Product entities with a parent org (tasks, labels, attachments). */ @@ -68,20 +66,11 @@ CREATE TRIGGER ${triggerName} export const baseEntityImmutabilityFunctionSQL = buildFunctionSQL('base_entity_immutable_keys', BASE_ENTITY_COLUMNS); -export const productImmutabilityFunctionSQL = buildFunctionSQL( - 'product_entity_immutable_keys', - productImmutableColumns, -); +export const productImmutabilityFunctionSQL = buildFunctionSQL('product_entity_immutable_keys', productImmutableColumns); -export const membershipImmutabilityFunctionSQL = buildFunctionSQL( - 'membership_immutable_keys', - membershipImmutableColumns, -); +export const membershipImmutabilityFunctionSQL = buildFunctionSQL('membership_immutable_keys', membershipImmutableColumns); -export const inactiveMembershipImmutabilityFunctionSQL = buildFunctionSQL( - 'inactive_membership_immutable_keys', - inactiveMembershipImmutableColumns, -); +export const inactiveMembershipImmutabilityFunctionSQL = buildFunctionSQL('inactive_membership_immutable_keys', inactiveMembershipImmutableColumns); export const appendOnlyImmutabilityFunctionSQL = ` CREATE OR REPLACE FUNCTION append_only_immutable_row() RETURNS TRIGGER AS $$ @@ -118,14 +107,10 @@ const membershipConfigs: TableImmutabilityConfig[] = [ { tableName: 'inactive_memberships', functionName: 'inactive_membership_immutable_keys' }, ]; -const appendOnlyConfigs: TableImmutabilityConfig[] = [ - { tableName: 'activities', functionName: 'append_only_immutable_row' }, -]; +const appendOnlyConfigs: TableImmutabilityConfig[] = [{ tableName: 'activities', functionName: 'append_only_immutable_row' }]; /** Read-only for the app: `system_roles` decides who is a system admin, so writes must use the admin connection. */ -const adminOnlyWriteConfigs: TableImmutabilityConfig[] = [ - { tableName: 'system_roles', functionName: 'admin_only_write_row' }, -]; +const adminOnlyWriteConfigs: TableImmutabilityConfig[] = [{ tableName: 'system_roles', functionName: 'admin_only_write_row' }]; export const allImmutabilityTables: TableImmutabilityConfig[] = [ ...channelConfigs, diff --git a/backend/src/db/membership-rules.ts b/backend/src/db/membership-rules.ts index 7a1679531..c90c44ec2 100644 --- a/backend/src/db/membership-rules.ts +++ b/backend/src/db/membership-rules.ts @@ -3,13 +3,13 @@ import { hierarchy } from 'shared'; /** The constraint a refused membership write names; `lib/error.ts` maps it to a 409 `last_admin`. */ export const keepOrganizationAdminConstraint = 'memberships_keep_org_admin'; +/** The trigger that versions a user's bindings for the membership cache (`middlewares/guard/membership-cache.ts`). */ +export const bumpBindingsVersionTrigger = 'memberships_bump_bindings_version'; + /** The triggers the membership rules create, each with its function: the side-effect verify block asserts them. */ export const membershipRuleTriggers = [ - { - tableName: 'memberships', - triggerName: keepOrganizationAdminConstraint, - functionName: keepOrganizationAdminConstraint, - }, + { tableName: 'memberships', triggerName: keepOrganizationAdminConstraint, functionName: keepOrganizationAdminConstraint }, + { tableName: 'memberships', triggerName: bumpBindingsVersionTrigger, functionName: bumpBindingsVersionTrigger }, ]; /** @@ -67,3 +67,33 @@ CREATE CONSTRAINT TRIGGER ${name} return { adminRole, sql }; }; + +/** + * Every insert, update and delete of a membership, by any writer and through cascades, gives its user's + * `actors.bindings_version` a new random value in the same transaction. The guards read the version with each session or + * token and cache memberships under it, so a change counts at the next request in every process. A random value never + * repeats, also when a user id is reused or the database is restored. + * @returns The idempotent SQL. + */ +export const bumpBindingsVersionSQL = (): string => { + const name = bumpBindingsVersionTrigger; + + return `CREATE OR REPLACE FUNCTION ${name}() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE actors SET bindings_version = gen_random_uuid() WHERE id = COALESCE(NEW.user_id, OLD.user_id); + RETURN NULL; +END; +$$; +--> statement-breakpoint + +DROP TRIGGER IF EXISTS ${name} ON memberships; +--> statement-breakpoint + +CREATE TRIGGER ${name} + AFTER INSERT OR UPDATE OR DELETE ON memberships + FOR EACH ROW EXECUTE FUNCTION ${name}(); +`; +}; diff --git a/backend/src/db/prepared.ts b/backend/src/db/prepared.ts index ad387b978..7c7a890e2 100644 --- a/backend/src/db/prepared.ts +++ b/backend/src/db/prepared.ts @@ -10,9 +10,7 @@ const prepared = (name: string, build: () => T): T => { if (hasDb) return build(); return new Proxy({} as T, { get() { - throw new Error( - `Prepared statement "${name}" is unavailable: this process runs without a database connection (NODB).`, - ); + throw new Error(`Prepared statement "${name}" is unavailable: this process runs without a database connection (NODB).`); }, }); }; diff --git a/backend/src/db/product-tables.ts b/backend/src/db/product-tables.ts index 1fa8af488..dbe3d0d9a 100644 --- a/backend/src/db/product-tables.ts +++ b/backend/src/db/product-tables.ts @@ -13,11 +13,10 @@ import type { PartitionConfig, ResolvableTable } from '#/tables'; * would be a load-order cycle under drizzle-kit's per-file loading. `satisfies` makes a missing * product a compile error. */ -export const productTables = { - attachment: () => attachmentsTable, - label: () => labelsTable, - task: () => tasksTable, -} satisfies Record ResolvableTable>; +export const productTables = { attachment: () => attachmentsTable, label: () => labelsTable, task: () => tasksTable } satisfies Record< + ProductEntityType, + () => ResolvableTable +>; /** App partition entry: the Drizzle table stands in for `name`, so the parity test checks the same schema the migration converts. */ export type AppPartitionConfig = Omit & { table: AnyPgTable }; diff --git a/backend/src/db/secret-columns.ts b/backend/src/db/secret-columns.ts index c8c0abcfc..0fe119258 100644 --- a/backend/src/db/secret-columns.ts +++ b/backend/src/db/secret-columns.ts @@ -39,9 +39,6 @@ export const secretColumnPattern = /(hash|secret|jwk|token|password)$/i; export type SecretColumnTable = keyof typeof secretColumns; /** The secret column names of a table, `never` for a table without any. */ -export type SecretColumnsOf = TName extends SecretColumnTable - ? (typeof secretColumns)[TName][number] - : never; +export type SecretColumnsOf = TName extends SecretColumnTable ? (typeof secretColumns)[TName][number] : never; -export const secretColumnsOf = (tableName: string): readonly string[] => - (secretColumns as Record)[tableName] ?? []; +export const secretColumnsOf = (tableName: string): readonly string[] => (secretColumns as Record)[tableName] ?? []; diff --git a/backend/src/db/tenant-context.ts b/backend/src/db/tenant-context.ts index 4b0585bf5..c9d68485e 100644 --- a/backend/src/db/tenant-context.ts +++ b/backend/src/db/tenant-context.ts @@ -39,11 +39,7 @@ export async function tenantReadById(tenantId: string, fn: (tx: DbOrTx) => Pr * `ctx.var.tenantId`) that resolved the request's single tenant themselves, e.g. an org-scoped * list whose per-row subqueries read RLS-guarded product tables. */ -export async function tenantReadAs( - ctx: ActorContext, - tenantId: string, - fn: (readCtx: ActorContext) => Promise, -): Promise { +export async function tenantReadAs(ctx: ActorContext, tenantId: string, fn: (readCtx: ActorContext) => Promise): Promise { return baseDb.transaction( async (tx) => { await setSessionVars(tx, tenantId, ctx.var.actor.id, false); @@ -65,10 +61,7 @@ export async function tenantRead(ctx: ActorContext, fn: (readCtx: ActorContex ); } -export async function tenantReadIncludingDeleted( - ctx: ActorContext, - fn: (readCtx: ActorContext) => Promise, -): Promise { +export async function tenantReadIncludingDeleted(ctx: ActorContext, fn: (readCtx: ActorContext) => Promise): Promise { return baseDb.transaction( async (tx) => { await setTenantSessionVars(tx, ctx, true); @@ -86,10 +79,7 @@ export async function tenantContext(ctx: ActorContext, fn: (txCtx: ActorConte }); } -export async function tenantContextIncludingDeleted( - ctx: ActorContext, - fn: (txCtx: ActorContext) => Promise, -): Promise { +export async function tenantContextIncludingDeleted(ctx: ActorContext, fn: (txCtx: ActorContext) => Promise): Promise { return baseDb.transaction(async (tx) => { await setTenantSessionVars(tx, ctx, true); return fn({ var: { ...ctx.var, db: tx } }); diff --git a/backend/src/db/utils/channel-relation-columns.ts b/backend/src/db/utils/channel-relation-columns.ts index d22119fe7..9455c6a38 100644 --- a/backend/src/db/utils/channel-relation-columns.ts +++ b/backend/src/db/utils/channel-relation-columns.ts @@ -23,8 +23,7 @@ export type ChannelTable = AnyPgTable & { id: PgColumn }; * table and its products is harmless. The organization is not referenced here; organization-bound * tables declare `organizationForeignKey` (the composite `(tenant_id, organization_id)` key). */ -const referencedChannelId = (channelType: string): PgColumn => - channelTables[channelType as keyof typeof channelTables]().id; +const referencedChannelId = (channelType: string): PgColumn => channelTables[channelType as keyof typeof channelTables]().id; /** Strict ancestors are non-null columns, except declared `nullableAncestors`; `relatedChannels` are nullable. */ export type ChannelRelationColumns = EntityIdColumns< @@ -47,16 +46,11 @@ export const channelRelationColumns = (entityType: const columns = {} as Record; for (const ancestor of hierarchy.getOrderedAncestors(entityType)) { - const column = - ancestor === 'organization' - ? uuid() - : uuid().references(() => referencedChannelId(ancestor), { onDelete: 'cascade' }); + const column = ancestor === 'organization' ? uuid() : uuid().references(() => referencedChannelId(ancestor), { onDelete: 'cascade' }); columns[appConfig.entityIdColumnKeys[ancestor]] = nullableAncestors.has(ancestor) ? column : column.notNull(); } for (const related of hierarchy.getRelatedChannels(entityType)) { - columns[appConfig.entityIdColumnKeys[related]] = uuid().references(() => referencedChannelId(related), { - onDelete: 'set null', - }); + columns[appConfig.entityIdColumnKeys[related]] = uuid().references(() => referencedChannelId(related), { onDelete: 'set null' }); } return columns as ChannelRelationColumns; @@ -66,11 +60,7 @@ export const channelRelationColumns = (entityType: * One index per sub-organization ancestor and related-channel column, named `

{children}
__index`, * for a product table's index list. Empty for org-homed products, so cella's own tables are unchanged. */ -export const channelRelationIndexes = ( - tableName: string, - table: Record, - entityType: ProductEntityType, -) => +export const channelRelationIndexes = (tableName: string, table: Record, entityType: ProductEntityType) => [...hierarchy.getOrderedAncestors(entityType), ...hierarchy.getRelatedChannels(entityType)] .filter((type) => type !== 'organization') .map((type) => { @@ -79,10 +69,7 @@ export const channelRelationIndexes = ( }); /** One nullable id column per sub-organization channel type: the channels a membership can be held at below the organization. */ -export type MembershipChannelColumns = EntityIdColumns< - Exclude & EntityType, - NullableUuid ->; +export type MembershipChannelColumns = EntityIdColumns & EntityType, NullableUuid>; /** * Sub-organization channel columns shared by the membership tables, from hierarchy config: one @@ -95,9 +82,7 @@ export const membershipChannelColumns = (): MembershipChannelColumns => { for (const channelType of appConfig.channelEntityTypes) { if (channelType === 'organization') continue; - columns[appConfig.entityIdColumnKeys[channelType]] = uuid().references(() => referencedChannelId(channelType), { - onDelete: 'cascade', - }); + columns[appConfig.entityIdColumnKeys[channelType]] = uuid().references(() => referencedChannelId(channelType), { onDelete: 'cascade' }); } return columns as MembershipChannelColumns; @@ -114,20 +99,15 @@ export const membershipChannelIndexes = (tableName: string, table: Record { const column = table[appConfig.entityIdColumnKeys[channelType]] as PgColumn; const channel = entityIdColumnName(channelType).replace(/_id$/, ''); - return index(`${tableName}_${channel}_user_archived_idx`).on( - column, - table.userId as PgColumn, - table.archived as PgColumn, - ); + return index(`${tableName}_${channel}_user_archived_idx`).on(column, table.userId as PgColumn, table.archived as PgColumn); }); /** Nullable ancestor-context id columns for every product entity, for tables holding rows of several types. */ export const activityChannelColumns = (): ActivityChannelColumns => { const columns = {} as Record; - for (const ctx of new Set( - appConfig.productEntityTypes.flatMap((entityType) => hierarchy.getOrderedAncestors(entityType)), - )) { + const contexts = new Set(appConfig.productEntityTypes.flatMap((entityType) => hierarchy.getOrderedAncestors(entityType))); + for (const ctx of contexts) { columns[appConfig.entityIdColumnKeys[ctx]] = uuid(); } diff --git a/backend/src/db/utils/channel-relation-schema.ts b/backend/src/db/utils/channel-relation-schema.ts index 06336b29c..d54b3e799 100644 --- a/backend/src/db/utils/channel-relation-schema.ts +++ b/backend/src/db/utils/channel-relation-schema.ts @@ -2,10 +2,7 @@ import { z } from '@hono/zod-openapi'; import type { EntityIdColumns, EntityType, ProductEntityType, RelatedChannelType } from 'shared'; import { appConfig, hierarchy } from 'shared'; -export type RelatedChannelShape = EntityIdColumns< - RelatedChannelType & EntityType, - z.ZodOptional ->; +export type RelatedChannelShape = EntityIdColumns & EntityType, z.ZodOptional>; /** Optional uuid fields for the entity's `relatedChannels`: the validation twin of `channelRelationColumns`. */ export const relatedChannelShape = (entityType: E): RelatedChannelShape => { diff --git a/backend/src/db/utils/delta-index.test.ts b/backend/src/db/utils/delta-index.test.ts index 8ae544df6..5ad82e321 100644 --- a/backend/src/db/utils/delta-index.test.ts +++ b/backend/src/db/utils/delta-index.test.ts @@ -6,9 +6,7 @@ import { entityTables } from '#/tables'; /** Every product entity table needs a composite `(organization_id, seq)` index so seq-range delta reads scan it. */ describe('every product entity table has the (organization_id, seq) delta index', () => { - const productTables = Object.entries(entityTables).filter(([type]) => - (appConfig.productEntityTypes as readonly string[]).includes(type), - ); + const productTables = Object.entries(entityTables).filter(([type]) => (appConfig.productEntityTypes as readonly string[]).includes(type)); it('covers at least one product table (guard against registry drift)', () => { expect(productTables.length).toBeGreaterThan(0); diff --git a/backend/src/db/utils/drizzle-schema.ts b/backend/src/db/utils/drizzle-schema.ts index ff944b463..efe184cee 100644 --- a/backend/src/db/utils/drizzle-schema.ts +++ b/backend/src/db/utils/drizzle-schema.ts @@ -1,12 +1,6 @@ import { z } from '@hono/zod-openapi'; import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'; -import { - type BuildRefine, - type BuildSchema, - type CreateSelectSchema, - createSchemaFactory, - type NoUnknownKeys, -} from 'drizzle-orm/zod'; +import { type BuildRefine, type BuildSchema, type CreateSelectSchema, createSchemaFactory, type NoUnknownKeys } from 'drizzle-orm/zod'; import { type SecretColumnsOf, secretColumnsOf } from '#/db/secret-columns'; const factory = createSchemaFactory({ zodInstance: z }); diff --git a/backend/src/db/utils/home-channel.ts b/backend/src/db/utils/home-channel.ts index cb9b24fc2..a922b4372 100644 --- a/backend/src/db/utils/home-channel.ts +++ b/backend/src/db/utils/home-channel.ts @@ -14,7 +14,6 @@ export function homeChannelIdSql(productType: ProductEntityType, table: AnyPgTab .getOrderedAncestors(productType) .map((ancestor) => columns[appConfig.entityIdColumnKeys[ancestor]]) .filter((column): column is PgColumn => Boolean(column)); - if (ancestorColumns.length === 0) - throw new Error(`homeChannelIdSql: ${productType} table carries no ancestor id column`); + if (ancestorColumns.length === 0) throw new Error(`homeChannelIdSql: ${productType} table carries no ancestor id column`); return sql`COALESCE(${sql.join(ancestorColumns, sql`, `)})`; } diff --git a/backend/src/db/utils/list-total.ts b/backend/src/db/utils/list-total.ts index b10a7633a..d02e6cd26 100644 --- a/backend/src/db/utils/list-total.ts +++ b/backend/src/db/utils/list-total.ts @@ -10,10 +10,7 @@ export interface PaginatedResult { } /** Runs the items query and the total source in parallel; page-length reads skip the total source. */ -export async function resolveListTotal( - itemsQuery: PromiseLike, - source: ListTotalSource, -): Promise> { +export async function resolveListTotal(itemsQuery: PromiseLike, source: ListTotalSource): Promise> { if (source.kind === 'pageLength') { const items = await itemsQuery; return { items, total: items.length }; diff --git a/backend/src/db/utils/path-column.test.ts b/backend/src/db/utils/path-column.test.ts index d091362d2..4e86fd2b5 100644 --- a/backend/src/db/utils/path-column.test.ts +++ b/backend/src/db/utils/path-column.test.ts @@ -10,11 +10,7 @@ const seedDb = getSeedDb(); const roles = createRoleRegistry(['admin', 'member'] as const); // Synthetic org-homed product: binding to the real config would break the assertion in apps that re-home it. -const orgHomedH = createEntityHierarchy(roles) - .user() - .organization({ roles: roles.all }) - .product('doc', { parent: 'organization' }) - .build(); +const orgHomedH = createEntityHierarchy(roles).user().organization({ roles: roles.all }).product('doc', { parent: 'organization' }).build(); describe('pathColumnSql (SQL shape)', () => { it('org-homed product: just the org id', () => { expect(orgHomedH.pathColumnSql('doc', false)).toBe('"organization_id"::text'); @@ -64,9 +60,7 @@ describe('SQL ≍ JS path parity on a live deep-chain table', () => { }); afterAll(async () => { - await seedDb.execute( - sql.raw('drop table if exists test_path_parity_items; drop table if exists test_path_parity_projects;'), - ); + await seedDb.execute(sql.raw('drop table if exists test_path_parity_items; drop table if exists test_path_parity_projects;')); }); const itemRows = [ @@ -89,9 +83,7 @@ describe('SQL ≍ JS path parity on a live deep-chain table', () => { ), ); } - const stored = await seedDb.execute<{ id: string; path: string }>( - sql.raw('select id, path from test_path_parity_items order by id'), - ); + const stored = await seedDb.execute<{ id: string; path: string }>(sql.raw('select id, path from test_path_parity_items order by id')); for (const { id, path } of stored.rows) { const row = itemRows.find((r) => r.id === id); expect(path, `item ${id}`).toBe(deepH.computeProductPath('item', row ?? {})); @@ -112,9 +104,7 @@ describe('SQL ≍ JS path parity on a live deep-chain table', () => { ), ); } - const stored = await seedDb.execute<{ id: string; path: string }>( - sql.raw('select id, path from test_path_parity_projects order by id'), - ); + const stored = await seedDb.execute<{ id: string; path: string }>(sql.raw('select id, path from test_path_parity_projects order by id')); for (const { id, path } of stored.rows) { const row = projectRows.find((r) => r.id === id); expect(path, `project ${id}`).toBe(deepH.computeChannelPath('project', row ?? {})); diff --git a/backend/src/db/utils/product-columns.ts b/backend/src/db/utils/product-columns.ts index 04be7191d..cafca51a3 100644 --- a/backend/src/db/utils/product-columns.ts +++ b/backend/src/db/utils/product-columns.ts @@ -1,5 +1,4 @@ -import { sql } from 'drizzle-orm'; -import { bigint, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; +import { bigint, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; import type { ProductEntityType } from 'shared'; import { maxLength } from '#/db/utils/constraints'; import type { ActorId } from '#/db/utils/ids'; @@ -30,9 +29,8 @@ export const productColumns = (entityType: T) => ({ }); /** - * Server-derived user ids mentioned in `description`. Its presence on a product table switches - * on mention derivation and mention fan-out for that product's notification source. + * @deprecated The fan-out reads mentions from `description`, so no column stores them. Empty, so a + * table that still spreads it drops its `mentions` column on the next `pnpm generate`; removed in a + * later release. */ -export const mentionableColumns = { - mentions: text().array().notNull().default(sql`'{}'::text[]`), -}; +export const mentionableColumns = {}; diff --git a/backend/src/db/utils/publication-filter.test.ts b/backend/src/db/utils/publication-filter.test.ts index 316993583..8aee45969 100644 --- a/backend/src/db/utils/publication-filter.test.ts +++ b/backend/src/db/utils/publication-filter.test.ts @@ -4,14 +4,9 @@ import { describe, expect, it } from 'vitest'; import { PUBLISHED_ROW_FILTER, publicationRowFilter } from './publication-filter'; import { draftVisibleRowsPredicate, publishedRowsPredicate } from './published-predicate'; -const draftProduct = pgTable('test_pub_items', { - id: varchar('id').primaryKey(), - publishedAt: timestamp('published_at', { mode: 'string' }), -}); +const draftProduct = pgTable('test_pub_items', { id: varchar('id').primaryKey(), publishedAt: timestamp('published_at', { mode: 'string' }) }); -const plainProduct = pgTable('test_pub_attachments', { - id: varchar('id').primaryKey(), -}); +const plainProduct = pgTable('test_pub_attachments', { id: varchar('id').primaryKey() }); const authoredDraftProduct = pgTable('test_pub_notes', { id: varchar('id').primaryKey(), @@ -57,9 +52,7 @@ describe('draftVisibleRowsPredicate', () => { }); it('shows drafts to nobody on a table without an author column, and filters nothing without drafts', () => { - expect(normalize(compile(draftVisibleRowsPredicate(draftProduct, 'author-1')).sql)).toBe( - 'test_pub_items.published_at is not null', - ); + expect(normalize(compile(draftVisibleRowsPredicate(draftProduct, 'author-1')).sql)).toBe('test_pub_items.published_at is not null'); expect(draftVisibleRowsPredicate(plainProduct, 'author-1')).toBeUndefined(); }); }); diff --git a/backend/src/db/utils/published-column.ts b/backend/src/db/utils/published-column.ts index 2719b566d..566896704 100644 --- a/backend/src/db/utils/published-column.ts +++ b/backend/src/db/utils/published-column.ts @@ -1,6 +1,4 @@ import { timestamp } from 'drizzle-orm/pg-core'; /** Opt-in draft lifecycle: null is an author-only draft, a timestamp is published. Stays mutable. */ -export const publishedColumn = { - publishedAt: timestamp('published_at', { mode: 'string' }), -}; +export const publishedColumn = { publishedAt: timestamp('published_at', { mode: 'string' }) }; diff --git a/backend/src/db/utils/request-scope.ts b/backend/src/db/utils/request-scope.ts index 7ac75033e..35ba39c59 100644 --- a/backend/src/db/utils/request-scope.ts +++ b/backend/src/db/utils/request-scope.ts @@ -8,16 +8,10 @@ import { AppError } from '#/core/error'; * The tenant and organization ids the guard chain set for this request. A route that reaches * scoped code without both `tenantGuard` and `orgGuard` is a wiring bug, not a request error. */ -export const requestScope = ( - ctx: ActorContext, - entityType?: EntityType, -): { tenantId: string; organizationId: string } => { +export const requestScope = (ctx: ActorContext, entityType?: EntityType): { tenantId: string; organizationId: string } => { const { tenantId, organizationId } = ctx.var; if (!tenantId || !organizationId) { - throw new AppError(500, 'server_error', 'error', { - entityType, - meta: { reason: 'Scoped query without tenant and organization guards' }, - }); + throw new AppError(500, 'server_error', 'error', { entityType, meta: { reason: 'Scoped query without tenant and organization guards' } }); } return { tenantId, organizationId }; }; @@ -27,11 +21,7 @@ export const requestScope = ( * product query (lists, counts, updates, soft-deletes, bulk predicates). Redundant with RLS and * global UUID identity on purpose: removing RLS must broaden no application query. */ -export const requestScopeWhere = ( - ctx: ActorContext, - table: { tenantId: PgColumn; organizationId: PgColumn }, - entityType?: EntityType, -): SQL => { +export const requestScopeWhere = (ctx: ActorContext, table: { tenantId: PgColumn; organizationId: PgColumn }, entityType?: EntityType): SQL => { const { tenantId, organizationId } = requestScope(ctx, entityType); return and(eq(table.tenantId, tenantId), eq(table.organizationId, organizationId)) as SQL; }; diff --git a/backend/src/db/utils/stx-columns.ts b/backend/src/db/utils/stx-columns.ts index d044dcddf..49350f819 100644 --- a/backend/src/db/utils/stx-columns.ts +++ b/backend/src/db/utils/stx-columns.ts @@ -2,6 +2,4 @@ import { jsonb } from 'drizzle-orm/pg-core'; import type { StxBase } from '#/schemas/sync-transaction-schemas'; /** Tracks mutations for CDC conflict detection. notNull: every offline or realtime mutation must carry stx metadata. */ -export const stxColumns = { - stx: jsonb().$type().notNull(), -}; +export const stxColumns = { stx: jsonb().$type().notNull() }; diff --git a/backend/src/db/utils/subtree-cover.ts b/backend/src/db/utils/subtree-cover.ts index 2af60c7da..f76687ed4 100644 --- a/backend/src/db/utils/subtree-cover.ts +++ b/backend/src/db/utils/subtree-cover.ts @@ -8,11 +8,7 @@ import { appConfig, hierarchy, type ProductEntityType } from 'shared'; * scope: folding the covering id into the permission scope would let an intermediate grant widen the read * past the requested subtree. Undefined for an org-homed entity or an absent channelId. */ -export function buildSubtreeCoverWhere( - table: AnyPgTable, - entityType: ProductEntityType, - channelId: string | undefined, -): SQL | undefined { +export function buildSubtreeCoverWhere(table: AnyPgTable, entityType: ProductEntityType, channelId: string | undefined): SQL | undefined { if (!channelId) return undefined; const columns = getTableColumns(table) as Record; diff --git a/backend/src/env.test.ts b/backend/src/env.test.ts index 1a53eca5e..b752d8c64 100644 --- a/backend/src/env.test.ts +++ b/backend/src/env.test.ts @@ -31,16 +31,12 @@ const modeBound = { */ function loadEnv(vars: Record) { const absent = Object.fromEntries(modeSecretNames.map((name) => [name, ''])); - const result = spawnSync( - process.execPath, - ['--import', 'tsx', '--input-type=module', '-e', "await import('./src/env.ts')"], - { - cwd: backendDir, - env: { PATH: process.env.PATH ?? '', NODE_ENV: 'test', ...absent, ...vars }, - encoding: 'utf8', - timeout: 20_000, - }, - ); + const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', "await import('./src/env.ts')"], { + cwd: backendDir, + env: { PATH: process.env.PATH ?? '', NODE_ENV: 'test', ...absent, ...vars }, + encoding: 'utf8', + timeout: 20_000, + }); return { status: result.status, output: `${result.stdout}${result.stderr}` }; } @@ -90,14 +86,7 @@ const strong = () => randomBytes(24).toString('base64url'); const boot = async (mode: 'production' | 'development', overrides: Record) => { // Vitest sets VITEST, and MODE for its own use. const { VITEST: _vitest, MODE: _mode, ...inherited } = process.env; - const env = { - ...inherited, - NODE_ENV: mode, - APP_MODE: mode, - COOKIE_SECRET: strong(), - UNSUBSCRIBE_SECRET: strong(), - ...overrides, - }; + const env = { ...inherited, NODE_ENV: mode, APP_MODE: mode, COOKIE_SECRET: strong(), UNSUBSCRIBE_SECRET: strong(), ...overrides }; try { const script = "import('./src/env.ts').then(() => console.info('env loaded'))"; const { stdout } = await promisify(execFile)(tsx, ['-e', script], { cwd: backendDir, env }); @@ -116,8 +105,8 @@ const boot = async (mode: 'production' | 'development', overrides: Record { it('must not boot with an empty or short cookie secret entry', async () => { const refused = await Promise.all( - [',', ' ', `${strong()},`, `${strong()}, ,${strong()}`, 'short-secret', `${strong()},short-secret`].map( - (COOKIE_SECRET) => boot('production', { COOKIE_SECRET }), + [',', ' ', `${strong()},`, `${strong()}, ,${strong()}`, 'short-secret', `${strong()},short-secret`].map((COOKIE_SECRET) => + boot('production', { COOKIE_SECRET }), ), ); diff --git a/backend/src/env.ts b/backend/src/env.ts index ff765386c..5c176a074 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -15,8 +15,7 @@ if (existsSync(envFile)) process.loadEnvFile(envFile); const minSecretLength = 16; /** A secret of at least `min` characters, refused with a message that names it. */ -const secretString = (name: string, min = minSecretLength) => - z.string().min(min, `${name} must be at least ${min} characters`); +const secretString = (name: string, min = minSecretLength) => z.string().min(min, `${name} must be at least ${min} characters`); /** Development and tunnel run on the example `.env`, whose cookie secret is shorter: there an entry only has to be non-empty. */ const minCookieSecretLength = appConfig.mode === 'development' || appConfig.mode === 'tunnel' ? 1 : minSecretLength; @@ -38,13 +37,7 @@ export const env = createEnv({ DATABASE_POOL_MAX: z.coerce.number().default(20), // PEM CA cert for the managed PostgreSQL TLS connection: required in production, where the DB client fails fast without it. DATABASE_SSL_CA: z.string().optional(), - NODE_ENV: z.union([ - z.literal('development'), - z.literal('production'), - z.literal('staging'), - z.literal('tunnel'), - z.literal('test'), - ]), + NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('staging'), z.literal('tunnel'), z.literal('test')]), PORT: z.string().default(String(appConfig.devPorts.api)), // The internal listener (lib/listeners.ts): the CDC socket and the Yjs relay's routes, reached only from the private network. INTERNAL_PORT: z.string().default(String(appConfig.devPorts.internal)), @@ -58,11 +51,9 @@ export const env = createEnv({ // One secret or a comma-separated list (the first signs, any verifies). Every entry counts on its own, so a stray // comma or a short entry stops the boot and never becomes a signing key. - COOKIE_SECRET: z - .string() - .refine((value) => value.split(',').every((entry) => entry.trim().length >= minCookieSecretLength), { - message: `Every COOKIE_SECRET entry must be at least ${minCookieSecretLength} characters`, - }), + COOKIE_SECRET: z.string().refine((value) => value.split(',').every((entry) => entry.trim().length >= minCookieSecretLength), { + message: `Every COOKIE_SECRET entry must be at least ${minCookieSecretLength} characters`, + }), // Operator-managed runtime secret. When the secret has no version the env var is omitted and this // defaults to 'none' (deny), so sys-admin routes stay off until an operator sets the allowlist. diff --git a/backend/src/lib/cdc-websocket.ts b/backend/src/lib/cdc-websocket.ts index 2bf24ef88..8bca37288 100644 --- a/backend/src/lib/cdc-websocket.ts +++ b/backend/src/lib/cdc-websocket.ts @@ -36,14 +36,7 @@ const cdcMessageSchema = z.object({ }), ) .optional(), - _trace: z - .object({ - traceId: z.string(), - spanId: z.string(), - cdcTimestamp: z.number(), - lsn: z.string().optional(), - }) - .optional(), + _trace: z.object({ traceId: z.string(), spanId: z.string(), cdcTimestamp: z.number(), lsn: z.string().optional() }).optional(), }); export type CdcMessage = z.infer; @@ -170,15 +163,8 @@ class CdcWebSocketServer { if (!result.success) { this._parseErrors++; - const preview = { - type: parsed?.activity?.type, - subjectId: parsed?.activity?.subjectId, - action: parsed?.activity?.action, - }; - log.error('CDC message schema validation failed - message dropped', { - errors: result.error.issues, - preview, - }); + const preview = { type: parsed?.activity?.type, subjectId: parsed?.activity?.subjectId, action: parsed?.activity?.action }; + log.error('CDC message schema validation failed - message dropped', { errors: result.error.issues, preview }); return; } @@ -189,10 +175,7 @@ class CdcWebSocketServer { const { type } = message.activity; if (!isValidEventType(type)) { this._parseErrors++; - log.error('Unknown event type in CDC message - message dropped', { - type, - subjectId: message.activity.subjectId, - }); + log.error('Unknown event type in CDC message - message dropped', { type, subjectId: message.activity.subjectId }); return; } @@ -222,10 +205,7 @@ class CdcWebSocketServer { trace: message._trace ?? null, } as ActivityEvent; - log.trace('CDC message processed', { - type: message.activity.type, - subjectId: message.activity.subjectId, - }); + log.trace('CDC message processed', { type: message.activity.type, subjectId: message.activity.subjectId }); activityBus.emit(activityEvent); } catch (err) { @@ -243,10 +223,7 @@ class CdcWebSocketServer { // Clear entity caches after counter recalculation. productCache.clear(); - log.info('CDC catchup complete: entity caches cleared', { - eventsProcessed, - catchupDurationMs, - }); + log.info('CDC catchup complete: entity caches cleared', { eventsProcessed, catchupDurationMs }); return; } @@ -269,8 +246,7 @@ class CdcWebSocketServer { receivedAt: new Date().toISOString(), }; this._lastLagAlert = alert; - if (alert.severity === 'wal_lag_unhealthy') - log.error('CDC WAL lag exceeded the backpressure limit', { ...alert }); + if (alert.severity === 'wal_lag_unhealthy') log.error('CDC WAL lag exceeded the backpressure limit', { ...alert }); else log.warn('CDC WAL lag above warning threshold', { ...alert }); return; } diff --git a/backend/src/lib/error.ts b/backend/src/lib/error.ts index 89b755de9..e9da4d889 100644 --- a/backend/src/lib/error.ts +++ b/backend/src/lib/error.ts @@ -1,4 +1,3 @@ -import { trace } from '@opentelemetry/api'; import type { ErrorHandler } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { ContentfulStatusCode } from 'hono/utils/http-status'; @@ -38,11 +37,7 @@ const PG_ERROR_MAP: Record = { // Refused when an organization would be left without an admin. - [keepOrganizationAdminConstraint]: { - status: 409, - type: 'last_admin', - message: 'An organization keeps at least one admin', - }, + [keepOrganizationAdminConstraint]: { status: 409, type: 'last_admin', message: 'An organization keeps at least one admin' }, }; type PgErrorInfo = { code: string; detail?: string; constraint?: string }; @@ -106,8 +101,7 @@ export function toClientError( { exposeServerMessage = exposesServerMessages() }: ToClientErrorOptions = {}, ): ClientError { const fields = Object.fromEntries(Object.entries(logFields).filter(([, value]) => value !== undefined)); - const hideIfServerError = (status: number, message: string) => - status >= 500 && !exposeServerMessage ? 'Internal server error' : message; + const hideIfServerError = (status: number, message: string) => (status >= 500 && !exposeServerMessage ? 'Internal server error' : message); if (isPoolTimeoutError(err)) { log.error('Database pool exhausted', { err, ...fields }); @@ -209,8 +203,6 @@ export const appErrorHandler: ErrorHandler = (err, ctx) => { return ctx.json( { ...body, - // Correlates browser tracing, server spans, and logs; falls back to request ID when no span records - logId: trace.getActiveSpan()?.spanContext().traceId ?? ctx.get('requestId'), requestId: ctx.get('requestId'), path: safePath, method: ctx.req.method, diff --git a/backend/src/lib/geoip.test.ts b/backend/src/lib/geoip.test.ts index 3c6ef00e0..e6aa47075 100644 --- a/backend/src/lib/geoip.test.ts +++ b/backend/src/lib/geoip.test.ts @@ -47,11 +47,7 @@ describe('lookupIp', () => { describe('refreshGeoipDatabases', () => { it('downloads both databases from the source, remembers their etags and serves the new data', async () => { const fetchMock = vi.fn(async (url: string) => - respond( - 200, - archive(url.includes('country') ? 'country-v1' : 'asn-v1'), - `"v1-${url.includes('country') ? 'c' : 'a'}"`, - ), + respond(200, archive(url.includes('country') ? 'country-v1' : 'asn-v1'), `"v1-${url.includes('country') ? 'c' : 'a'}"`), ); vi.stubGlobal('fetch', fetchMock); @@ -104,9 +100,7 @@ describe('refreshGeoipDatabases', () => { it('replaces a database when its etag moved and reopens only that reader', async () => { vi.stubGlobal( 'fetch', - vi.fn(async (url: string) => - url.includes('country') ? respond(200, archive('country-v2'), '"v2-c"') : respond(304), - ), + vi.fn(async (url: string) => (url.includes('country') ? respond(200, archive('country-v2'), '"v2-c"') : respond(304))), ); expect(await refreshGeoipDatabases()).toEqual({ country: 'updated', asn: 'unchanged' }); diff --git a/backend/src/lib/geoip.ts b/backend/src/lib/geoip.ts index 5706562c5..01a915fe8 100644 --- a/backend/src/lib/geoip.ts +++ b/backend/src/lib/geoip.ts @@ -54,9 +54,7 @@ const loadReader = async (db: GeoipDatabase): Promise if (db.reader) return db.reader; if (!existsSync(db.path)) { if (!db.warned) { - baseLog.warn(`GeoIP ${db.kind} database not found: ${db.kind} lookups disabled until the next refresh`, { - path: db.path, - }); + baseLog.warn(`GeoIP ${db.kind} database not found: ${db.kind} lookups disabled until the next refresh`, { path: db.path }); db.warned = true; } return null; @@ -70,10 +68,7 @@ const loadReader = async (db: GeoipDatabase): Promise * development substitutes a sample public address and the tile and the sign-in notice show a country. The raw * address the session stores its hashes of is never touched. */ -export const lookupTargetIp = ( - ip: string | null | undefined, - { mode, sampleIp }: { mode: string; sampleIp: string }, -): string | null => { +export const lookupTargetIp = (ip: string | null | undefined, { mode, sampleIp }: { mode: string; sampleIp: string }): string | null => { if (!ip) return null; if (mode === 'development' && sampleIp && !isPublicIp(ip)) return sampleIp; return ip; @@ -83,17 +78,14 @@ export const lookupTargetIp = ( * ISO-3166 alpha-2 country code and ASN for an IP; either is null when its database is missing or the IP is unknown. * Never throws, so auth and session paths can call it directly. */ -export const lookupIp = async ( - ip: string | null | undefined, -): Promise<{ country: string | null; asn: number | null }> => { +export const lookupIp = async (ip: string | null | undefined): Promise<{ country: string | null; asn: number | null }> => { const target = lookupTargetIp(ip, { mode: appConfig.mode, sampleIp: env.GEOIP_DEV_SAMPLE_IP }); if (!target) return { country: null, asn: null }; try { const [countryReader, asnReader] = await Promise.all([loadReader(country), loadReader(asn)]); - return { - country: countryReader?.get(target)?.country?.iso_code ?? null, - asn: asnReader?.get(target)?.autonomous_system_number ?? null, - }; + const countryCode = countryReader?.get(target)?.country?.iso_code ?? null; + const asnNumber = asnReader?.get(target)?.autonomous_system_number ?? null; + return { country: countryCode, asn: asnNumber }; } catch (err) { baseLog.warn('GeoIP lookup failed', { err, ip: target }); return { country: null, asn: null }; @@ -112,10 +104,7 @@ const readEtag = async (path: string): Promise => { * Conditional download of one database: a 304 leaves the file alone, a 200 replaces it atomically (gunzip into a * temp file, rename) and drops the open reader so the next lookup opens the new data. */ -const refreshDatabase = async ( - db: GeoipDatabase, - source: string, -): Promise<'updated' | 'unchanged' | 'failed'> => { +const refreshDatabase = async (db: GeoipDatabase, source: string): Promise<'updated' | 'unchanged' | 'failed'> => { const url = `${source}/${db.object}`; const etag = existsSync(db.path) ? await readEtag(db.path) : null; const tmp = `${db.path}.tmp`; @@ -145,15 +134,10 @@ const refreshDatabase = async ( }; /** Fetches both databases from the source when they changed. Never throws. */ -export const refreshGeoipDatabases = async (): Promise< - Record -> => { +export const refreshGeoipDatabases = async (): Promise> => { const source = geoipSourceUrl(); if (!source) return { country: 'off', asn: 'off' }; - const [countryResult, asnResult] = await Promise.all([ - refreshDatabase(country, source), - refreshDatabase(asn, source), - ]); + const [countryResult, asnResult] = await Promise.all([refreshDatabase(country, source), refreshDatabase(asn, source)]); return { country: countryResult, asn: asnResult }; }; diff --git a/backend/src/lib/health-helpers.ts b/backend/src/lib/health-helpers.ts index 6b0075bdc..0ca22aa4f 100644 --- a/backend/src/lib/health-helpers.ts +++ b/backend/src/lib/health-helpers.ts @@ -5,8 +5,6 @@ export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'; /** One service or dependency in the health envelope: `status` grades it, the open `details` bag diagnoses it. */ export interface HealthComponent { status: HealthStatus; - /** Human-readable name for user-facing status displays. */ - label?: string; /** How the status was obtained: `local` self-check, worker `push`, or active `probe`. */ checkedVia?: 'local' | 'push' | 'probe'; /** Age of the underlying data (ms), set for pushed/cached reports. */ @@ -32,10 +30,7 @@ export function worstStatus(a: HealthStatus, b: HealthStatus): HealthStatus { } /** Only critical components reach `unhealthy`; others cap at `degraded` so a flaky worker keeps the API registered. */ -export function rollupStatus( - components: Record, - criticalComponents: Set, -): HealthStatus { +export function rollupStatus(components: Record, criticalComponents: Set): HealthStatus { let result: HealthStatus = 'healthy'; for (const [name, component] of Object.entries(components)) { const capped = criticalComponents.has(name) || component.status !== 'unhealthy' ? component.status : 'degraded'; @@ -167,20 +162,11 @@ export interface ProbeResult { } /** Maps an active probe of a worker's `/health?depth=full`; an unreachable worker is `unhealthy` here. */ -export function mapProbeComponent( - result: ProbeResult, - extractDetails: (body: Record) => Record, -): HealthComponent { +export function mapProbeComponent(result: ProbeResult, extractDetails: (body: Record) => Record): HealthComponent { if (!result.ok || !result.body) { - return { - status: 'unhealthy', - checkedVia: 'probe', - latencyMs: result.latencyMs, - reason: result.reason ?? 'unreachable', - }; + return { status: 'unhealthy', checkedVia: 'probe', latencyMs: result.latencyMs, reason: result.reason ?? 'unreachable' }; } const reported = result.body.status; - const status: HealthStatus = - reported === 'unhealthy' ? 'unhealthy' : reported === 'degraded' ? 'degraded' : 'healthy'; + const status: HealthStatus = reported === 'unhealthy' ? 'unhealthy' : reported === 'degraded' ? 'degraded' : 'healthy'; return { status, checkedVia: 'probe', latencyMs: result.latencyMs, details: extractDetails(result.body) }; } diff --git a/backend/src/lib/health-probe.ts b/backend/src/lib/health-probe.ts index 40b6e425a..d4405f8eb 100644 --- a/backend/src/lib/health-probe.ts +++ b/backend/src/lib/health-probe.ts @@ -23,10 +23,7 @@ async function runProbe(baseUrl: string): Promise { try { // WebSocket workers advertise ws(s):// URLs, but /health speaks plain HTTP and fetch() rejects the ws scheme const httpBase = baseUrl.replace(/^ws(s?):/, 'http$1:'); - const res = await fetch(`${httpBase}/health?depth=full`, { - signal: controller.signal, - headers: { accept: 'application/json' }, - }); + const res = await fetch(`${httpBase}/health?depth=full`, { signal: controller.signal, headers: { accept: 'application/json' } }); const latencyMs = Date.now() - startedAt; if (!res.ok) return { ok: false, latencyMs, reason: `http_${res.status}` }; const body = (await res.json()) as Record; @@ -58,4 +55,10 @@ export function extractMcpDetails(body: Record): Record): Record { + const components = (body.components ?? {}) as Record; + return Object.fromEntries(Object.entries(components).map(([name, component]) => [name, component.status ?? null])); +} + +export const workerUrls = { yjs: appConfig.yjsUrl, mcp: appConfig.mcpUrl, oauth: appConfig.oauthUrl }; diff --git a/backend/src/lib/health.ts b/backend/src/lib/health.ts index 1a108b5d4..ce7afcb40 100644 --- a/backend/src/lib/health.ts +++ b/backend/src/lib/health.ts @@ -16,16 +16,15 @@ import { mapProbeComponent, rollupStatus, } from '#/lib/health-helpers'; -import { extractMcpDetails, extractYjsDetails, probeWorker, workerUrls } from '#/lib/health-probe'; +import { extractMcpDetails, extractOauthDetails, extractYjsDetails, probeWorker, workerUrls } from '#/lib/health-probe'; import { mapJobsComponent, readJobsHealth } from '#/lib/jobs-health'; import { getBackendJobs } from '#/lib/module'; -import { authInvalidationHealth } from '#/middlewares/guard/invalidation-listener'; import { log } from '#/utils/logger'; export type { HealthResponse, HealthStatus }; /** Components that reflect the process's own ability to serve; only these can drive an `unhealthy` rollup (503). */ -const CRITICAL_COMPONENTS = new Set(['api', 'database', 'authInvalidation']); +const CRITICAL_COMPONENTS = new Set(['api', 'database']); /** Check database connectivity with a timed `SELECT 1`. */ async function checkDatabase(): Promise<{ connected: boolean; latencyMs: number | null }> { @@ -83,36 +82,32 @@ async function buildJobsComponent(): Promise { /** * Aggregates every dependency and sibling worker into a uniform `component` keyed by name. The api process grades - * itself, checks the database and its auth invalidation listener, reads the pushed CDC report, probes yjs/mcp and - * reads the job store; the mcp worker grades the same three and reports itself; the jobs worker grades itself, the - * database and the store. + * itself, checks the database, reads the pushed CDC report, probes yjs/mcp/oauth and reads the job store; the mcp + * worker grades the same two and reports itself; the jobs worker grades itself, the database and the store. */ async function getHealthResponse(): Promise<{ response: HealthResponse; httpStatus: number }> { const components: Record = {}; const dbCheck = await checkDatabase(); - components.api = { ...mapApiComponent(getEventLoopLagMs(), process.memoryUsage()), label: 'API' }; - components.database = { ...mapDatabaseComponent(dbCheck.connected, dbCheck.latencyMs), label: 'Database' }; - // The jobs worker serves no request, so it holds no guard cache that an invalidation would have to reach. - if (env.MODE !== 'jobs') components.authInvalidation = { ...authInvalidationHealth(), label: 'Auth invalidation' }; + components.api = mapApiComponent(getEventLoopLagMs(), process.memoryUsage()); + components.database = mapDatabaseComponent(dbCheck.connected, dbCheck.latencyMs); if (env.MODE === 'mcp') { - components.mcp = { ...buildMcpSelfComponent(), label: 'MCP' }; + components.mcp = buildMcpSelfComponent(); } else if (env.MODE === 'jobs') { - components.jobs = { ...(await buildJobsComponent()), label: 'Jobs' }; + components.jobs = await buildJobsComponent(); } else { - if (appConfig.services.cdc.enabled !== false) components.cdc = { ...buildCdcComponent(), label: 'CDC' }; + if (appConfig.services.cdc.enabled !== false) components.cdc = buildCdcComponent(); const workerChecks = await Promise.all([ appConfig.services.yjs.enabled !== false - ? probeWorker(workerUrls.yjs).then( - (result) => ['yjs', { ...mapProbeComponent(result, extractYjsDetails), label: 'YJS' }] as const, - ) + ? probeWorker(workerUrls.yjs).then((result) => ['yjs', mapProbeComponent(result, extractYjsDetails)] as const) : Promise.resolve(null), appConfig.services.mcp.enabled !== false - ? probeWorker(workerUrls.mcp).then( - (result) => ['mcp', { ...mapProbeComponent(result, extractMcpDetails), label: 'MCP' }] as const, - ) + ? probeWorker(workerUrls.mcp).then((result) => ['mcp', mapProbeComponent(result, extractMcpDetails)] as const) + : Promise.resolve(null), + appConfig.services.oauth.enabled !== false + ? probeWorker(workerUrls.oauth).then((result) => ['oauth', mapProbeComponent(result, extractOauthDetails)] as const) : Promise.resolve(null), ]); @@ -122,7 +117,7 @@ async function getHealthResponse(): Promise<{ response: HealthResponse; httpStat components[name] = component; } - if (appConfig.services.jobs.enabled !== false) components.jobs = { ...(await buildJobsComponent()), label: 'Jobs' }; + if (appConfig.services.jobs.enabled !== false) components.jobs = await buildJobsComponent(); } const status = rollupStatus(components, CRITICAL_COMPONENTS); diff --git a/backend/src/lib/jobs-health.ts b/backend/src/lib/jobs-health.ts index 4f35f08c4..c5e2a9629 100644 --- a/backend/src/lib/jobs-health.ts +++ b/backend/src/lib/jobs-health.ts @@ -48,13 +48,7 @@ interface QueueRow extends Record { */ export async function readJobsHealth(): Promise { const schema = sql.raw(JOBS_SCHEMA); - const empty: JobsHealthSnapshot = { - installed: false, - schema: JOBS_SCHEMA, - cronOn: null, - cronAgeMs: null, - queues: [], - }; + const empty: JobsHealthSnapshot = { installed: false, schema: JOBS_SCHEMA, cronOn: null, cronAgeMs: null, queues: [] }; if (env.NODB) return empty; const { rows: versions } = await baseDb.execute<{ cron_on: Date | null }>(sql` diff --git a/backend/src/lib/jobs.test.ts b/backend/src/lib/jobs.test.ts index 858a78696..c7f2767ed 100644 --- a/backend/src/lib/jobs.test.ts +++ b/backend/src/lib/jobs.test.ts @@ -25,21 +25,15 @@ describe('job declarations', () => { }); it('rejects a cron expression the scheduler cannot evaluate', () => { - expect(() => validateJobDeclarations(preview, { jobs: [job('bad', 'every hour')], queues: [] })).toThrow( - /invalid cron/, - ); + expect(() => validateJobDeclarations(preview, { jobs: [job('bad', 'every hour')], queues: [] })).toThrow(/invalid cron/); }); it('rejects a name shared by a queue and a job', () => { - expect(() => validateJobDeclarations(preview, { jobs: [job('same')], queues: [queue('same')] })).toThrow( - /declared twice/, - ); + expect(() => validateJobDeclarations(preview, { jobs: [job('same')], queues: [queue('same')] })).toThrow(/declared twice/); }); it('rejects a dead-letter target that is not declared', () => { - expect(() => - validateJobDeclarations(preview, { jobs: [], queues: [queue('deliver', { deadLetter: 'deliver.dead' })] }), - ).toThrow(/not declared/); + expect(() => validateJobDeclarations(preview, { jobs: [], queues: [queue('deliver', { deadLetter: 'deliver.dead' })] })).toThrow(/not declared/); expect(() => validateJobDeclarations(preview, { jobs: [], diff --git a/backend/src/lib/lens-telemetry.ts b/backend/src/lib/lens-telemetry.ts index b1a343ec8..667f4f04b 100644 --- a/backend/src/lib/lens-telemetry.ts +++ b/backend/src/lib/lens-telemetry.ts @@ -6,12 +6,8 @@ const meter = otel.meterProvider.getMeter('app-lens'); const transformDuration = meter.createHistogram('lens.transform.duration_ms', { description: 'Duration of a doba lens transform (full chain) in milliseconds', }); -const stepDuration = meter.createHistogram('lens.step.duration_ms', { - description: 'Duration of a single lens migration step in milliseconds', -}); -const warnings = meter.createCounter('lens.warnings', { - description: 'Warnings emitted during lens transforms', -}); +const stepDuration = meter.createHistogram('lens.step.duration_ms', { description: 'Duration of a single lens migration step in milliseconds' }); +const warnings = meter.createCounter('lens.warnings', { description: 'Warnings emitted during lens transforms' }); const hooks: RegistryHooks = { onTransform: (info) => transformDuration.record(info.durationMs, { from: info.from, to: info.to, ok: info.ok }), diff --git a/backend/src/lib/listeners.ts b/backend/src/lib/listeners.ts index 0af4ed2d8..321894880 100644 --- a/backend/src/lib/listeners.ts +++ b/backend/src/lib/listeners.ts @@ -32,10 +32,7 @@ export function serveApi( { fetch, port, hostname = '0.0.0.0' }: ListenOptions & { fetch: FetchHandler }, onListening?: (info: AddressInfo) => void, ): ServerType { - const server = serve( - { fetch, hostname, port, serverOptions: { keepAlive: true, keepAliveTimeout: 30_000 } }, - onListening, - ); + const server = serve({ fetch, hostname, port, serverOptions: { keepAlive: true, keepAliveTimeout: 30_000 } }, onListening); if ('headersTimeout' in server) { server.headersTimeout = 60_000; server.requestTimeout = 30_000; diff --git a/backend/src/lib/mailer.ts b/backend/src/lib/mailer.ts index 465a7809e..71fa18c57 100644 --- a/backend/src/lib/mailer.ts +++ b/backend/src/lib/mailer.ts @@ -35,11 +35,7 @@ interface BrevoPlaceholders { * @param placeholders - The placeholders the mailer put there itself. * @param format - `html` for the body and HTML params, `text` for the subject. */ -export function neutralizeBrevoTags( - content: string, - { params, htmlParams = [] }: BrevoPlaceholders, - format: 'html' | 'text', -): string { +export function neutralizeBrevoTags(content: string, { params, htmlParams = [] }: BrevoPlaceholders, format: 'html' | 'text'): string { const brace = format === 'html' ? '{' : '{\u200B'; return content.replace(BREVO_TAG_OPENER, (match, key: string | undefined, safe: string | undefined) => { const ownPlaceholder = key !== undefined && (safe ? htmlParams.includes(key) : params.includes(key)); @@ -52,10 +48,7 @@ export function neutralizeBrevoTags( * local render would apply, and with no tag opener left in it: pongo2 never parses a printed value, and nothing here * depends on that. */ -const withSafeHtmlParams = ( - params: Record, - htmlParams: Partial>, -): Record => +const withSafeHtmlParams = (params: Record, htmlParams: Partial>): Record => Object.fromEntries( Object.entries(params).map(([key, value]) => { const policy = htmlParams[key]; @@ -133,9 +126,7 @@ export const mailer: Mailer = { for (const [key, policy] of Object.entries(declaredHtmlParams)) htmlParams[paramKey(key)] = policy; // Translate once per language - const translated = template.translate(lng, staticProps, (key) => - brevoPlaceholder(paramKey(String(key)), htmlParams), - ); + const translated = template.translate(lng, staticProps, (key) => brevoPlaceholder(paramKey(String(key)), htmlParams)); const { subject, ...componentProps } = translated; // Determine per-recipient keys (everything beyond email/lng) @@ -179,10 +170,7 @@ export const mailer: Mailer = { htmlContent: neutralizeBrevoTags(html, placeholders, 'html'), sender: { email: appConfig.senderEmail }, replyTo: { email: replyTo || appConfig.supportEmail }, - messageVersions: versions.map((version) => ({ - ...version, - params: withSafeHtmlParams(version.params, htmlParams), - })), + messageVersions: versions.map((version) => ({ ...version, params: withSafeHtmlParams(version.params, htmlParams) })), }); } catch (err) { log.warn('Failed to send email batch', { err }); diff --git a/backend/src/lib/module.ts b/backend/src/lib/module.ts index 4530582dd..00d63331a 100644 --- a/backend/src/lib/module.ts +++ b/backend/src/lib/module.ts @@ -63,10 +63,8 @@ export interface NotificationSubjectRow { id: string; createdBy: string | null; organizationId: string; - /** Stored body; mention derivation reads it on mentionable modules. */ + /** Stored body; the fan-out reads mentions from it on a mentionable source. */ description?: string | null; - /** Server-derived mentioned user ids; the fan-out trusts this column, never client input. */ - mentions?: string[] | null; [key: string]: unknown; } @@ -79,30 +77,21 @@ export interface NotificationCandidate { /** * Notification source declaration for a product module (`notifications: true` declares nothing). * Each reader below is used when given, else the notification module reads the product table - * (`notification-sources.ts`): live rows are the non-deleted, published ones; a `mentions` column - * (`mentionableColumns`) switches mention derivation and mention fan-out on; previews and digest - * lines read `name` and `description`; `deriveFrom` is `both` when the module registers a - * `yjsMaterializer`. Apps typically declare only `resolveRecipients` and `resolveContextId`. + * (`notification-sources.ts`): live rows are the non-deleted, published ones; previews and digest + * lines read `name` and `description`. Apps typically declare only `resolveRecipients` and + * `resolveContextId`. * - * The fan-out runs off the CDC activity stream, but mention derivation listens on the mutation - * bus, so the module's create and update ops must `dispatchMutation(txCtx, '.created' | - * '.updated', { before, after })` inside the write transaction (`materialized: true` for - * Yjs materialization); see the attachment ops for the shape. Deep links need no declaration: - * emails and push carry the subject's location for the frontend `/n` route. + * The fan-out runs after commit off the CDC activity stream, so ops need no wiring for it. On a + * mentionable source it reads mentions from the stored `description` of a created row, or of an + * updated one whose changed fields include it, and notifies the mentioned users who may read the + * row and were not told about it before. Deep links need no declaration: emails and push carry + * the subject's location for the frontend `/n` route. */ export interface ModuleNotifications { - /** Server-side mention derivation and mention fan-out; defaults to whether the table has a `mentions` column. */ + /** Mention fan-out from the stored `description`; on unless set to false. */ mentionable?: boolean; - /** - * Which writes mention derivation reads: `client` skips Yjs materialization so a collaborative - * re-write cannot resurrect a mention edited away in a client-owned body; `materialized` for - * bodies whose Yjs document is the source of truth; `both` when either path edits. - */ - deriveFrom?: 'client' | 'materialized' | 'both'; - /** Batch-load audience-bearing subject rows for the given ids; drop drafts and deleted rows here. */ + /** Batch-load audience-bearing subject rows (with `description` when mentionable); drop drafts and deleted rows here. */ loadRows?: (tx: DbOrTx, ids: string[]) => Promise; - /** Persist the server-derived mention set for one row. */ - writeMentions?: (tx: DbOrTx, id: string, mentions: string[]) => Promise; /** Recipients beyond mentions (thread participants, assignees, ...) with their notification type. */ resolveRecipients?: (tx: DbOrTx, row: NotificationSubjectRow) => Promise; /** Grouping/deep-link context id for a row (e.g. the host thread); defaults to the row's own id. */ diff --git a/backend/src/lib/mutation-bus.ts b/backend/src/lib/mutation-bus.ts index 05b5a2c3b..d2829d0a5 100644 --- a/backend/src/lib/mutation-bus.ts +++ b/backend/src/lib/mutation-bus.ts @@ -17,7 +17,7 @@ export type MutationHandler = (ctx: ActorContext, payload: MutationPayload) => P const handlers = new Map(); -/** Direct registration, for cross-module handlers derived from other modules' declarations (e.g. mention derivation). */ +/** Direct registration, for cross-module handlers derived from other modules' declarations. */ export function registerMutationHandler(event: TrackedEventType, handler: MutationHandler): void { const existing = handlers.get(event); if (existing) existing.push(handler); @@ -33,10 +33,6 @@ onBackendModuleRegister((module) => { }); /** Awaits handlers in registration order, rejecting on the first error. Pass a transactional ctx to join the write. */ -export async function dispatchMutation( - ctx: ActorContext, - event: TrackedEventType, - payload: MutationPayload = {}, -): Promise { +export async function dispatchMutation(ctx: ActorContext, event: TrackedEventType, payload: MutationPayload = {}): Promise { for (const handler of handlers.get(event) ?? []) await handler(ctx, payload); } diff --git a/backend/src/lib/notifications/send-matrix-message.test.ts b/backend/src/lib/notifications/send-matrix-message.test.ts index 65f767cb2..c2a8e64f0 100644 --- a/backend/src/lib/notifications/send-matrix-message.test.ts +++ b/backend/src/lib/notifications/send-matrix-message.test.ts @@ -2,9 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const botAccessToken = 'syt_bot_access_token_value'; -vi.mock('#/env', () => ({ - env: { ELEMENT_ROOM_ID: '!room:matrix.example', ELEMENT_BOT_ACCESS_TOKEN: botAccessToken }, -})); +vi.mock('#/env', () => ({ env: { ELEMENT_ROOM_ID: '!room:matrix.example', ELEMENT_BOT_ACCESS_TOKEN: botAccessToken } })); vi.mock('#/utils/logger', () => ({ log: { info: vi.fn(), error: vi.fn() } })); const { sendMatrixMessage } = await import('./send-matrix-message'); diff --git a/backend/src/lib/notifications/send-matrix-message.ts b/backend/src/lib/notifications/send-matrix-message.ts index fce5af14a..f4befc9ee 100644 --- a/backend/src/lib/notifications/send-matrix-message.ts +++ b/backend/src/lib/notifications/send-matrix-message.ts @@ -8,15 +8,7 @@ import { log } from '#/utils/logger'; type MatrixMsgTypes = 'm.text' | 'm.notice'; -export const sendMatrixMessage = async ({ - msgtype, - textMessage, - html, -}: { - msgtype: MatrixMsgTypes; - textMessage: string; - html?: string; -}) => { +export const sendMatrixMessage = async ({ msgtype, textMessage, html }: { msgtype: MatrixMsgTypes; textMessage: string; html?: string }) => { if (!env.ELEMENT_ROOM_ID || !env.ELEMENT_BOT_ACCESS_TOKEN) { log.info('Missing required Element env values (roomId and/or botAccessToken).'); return; diff --git a/backend/src/lib/sync-metrics.ts b/backend/src/lib/sync-metrics.ts index bf29551a3..89c18bccd 100644 --- a/backend/src/lib/sync-metrics.ts +++ b/backend/src/lib/sync-metrics.ts @@ -21,11 +21,7 @@ export const cdcMessagesReceived = meter.createCounter('sync.cdc.messages_receiv const tracer = trace.getTracer('app-sync'); /** Start a sync span; the caller ends it. */ -export function startSyncSpan( - name: string, - attributes?: Record, - _parentTraceId?: string, -): Span { +export function startSyncSpan(name: string, attributes?: Record, _parentTraceId?: string): Span { const span = tracer.startSpan(name); if (attributes) { for (const [key, value] of Object.entries(attributes)) { diff --git a/backend/src/lib/transloadit.ts b/backend/src/lib/transloadit.ts index 852e2a5d0..9a982454b 100644 --- a/backend/src/lib/transloadit.ts +++ b/backend/src/lib/transloadit.ts @@ -22,11 +22,7 @@ export const getParams = (templateId: UploadTemplateId, sub: string) => { const { publicBucket, bucketName } = uploadStorage(templateId); return { - auth: { - key: authKey, - expires, - nonce, - }, + auth: { key: authKey, expires, nonce }, steps: { ':original': { robot: '/upload/handle' }, // Inject steps based on template: avatar thumbnail, cover image, attachments ... diff --git a/backend/src/lib/ttl-cache.ts b/backend/src/lib/ttl-cache.ts index 8711f0776..e4c538ddc 100644 --- a/backend/src/lib/ttl-cache.ts +++ b/backend/src/lib/ttl-cache.ts @@ -82,11 +82,7 @@ export class TTLCache { } get stats(): { size: number; capacity: number; utilization: number } { - return { - size: this.cache.size, - capacity: this.maxSize, - utilization: this.cache.size / this.maxSize, - }; + return { size: this.cache.size, capacity: this.maxSize, utilization: this.cache.size / this.maxSize }; } /** Cancel the internal timer for graceful shutdown; entries stop expiring automatically. */ diff --git a/backend/src/main.api.ts b/backend/src/main.api.ts index 73fe1f1a8..41adc2741 100644 --- a/backend/src/main.api.ts +++ b/backend/src/main.api.ts @@ -12,7 +12,6 @@ import { startGeoipRefresh } from '#/lib/geoip'; import { serveApi, serveInternal } from '#/lib/listeners'; import { stopPgBoss } from '#/lib/pg-boss'; import { otel } from '#/lib/tracing'; -import { listenForAuthInvalidation } from '#/middlewares/guard/invalidation-listener'; import { registerCacheInvalidation } from '#/middlewares/product-cache/cache-invalidation'; import { baseApp as app } from '#/routes'; import { timestamp } from '#/utils/console'; @@ -23,7 +22,7 @@ otel.verifyConnection(); let server: import('@hono/node-server').ServerType | undefined; let internalListener: ReturnType | undefined; -/** Stops what this process starts besides its listeners: the auth invalidation listener and the GeoIP refresh. */ +/** Stops what this process starts besides its listeners: the GeoIP refresh. */ const stops: (() => unknown)[] = []; const startTunnel = appConfig.mode === 'tunnel' ? (await import('../scripts/start-tunnel')).startTunnel : () => null; @@ -61,7 +60,6 @@ const main = async () => { } registerCacheInvalidation(); - stops.push(listenForAuthInvalidation()); // Per process, not a scheduled job: every replica keeps its own GeoIP copy current. stops.push(startGeoipRefresh()); @@ -69,54 +67,46 @@ const main = async () => { // Server-to-server routes (the CDC socket, the Yjs relay) listen apart from the public API. internalListener = serveInternal({ port: Number(env.INTERNAL_PORT) }); - server = serveApi( - { - fetch: app.fetch, - port, - }, - async () => { - // Single-VM: this API process also runs every enabled service in-process, through each subsystem's own start(). - if (appConfig.singleVM) { - if (appConfig.services.cdc.enabled) { - console.warn( - `${timestamp()} [startup] singleVM + cdc: API holds the replication slot, deploy must be exclusive (no blue-green)`, - ); - // The replication loop never resolves, so detach it and log failures to prevent unhandled rejections. - void (await import('cdc-worker')).runCdcWorker().catch((error) => { - console.error(`${timestamp()} [startup] in-process cdc worker crashed:`, error); - }); - } - if (appConfig.services.yjs.enabled) await (await import('yjs-worker')).startYjsWorker(); - (await import('#/modules/yjs/yjs-materializers')).warnWhenNoYjsMaterializer(); - // Folded workers listen on their own ports (the LB routes each path to the host VM on that port); the API - // process keeps PORT for itself. - if (appConfig.services.mcp.enabled) - await (await import('#/modules/mcp/worker/mcp-worker-entry')).startMcpWorker({ - port: appConfig.devPorts.mcp, - }); - if (appConfig.services.oauth.enabled) - await (await import('#/modules/oauth-server/worker/oauth-worker-entry')).startOauthServer({ - port: appConfig.devPorts.oauth, - inProcess: true, - }); - // The folded jobs worker needs no port: this process's /health carries the jobs component. - if (appConfig.services.jobs.enabled) - await (await import('#/lib/jobs-worker')).startJobsWorker({ inProcess: true }); + server = serveApi({ fetch: app.fetch, port }, async () => { + // Single-VM: this API process also runs every enabled service in-process, through each subsystem's own start(). + if (appConfig.singleVM) { + if (appConfig.services.cdc.enabled) { + console.warn(`${timestamp()} [startup] singleVM + cdc: API holds the replication slot, deploy must be exclusive (no blue-green)`); + // The replication loop never resolves, so detach it and log failures to prevent unhandled rejections. + void (await import('cdc-worker')).runCdcWorker().catch((error) => { + console.error(`${timestamp()} [startup] in-process cdc worker crashed:`, error); + }); } + if (appConfig.services.yjs.enabled) await (await import('yjs-worker')).startYjsWorker(); + (await import('#/modules/yjs/yjs-materializers')).warnWhenNoYjsMaterializer(); + // Folded workers listen on their own ports (the LB routes each path to the host VM on that port); the API + // process keeps PORT for itself. + if (appConfig.services.mcp.enabled) + await (await import('#/modules/mcp/worker/mcp-worker-entry')).startMcpWorker({ + port: appConfig.devPorts.mcp, + inProcess: true, + }); + if (appConfig.services.oauth.enabled) + await (await import('#/modules/oauth-server/worker/oauth-worker-entry')).startOauthServer({ + port: appConfig.devPorts.oauth, + inProcess: true, + }); + // The folded jobs worker needs no port: this process's /health carries the jobs component. + if (appConfig.services.jobs.enabled) await (await import('#/lib/jobs-worker')).startJobsWorker({ inProcess: true }); + } - const tunnelUrl = await startTunnel(); + const tunnelUrl = await startTunnel(); - renderAscii(); - console.info(' '); + renderAscii(); + console.info(' '); - console.info(`${pc.bold(pc.greenBright(appConfig.name))} + console.info(`${pc.bold(pc.greenBright(appConfig.name))} Frontend: ${pc.bold(pc.cyanBright(appConfig.frontendUrl))} Backend: ${pc.bold(pc.cyanBright(appConfig.backendUrl))} Tunnel: ${pc.bold(pc.magentaBright(tunnelUrl || '-'))}`); - console.info(' '); - }, - ); + console.info(' '); + }); }; setupGracefulShutdown({ diff --git a/backend/src/main.migrate.ts b/backend/src/main.migrate.ts index a2854aaa9..0d3140f1a 100644 --- a/backend/src/main.migrate.ts +++ b/backend/src/main.migrate.ts @@ -34,8 +34,7 @@ try { // Surface wrapped driver errors so serial-only production failures retain TLS/auth detail. const cause = error instanceof Error ? error.cause : undefined; if (cause) { - const causeMsg = - cause instanceof Error ? `${cause.message}${cause.stack ? `\n${cause.stack}` : ''}` : String(cause); + const causeMsg = cause instanceof Error ? `${cause.message}${cause.stack ? `\n${cause.stack}` : ''}` : String(cause); console.error(pc.red(`${timestamp()} [migrate] cause: ${causeMsg}`)); } // Log to OTel because one-shot container output is not collected, then wait through the batch interval. diff --git a/backend/src/middlewares/app.ts b/backend/src/middlewares/app.ts index dffdd4690..0c04fc5ec 100644 --- a/backend/src/middlewares/app.ts +++ b/backend/src/middlewares/app.ts @@ -32,13 +32,18 @@ app.use( }), ); -app.use( - '*', - httpInstrumentationMiddleware({ - serviceName: appConfig.name, - serviceVersion: '1.0', - }), -); +const requestIdHeader = 'X-Request-Id'; + +// The span records the request id from the response header, so a trace is searchable by the id a user quotes. +app.use('*', httpInstrumentationMiddleware({ serviceName: appConfig.name, serviceVersion: '1.0', captureResponseHeaders: [requestIdHeader] })); + +// One id per request, generated here and never taken from the caller, so no two requests share one. +app.use('*', (ctx, next) => { + const requestId = crypto.randomUUID(); + ctx.set('requestId', requestId); + ctx.header(requestIdHeader, requestId); + return next(); +}); app.use('*', loggerMiddleware); diff --git a/backend/src/middlewares/body-limit.ts b/backend/src/middlewares/body-limit.ts index cce7d003a..3ca19d7ce 100644 --- a/backend/src/middlewares/body-limit.ts +++ b/backend/src/middlewares/body-limit.ts @@ -12,11 +12,7 @@ export const dynamicBodyLimit: MiddlewareHandler = createMiddleware(as const isJson = contentType.includes('application/json'); const isMultipart = contentType.includes('multipart/form-data'); - const maxSize = isJson - ? appConfig.jsonBodyLimit - : isMultipart - ? appConfig.fileUploadLimit - : appConfig.defaultBodyLimit; + const maxSize = isJson ? appConfig.jsonBodyLimit : isMultipart ? appConfig.fileUploadLimit : appConfig.defaultBodyLimit; const limit = bodyLimit({ maxSize, diff --git a/backend/src/middlewares/config-switch.ts b/backend/src/middlewares/config-switch.ts new file mode 100644 index 000000000..d1712ecf0 --- /dev/null +++ b/backend/src/middlewares/config-switch.ts @@ -0,0 +1,22 @@ +import type { MiddlewareHandler } from 'hono'; +import { type ConfigSwitch, isSwitchOn } from 'shared'; +import { AppError } from '#/core/error'; + +/** + * Refuses a request while its config switch is off: a service answers 404 as if the route did not exist, a + * sign-in method 400 `forbidden_strategy`, an OAuth provider 400 `unsupported_oauth`. + */ +export const assertSwitchOn = (on: ConfigSwitch): void => { + if (isSwitchOn(on)) return; + if ('service' in on) throw new AppError(404, 'route_not_found', 'warn'); + if (on.provider) throw new AppError(400, 'unsupported_oauth', 'error', { meta: { strategy: on.provider } }); + throw new AppError(400, 'forbidden_strategy', 'error', { meta: { strategy: on.strategy } }); +}; + +/** The gate `createXRoute` runs for a route's `xEnabledBy`, before its guards. */ +export const configSwitchGate = + (on: ConfigSwitch): MiddlewareHandler => + async (_ctx, next) => { + assertSwitchOn(on); + await next(); + }; diff --git a/backend/src/middlewares/guard/api-key-cache.ts b/backend/src/middlewares/guard/api-key-cache.ts index b842398d8..266b65809 100644 --- a/backend/src/middlewares/guard/api-key-cache.ts +++ b/backend/src/middlewares/guard/api-key-cache.ts @@ -10,7 +10,7 @@ export interface ApiKeyCacheEntry { const apiKeyCache = new TTLCache({ maxSize: 5000, - defaultTtl: 60_000, // 1 min, security-sensitive: a revoke or disable is also invalidated explicitly + defaultTtl: 60_000, // 1 min: keys are used and revoked in the API process, which drops them at the revoke or disable onDispose: (hash, entry) => { const hashes = accountIndex.get(entry.account.id); if (hashes) { @@ -40,9 +40,3 @@ export const invalidateApiKeyCacheByAccount = (accountId: string): void => { for (const hash of accountIndex.get(accountId) ?? []) apiKeyCache.delete(hash); accountIndex.delete(accountId); }; -export const clearApiKeyCache = (): void => { - apiKeyCache.clear(); - accountIndex.clear(); -}; - -export const apiKeyCacheStats = () => apiKeyCache.stats; diff --git a/backend/src/middlewares/guard/auth-cache.ts b/backend/src/middlewares/guard/auth-cache.ts deleted file mode 100644 index b0e2c2e7f..000000000 --- a/backend/src/middlewares/guard/auth-cache.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { TTLCache } from '#/lib/ttl-cache'; -import type { SessionFacts } from '#/modules/auth/sessions-db'; -import type { MembershipBaseModel } from '#/modules/memberships/helpers/select'; -import type { UserWithCounters } from '#/modules/user/helpers/select'; - -export interface SessionCacheEntry { - session: SessionFacts; - user: UserWithCounters; - /** Holds the admin system role. The rights also need an allowlisted request address, so they are never cached. */ - hasSystemRole: boolean; -} - -export type MembershipCacheEntry = (MembershipBaseModel & { createdBy: string | null })[]; - -/** - * Keyed by the hash of the session's cookie token, the value its row stores: an entry answers only to a cookie that - * carries the token itself, never to a session id. - */ -const sessionCache = new TTLCache({ - maxSize: 5000, - defaultTtl: 60_000, // 1 min, security-sensitive - onDispose: (key, value) => { - // Clean up reverse index when entry expires or is evicted - const secretHashes = userIndex.get(value.user.id); - if (secretHashes) { - secretHashes.delete(key); - if (secretHashes.size === 0) userIndex.delete(value.user.id); - } - }, -}); - -const membershipCache = new TTLCache({ - maxSize: 5000, - defaultTtl: 5 * 60_000, // 5 min, actively invalidated on changes -}); - -/** Reverse index: userId to the secret hashes of the user's cached sessions, for user-wide invalidation. */ -const userIndex = new Map>(); - -export const getSessionCache = (secretHash: string): SessionCacheEntry | undefined => { - return sessionCache.get(secretHash); -}; - -export const getMembershipCache = (userId: string): MembershipCacheEntry | undefined => { - return membershipCache.get(userId); -}; - -export const setSessionCache = (secretHash: string, entry: SessionCacheEntry): void => { - // Jitter TTL ±20% (48-72s) to prevent synchronized expiry under load - const jitteredTtl = Math.round(60_000 * (0.8 + Math.random() * 0.4)); - sessionCache.set(secretHash, entry, jitteredTtl); - - const userId = entry.user.id; - let secretHashes = userIndex.get(userId); - if (!secretHashes) { - secretHashes = new Set(); - userIndex.set(userId, secretHashes); - } - secretHashes.add(secretHash); -}; - -export const setMembershipCache = (userId: string, memberships: MembershipCacheEntry): void => { - // Jitter TTL ±20% (4-6 min) so a synchronized cohort cannot add a membership-DB burst to a fan-out stampede - const jitteredTtl = Math.round(5 * 60_000 * (0.8 + Math.random() * 0.4)); - membershipCache.set(userId, memberships, jitteredTtl); -}; - -/** Invalidate all cached entries for a user: every session and the memberships. */ -export const invalidateAuthCacheByUser = (userId: string): void => { - const secretHashes = userIndex.get(userId); - if (secretHashes) { - for (const secretHash of secretHashes) { - sessionCache.delete(secretHash); - } - userIndex.delete(userId); - } - membershipCache.delete(userId); -}; - -export const clearAuthCache = (): void => { - sessionCache.clear(); - membershipCache.clear(); - userIndex.clear(); -}; - -export const authCacheStats = () => ({ - session: sessionCache.stats, - membership: membershipCache.stats, -}); diff --git a/backend/src/middlewares/guard/cross-tenant-guard.ts b/backend/src/middlewares/guard/cross-tenant-guard.ts deleted file mode 100644 index 42db6a0ed..000000000 --- a/backend/src/middlewares/guard/cross-tenant-guard.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { AppError } from '#/core/error'; -import { xMiddleware } from '#/core/x-middleware'; -import { baseDb } from '#/db/db'; - -/** Sets baseDb for authenticated cross-tenant routes; handlers call tenantRead() when they need RLS. */ -export const crossTenantGuard = xMiddleware( - { - functionName: 'crossTenantGuard', - type: 'x-guard', - security: [{ cookieAuth: [] }], - name: 'crossTenant', - description: 'Requires userGuard and sets baseDb for cross-tenant access', - }, - async (ctx, next) => { - const user = ctx.var.user; - const memberships = ctx.var.memberships; - - if (!user || memberships === undefined) { - throw new AppError(401, 'unauthorized', 'warn', { - message: 'crossTenantGuard requires userGuard middleware', - }); - } - - ctx.set('db', baseDb); - await next(); - }, -); diff --git a/backend/src/middlewares/guard/index.ts b/backend/src/middlewares/guard/index.ts index 4099df5cc..aa3f2a9a9 100644 --- a/backend/src/middlewares/guard/index.ts +++ b/backend/src/middlewares/guard/index.ts @@ -1,7 +1,5 @@ export { hasApiKeyHeader } from '#/modules/service-accounts/helpers/api-key'; export * from './actor-guard'; -export * from './cross-tenant-guard'; -export * from './no-impersonation-guard'; export * from './org-guard'; export * from './public-guard'; export * from './relatable-guard'; diff --git a/backend/src/middlewares/guard/invalidate-cache.ts b/backend/src/middlewares/guard/invalidate-cache.ts index 02b9b8874..940036c2e 100644 --- a/backend/src/middlewares/guard/invalidate-cache.ts +++ b/backend/src/middlewares/guard/invalidate-cache.ts @@ -1,123 +1,48 @@ -import { z } from '@hono/zod-openapi'; -import { sql } from 'drizzle-orm'; -import type { DbOrTx } from '#/db/db'; -import { env } from '#/env'; -import { clearOauthClientCache, invalidateOauthClientCache } from '#/modules/oauth-server/client-cache'; import type { ServiceAccountModel } from '#/modules/service-accounts/service-accounts-db'; -import { clearApiKeyCache, invalidateApiKeyCacheByAccount } from './api-key-cache'; -import { clearAuthCache, invalidateAuthCacheByUser } from './auth-cache'; -import { clearOrgCache, invalidateOrgCache, invalidateOrgCacheByTenant } from './org-cache'; -import { clearTenantCache, invalidateTenantCache } from './tenant-cache'; -import { - clearTokenGrantCache, - invalidateTokenGrant, - invalidateTokenGrantsByActor, - invalidateTokenGrantsByTenant, -} from './token-grant-cache'; - -/** The Postgres channel every process with guard caches (api, mcp, oauth) listens on. */ -export const authInvalidateChannel = 'auth_invalidate'; - -const authInvalidationSchema = z.union([ - z.object({ user: z.string() }), - z.object({ org: z.object({ tenantId: z.string(), orgId: z.string() }) }), - z.object({ tenant: z.string() }), - z.object({ grant: z.object({ accountId: z.string(), grantId: z.string() }) }), - z.object({ serviceAccount: z.object({ id: z.string(), tenantId: z.string(), clientId: z.string().nullable() }) }), -]); - -/** - * What one message drops: a user's sessions, memberships and access-token verdicts; an organization; a tenant with its - * organizations and the verdicts on tokens naming it; the verdicts on one grant's tokens; or a service account's API - * keys, token verdicts and client, plus the verdicts on its users' tokens in its tenant when it installs an app. - */ -export type AuthInvalidation = z.infer; - -/** The invalidation a message carries, or null for a payload that is not one. */ -export const parseAuthInvalidation = (payload: string): AuthInvalidation | null => { - try { - const parsed = authInvalidationSchema.safeParse(JSON.parse(payload)); - return parsed.success ? parsed.data : null; - } catch { - return null; - } -}; - -/** Drops what an invalidation names from this process's guard caches. */ -export const dropCachedAuth = (invalidation: AuthInvalidation): void => { - if ('user' in invalidation) { - invalidateAuthCacheByUser(invalidation.user); - invalidateTokenGrantsByActor(invalidation.user); - } else if ('org' in invalidation) invalidateOrgCache(invalidation.org.tenantId, invalidation.org.orgId); - else if ('tenant' in invalidation) { - // The org cache keys are prefixed by tenantId. - invalidateTenantCache(invalidation.tenant); - invalidateOrgCacheByTenant(invalidation.tenant); - invalidateTokenGrantsByTenant(invalidation.tenant); - } else if ('grant' in invalidation) invalidateTokenGrant(invalidation.grant.accountId, invalidation.grant.grantId); - else { - const { id, tenantId, clientId } = invalidation.serviceAccount; - invalidateApiKeyCacheByAccount(id); - invalidateTokenGrantsByActor(id); - invalidateOauthClientCache(id); - // An installed app: its users' grants in the tenant rest on the installation (`grantRefusal`). - if (clientId) invalidateTokenGrantsByTenant(tenantId, clientId); - } -}; - -/** Drops every entry of every guard cache: what a process does when it may have missed messages. */ -export const clearCachedAuth = (): void => { - clearAuthCache(); - clearOrgCache(); - clearTenantCache(); - clearTokenGrantCache(); - clearApiKeyCache(); - clearOauthClientCache(); -}; +import { invalidateApiKeyCacheByAccount } from './api-key-cache'; +import { invalidateOrgCache, invalidateOrgCacheByTenant } from './org-cache'; +import { dropCachedSessions } from './session-cache'; +import { invalidateTenantCache } from './tenant-cache'; +import { invalidateTokenGrant, invalidateTokenGrantsByActor, invalidateTokenGrantsByTenant } from './token-grant-cache'; /** - * Tells every listening process, this one included, to drop what the invalidation names. Inside a transaction the - * message goes out when it commits, and not at all when it rolls back. + * The user's cached sessions, which carry the user row, system role and bindings version, and token verdicts: after a + * write to the user's row or memberships. */ -export const publishAuthInvalidation = async (db: DbOrTx, invalidation: AuthInvalidation): Promise => { - if (env.NODB) return; - await db.execute(sql`select pg_notify(${authInvalidateChannel}, ${JSON.stringify(invalidation)})`); -}; - -/** Drops here at once and publishes on `db`: on the writing transaction, the message commits with the write. */ -const invalidate = async (db: DbOrTx, invalidation: AuthInvalidation): Promise => { - dropCachedAuth(invalidation); - await publishAuthInvalidation(db, invalidation); -}; - -/** The cached sessions, memberships and access-token verdicts: after profile updates, membership changes or sign-out. */ -function user(db: DbOrTx, userId: string): Promise { - return invalidate(db, { user: userId }); +function user(userId: string): void { + dropCachedSessions(userId); + invalidateTokenGrantsByActor(userId); } /** After org name/settings updates or org deletion. */ -function org(db: DbOrTx, tenantId: string, orgId: string): Promise { - return invalidate(db, { org: { tenantId, orgId } }); +function org(tenantId: string, orgId: string): void { + invalidateOrgCache(tenantId, orgId); } /** After tenant updates or deletion. Cascades to the tenant's organizations and the verdicts on its tokens. */ -function tenant(db: DbOrTx, tenantId: string): Promise { - return invalidate(db, { tenant: tenantId }); +function tenant(tenantId: string): void { + invalidateTenantCache(tenantId); + invalidateOrgCacheByTenant(tenantId); + invalidateTokenGrantsByTenant(tenantId); } -/** - * After a service account's status or keys change: its API keys, token verdicts and client drop, and for an installed - * app the verdicts on its users' tokens in the tenant. - */ -function serviceAccount( - db: DbOrTx, - { id, tenantId, oauthClientId }: Pick, -): Promise { - return invalidate(db, { serviceAccount: { id, tenantId, clientId: oauthClientId } }); +/** After a service account's status or keys change: its API keys, and for an installed app the verdicts on its users' tokens. */ +function serviceAccount({ id, tenantId, oauthClientId }: Pick): void { + invalidateApiKeyCacheByAccount(id); + // An installed app: its users' grants in the tenant rest on the installation (`grantRefusal`). + if (oauthClientId) invalidateTokenGrantsByTenant(tenantId, oauthClientId); +} + +/** After a grant is deleted: the verdicts on its tokens. */ +function grant(accountId: string, grantId: string): void { + invalidateTokenGrant(accountId, grantId); } /** - * Every call drops the entries in every process: here at once, elsewhere through `auth_invalidate` when `db` commits. - * Pass the writing transaction and call it last in it; a deleted grant publishes through `revokeGrant`. + * Drops what a write changed from this process's guard caches; call it once the write has committed, so no request + * caches the old row again in between. The API process also drops sessions on CDC reports of user, membership and + * system role changes. Other processes keep an entry until it expires: 10 seconds for sessions, 15 for token verdicts, + * a minute for the rest. Grants, the API keys behind tokens and OAuth clients are read per request, and memberships + * are cached under the bindings version. */ -export const invalidateCache = { user, org, tenant, serviceAccount }; +export const invalidateCache = { user, org, tenant, serviceAccount, grant }; diff --git a/backend/src/middlewares/guard/invalidation-listener.test.ts b/backend/src/middlewares/guard/invalidation-listener.test.ts deleted file mode 100644 index f21823a5c..000000000 --- a/backend/src/middlewares/guard/invalidation-listener.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { sql } from 'drizzle-orm'; -import pg, { type Pool, type PoolClient } from 'pg'; -import { testDatabaseUrl } from 'shared/test-db'; -import { generateId } from 'shared/utils/entity-id'; -import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; -import { baseDb, getAdminDb } from '#/db/db'; -import { activityBus } from '#/lib/activity-bus'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; -import { clientCache } from '#/modules/oauth-server/client-cache'; -import type { VerifiedAccessToken } from '#/modules/oauth-server/verify-access-token'; -import { getApiKeyCache, setApiKeyCache } from './api-key-cache'; -import type { MembershipCacheEntry } from './auth-cache'; -import { getMembershipCache, getSessionCache, setMembershipCache, setSessionCache } from './auth-cache'; -import { invalidateCache } from './invalidate-cache'; -import { listenForAuthInvalidation } from './invalidation-listener'; -import { getOrgCache, setOrgCache } from './org-cache'; -import { getTenantCache, setTenantCache } from './tenant-cache'; -import { getTokenGrantCache, setTokenGrantCache } from './token-grant-cache'; - -const adminDb = getAdminDb('test publish'); - -/** A message from another process: published on another connection, so only the LISTEN path can drop this one's entries. */ -const publishElsewhere = (payload: unknown) => - adminDb.execute( - sql`select pg_notify('auth_invalidate', ${typeof payload === 'string' ? payload : JSON.stringify(payload)})`, - ); - -/** A person's access token as the guard verified it: under a grant, for one tenant, from one client. */ -const userToken = ( - actorId: string, - grantId = 'grant', - tenantId = 'tenant', - clientId = 'client', -): VerifiedAccessToken => ({ - kind: 'user', - actorId, - grantId, - tenantId, - clientId, - scopes: [], -}); -/** A service account's access token, minted with one of its API keys. */ -const serviceToken = (accountId: string, keyId: string): VerifiedAccessToken => ({ - kind: 'service', - actorId: accountId, - keyId, - tenantId: 'tenant', - clientId: accountId, - scopes: [], -}); -// The caches hold what the guards hand them; a stub with the id is enough to find and drop the entries. -const cacheVerdict = (token: VerifiedAccessToken) => - setTokenGrantCache(token, { refusal: null, kind: 'user', user: { id: token.actorId } as never }); - -/** Caches a session, memberships and an access-token verdict for a user, as the guards do on a request. */ -const cacheUser = (userId: string) => { - const user = { id: userId } as never; - setSessionCache(`${userId}-session`, { session: { id: `${userId}-session` } as never, user, hasSystemRole: false }); - setMembershipCache(userId, [] as MembershipCacheEntry); - cacheVerdict(userToken(userId)); -}; -const cachedFor = (userId: string) => ({ - session: !!getSessionCache(`${userId}-session`), - memberships: !!getMembershipCache(userId), - tokenGrant: !!getTokenGrantCache(userToken(userId)), -}); - -/** Caches a service account's API key and client, and a verdict on a token minted with the key. */ -const cacheServiceAccount = (accountId: string) => { - setApiKeyCache(`${accountId}-hash`, { - apiKey: { id: `${accountId}-key` } as never, - account: { id: accountId } as never, - }); - clientCache.set(accountId, { client_id: accountId, client_kind: 'service' }); - cacheVerdict(serviceToken(accountId, `${accountId}-key`)); -}; -const cachedForAccount = (accountId: string) => ({ - apiKey: !!getApiKeyCache(`${accountId}-hash`), - client: !!clientCache.get(accountId), - tokenGrant: !!getTokenGrantCache(serviceToken(accountId, `${accountId}-key`)), -}); - -/** - * Each process (api, mcp, oauth) holds its own guard caches. A change made in one process must drop the entry in the - * others, or MCP keeps a removed membership for up to 6 minutes and a deleted user's access-token verdicts for half a minute. - */ -describe('auth_invalidate listener', () => { - let stop: () => Promise; - - beforeAll(async () => { - stop = listenForAuthInvalidation(); - // Ready once a message round-trips; connecting clears every cache, so arrange only after this. - cacheUser('probe'); - await vi.waitFor( - async () => { - await publishElsewhere({ user: 'probe' }); - expect(cachedFor('probe').session).toBe(false); - }, - { timeout: 5000 }, - ); - }); - - afterAll(async () => await stop()); - - it("must not keep a user's cached session, memberships or access-token verdicts after another process invalidates them", async () => { - cacheUser('ended'); - cacheUser('bystander'); - - await publishElsewhere({ user: 'ended' }); - - await vi.waitFor(() => - expect(cachedFor('ended')).toEqual({ session: false, memberships: false, tokenGrant: false }), - ); - expect(cachedFor('bystander')).toEqual({ session: true, memberships: true, tokenGrant: true }); - }); - - it('must not keep a cached tenant, its organizations or one organization after another process invalidates them', async () => { - const org = { id: 'org-1' } as Parameters[2]; - setTenantCache('tenant-a', { id: 'tenant-a' } as Parameters[1]); - setOrgCache('tenant-a', 'org-1', org); - setOrgCache('tenant-b', 'org-2', org); - setOrgCache('tenant-b', 'org-3', org); - - await publishElsewhere({ tenant: 'tenant-a' }); - await publishElsewhere({ org: { tenantId: 'tenant-b', orgId: 'org-2' } }); - - await vi.waitFor(() => expect(getOrgCache('tenant-b', 'org-2')).toBeUndefined()); - expect(getTenantCache('tenant-a')).toBeUndefined(); - expect(getOrgCache('tenant-a', 'org-1')).toBeUndefined(); - expect(getOrgCache('tenant-b', 'org-3')).toBeDefined(); - }); - - it("must not keep verdicts on a grant's tokens after another process deletes the grant", async () => { - const [revoked, kept] = [userToken('holder', 'revoked-grant'), userToken('holder', 'kept-grant')]; - cacheVerdict(revoked); - cacheVerdict(kept); - - await publishElsewhere({ grant: { accountId: 'holder', grantId: 'revoked-grant' } }); - - await vi.waitFor(() => expect(getTokenGrantCache(revoked)).toBeUndefined()); - expect(getTokenGrantCache(kept)).toBeDefined(); - }); - - it("must not keep verdicts on a tenant's tokens after another process changes its policy or an installed app", async () => { - const inChangedTenant = userToken('member', 'grant-a', 'tenant-policy'); - const installedApp = userToken('member', 'grant-b', 'tenant-apps', 'portfolio'); - const otherClient = userToken('member', 'grant-c', 'tenant-apps', 'https://client.example/metadata.json'); - // The installed app's grant also reaches another tenant: its verdict there is its own. - const otherTenant = userToken('member', 'grant-b', 'tenant-other', 'portfolio'); - for (const token of [inChangedTenant, installedApp, otherClient, otherTenant]) cacheVerdict(token); - - await publishElsewhere({ tenant: 'tenant-policy' }); - await publishElsewhere({ serviceAccount: { id: 'installation', tenantId: 'tenant-apps', clientId: 'portfolio' } }); - - await vi.waitFor(() => expect(getTokenGrantCache(installedApp)).toBeUndefined()); - expect(getTokenGrantCache(inChangedTenant)).toBeUndefined(); - expect(getTokenGrantCache(otherClient)).toBeDefined(); - expect(getTokenGrantCache(otherTenant)).toBeDefined(); - }); - - it("must not keep a service account's API keys, client or token verdicts after another process changes it", async () => { - cacheServiceAccount('changed-account'); - cacheServiceAccount('other-account'); - - await publishElsewhere({ serviceAccount: { id: 'changed-account', tenantId: 'tenant', clientId: null } }); - - await vi.waitFor(() => expect(cachedForAccount('changed-account').apiKey).toBe(false)); - expect(cachedForAccount('changed-account')).toEqual({ apiKey: false, client: false, tokenGrant: false }); - expect(cachedForAccount('other-account')).toEqual({ apiKey: true, client: true, tokenGrant: true }); - }); - - it('ignores a malformed message and keeps listening', async () => { - cacheUser('kept'); - cacheUser('next'); - - await publishElsewhere('not json'); - await publishElsewhere({ user: 42 }); - await publishElsewhere({ user: 'next' }); - - await vi.waitFor(() => expect(cachedFor('next').session).toBe(false)); - expect(cachedFor('kept')).toEqual({ session: true, memberships: true, tokenGrant: true }); - }); - - it('must not keep an entry cached while the listening connection was down via the missed messages', async () => { - // The listening connection's last statement is always its LISTEN, which also serves as its heartbeat. - await adminDb.execute( - sql`select pg_terminate_backend(pid) from pg_stat_activity where query = 'LISTEN auth_invalidate' and pid <> pg_backend_pid()`, - ); - // Cached in the gap: whatever invalidation it missed is gone, so the reconnect drops it. - cacheUser('in-gap'); - - await vi.waitFor(() => expect(cachedFor('in-gap').session).toBe(false), { timeout: 10_000, interval: 100 }); - - // Listening again. - cacheUser('after'); - await vi.waitFor(async () => { - await publishElsewhere({ user: 'after' }); - expect(cachedFor('after').session).toBe(false); - }); - }); - - it("must not keep a service account's cached key or client while the listening connection was down", async () => { - await adminDb.execute( - sql`select pg_terminate_backend(pid) from pg_stat_activity where query = 'LISTEN auth_invalidate' and pid <> pg_backend_pid()`, - ); - cacheServiceAccount('in-gap-account'); - - await vi.waitFor(() => expect(cachedForAccount('in-gap-account').apiKey).toBe(false), { - timeout: 10_000, - interval: 100, - }); - expect(cachedForAccount('in-gap-account')).toEqual({ apiKey: false, client: false, tokenGrant: false }); - }); -}); - -/** - * A connection can die with no socket error (a dropped route, a host that vanished): its LISTEN hears nothing and no - * event says so. Only the heartbeat can notice, and only when an unanswered heartbeat counts as a lost connection. - */ -describe('auth_invalidate listener on a connection that stops answering', () => { - const pool = baseDb.$client as Pool; - - it('must not keep an entry cached via a listening connection that silently stopped answering', async () => { - const connections: PoolClient[] = []; - const connect = pool.connect.bind(pool); - const connectSpy = vi.spyOn(pool, 'connect').mockImplementation(async () => { - const connection = await connect(); - connections.push(connection); - return connection; - }); - const stop = listenForAuthInvalidation({ heartbeatMs: 200, heartbeatTimeoutMs: 500 }); - onTestFinished(async () => { - await stop(); - connectSpy.mockRestore(); - }); - - cacheUser('probe-silent'); - await vi.waitFor(async () => { - await publishElsewhere({ user: 'probe-silent' }); - expect(cachedFor('probe-silent').session).toBe(false); - }); - - // From now on nothing the listening connection sends gets an answer, as when its packets stop arriving. - const silent = connections.at(-1); - if (!silent) throw new Error('The listener took no connection'); - vi.spyOn(silent, 'query').mockImplementation(() => new Promise(() => {})); - // Cached while nothing reaches this process: whatever invalidation it missed is gone, so the reconnect drops it. - cacheUser('while-silent'); - - await vi.waitFor(() => expect(cachedFor('while-silent').session).toBe(false), { timeout: 8000, interval: 100 }); - expect(connections.length).toBeGreaterThan(1); - - // Listening again, on a new connection. - cacheUser('after-silent'); - await vi.waitFor(async () => { - await publishElsewhere({ user: 'after-silent' }); - expect(cachedFor('after-silent').session).toBe(false); - }); - }); - - it('must not stay deaf via a first LISTEN that never gets an answer', async () => { - const connections: PoolClient[] = []; - const connect = pool.connect.bind(pool); - const connectSpy = vi.spyOn(pool, 'connect').mockImplementation(async () => { - const connection = await connect(); - // The first connection never answers, as when the database stalls right after accepting it. - if (connections.length === 0) vi.spyOn(connection, 'query').mockImplementation(() => new Promise(() => {})); - connections.push(connection); - return connection; - }); - const stop = listenForAuthInvalidation({ heartbeatMs: 200, heartbeatTimeoutMs: 500 }); - onTestFinished(async () => { - await stop(); - connectSpy.mockRestore(); - }); - - // Listening, on a second connection. - cacheUser('after-stalled-listen'); - await vi.waitFor( - async () => { - await publishElsewhere({ user: 'after-stalled-listen' }); - expect(cachedFor('after-stalled-listen').session).toBe(false); - }, - { timeout: 8000, interval: 100 }, - ); - expect(connections.length).toBeGreaterThan(1); - }); - - it('keeps TCP keepalive on the pooled connections it listens on', () => { - expect(pool.options).toMatchObject({ keepAlive: true }); - }); -}); - -describe('invalidateCache and endSessions publish to every process', () => { - const listener = new pg.Client({ connectionString: testDatabaseUrl }); - const received: string[] = []; - - beforeAll(async () => { - await listener.connect(); - listener.on('notification', (message) => { - if (message.channel === 'auth_invalidate' && message.payload) received.push(message.payload); - }); - await listener.query('LISTEN auth_invalidate'); - }); - - afterAll(async () => await listener.end()); - - it('drops the entry here and tells the other processes', async () => { - cacheUser('changed'); - cacheServiceAccount('account-e'); - const installedApp = userToken('member-f', 'grant-f', 'tenant-f', 'portfolio'); - cacheVerdict(installedApp); - - await invalidateCache.user(baseDb, 'changed'); - await invalidateCache.org(baseDb, 'tenant-c', 'org-4'); - await invalidateCache.tenant(baseDb, 'tenant-d'); - await invalidateCache.serviceAccount(baseDb, { id: 'account-e', tenantId: 'tenant-e', oauthClientId: null }); - // An installed app's account: its users' tokens in the tenant rest on the installation. - await invalidateCache.serviceAccount(baseDb, { id: 'install-f', tenantId: 'tenant-f', oauthClientId: 'portfolio' }); - - expect(cachedFor('changed')).toEqual({ session: false, memberships: false, tokenGrant: false }); - expect(cachedForAccount('account-e')).toEqual({ apiKey: false, client: false, tokenGrant: false }); - expect(getTokenGrantCache(installedApp)).toBeUndefined(); - await vi.waitFor(() => - expect(received.map((payload) => JSON.parse(payload))).toEqual( - expect.arrayContaining([ - { user: 'changed' }, - { org: { tenantId: 'tenant-c', orgId: 'org-4' } }, - { tenant: 'tenant-d' }, - { serviceAccount: { id: 'account-e', tenantId: 'tenant-e', clientId: null } }, - { serviceAccount: { id: 'install-f', tenantId: 'tenant-f', clientId: 'portfolio' } }, - ]), - ), - ); - }); - - it('must not tell the other processes about a change that rolled back, and tells them once when it commits', async () => { - const [rolledBack, committed] = [generateId(), generateId()]; - const account = (id: string) => ({ id, tenantId: 'tenant-g', oauthClientId: null }); - const heardOf = (id: string) => received.filter((payload) => payload.includes(id)); - - await baseDb - .transaction(async (tx) => { - await invalidateCache.serviceAccount(tx, account(rolledBack)); - throw new Error('roll back'); - }) - .catch(() => {}); - await baseDb.transaction((tx) => invalidateCache.serviceAccount(tx, account(committed))); - - await vi.waitFor(() => expect(heardOf(committed)).toHaveLength(1)); - expect(heardOf(rolledBack)).toEqual([]); - }); - - it("must not keep a user's system role in any process via the cache once CDC reports it changed", async () => { - // system_roles is written outside the API; the change arrives as a CDC event on the activity bus. - await import('#/modules/system/system-listeners'); - cacheUser('demoted'); - cacheUser('bystander-admin'); - - activityBus.emit({ - id: generateId(), - type: 'system_role.deleted', - action: 'delete', - resourceType: 'system_role', - entityType: null, - rowData: { userId: 'demoted', role: 'admin' }, - } as never); - - expect(cachedFor('demoted')).toEqual({ session: false, memberships: false, tokenGrant: false }); - expect(cachedFor('bystander-admin').session).toBe(true); - await vi.waitFor(() => expect(received).toContain(JSON.stringify({ user: 'demoted' }))); - }); - - it('announces an ending of sessions to the other processes only once it commits', async () => { - const [rolledBack, committed] = [generateId(), generateId()]; - const ending = (userId: string) => ({ userId, all: true as const, reason: 'user_deleted' as const, by: null }); - - await baseDb - .transaction(async (tx) => { - await endSessions({ var: { db: tx } }, ending(rolledBack)); - throw new Error('roll back'); - }) - .catch(() => {}); - await endSessions({ var: { db: baseDb } }, ending(committed)); - - await vi.waitFor(() => expect(received).toContain(JSON.stringify({ user: committed }))); - expect(received).not.toContain(JSON.stringify({ user: rolledBack })); - }); -}); diff --git a/backend/src/middlewares/guard/invalidation-listener.ts b/backend/src/middlewares/guard/invalidation-listener.ts deleted file mode 100644 index 7aea0bc3b..000000000 --- a/backend/src/middlewares/guard/invalidation-listener.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Notification, PoolClient } from 'pg'; -import { openDedicatedConnection } from '#/db/db'; -import { env } from '#/env'; -import type { HealthComponent } from '#/lib/health-helpers'; -import { log } from '#/utils/logger'; -import { withinTimeout } from '#/utils/within-timeout'; -import { authInvalidateChannel, clearCachedAuth, dropCachedAuth, parseAuthInvalidation } from './invalidate-cache'; - -const listenStatement = `LISTEN ${authInvalidateChannel}`; -const RETRY_MIN_MS = 1_000; -const RETRY_MAX_MS = 30_000; -/** A silently dropped connection hears nothing; repeating the LISTEN finds out within this. */ -const HEARTBEAT_MS = 60_000; -/** A heartbeat without an answer in this long means the connection is gone, whether or not its socket said so. */ -const HEARTBEAT_TIMEOUT_MS = 10_000; - -let stopListening: (() => Promise) | null = null; - -/** What the listener is doing; only `listening` hears the other processes, `connecting` covers every (re)connect. */ -type ListenerState = 'never_started' | 'connecting' | 'listening' | 'stopped'; -let state: ListenerState = 'never_started'; - -/** - * This process's listener as a health component: a process that hears no invalidations serves ended sessions and - * removed memberships from its caches, so only `listening` is healthy. Between connections it is degraded; before the - * first start or after stop it is unhealthy. - * @returns The component, with the state as the reason while not listening. - */ -export function authInvalidationHealth(): HealthComponent { - const status = state === 'listening' ? 'healthy' : state === 'connecting' ? 'degraded' : 'unhealthy'; - return { status, checkedVia: 'local', ...(status === 'healthy' ? {} : { reason: state }) }; -} - -interface ListenOptions { - /** How often the LISTEN is repeated to check the connection. */ - heartbeatMs?: number; - /** How long a heartbeat may go unanswered before the connection counts as lost. */ - heartbeatTimeoutMs?: number; -} - -/** - * LISTENs on `auth_invalidate` over one connection taken from the pool and drops what each message names from this - * process's guard caches, so a session ending or a membership change in one process reaches the api, mcp and oauth - * processes. Reconnects with backoff, and every (re)connect clears the guard caches: messages sent while no connection - * listened are gone. One listener per process, also when singleVM runs the three in one. - * - * @param options - The heartbeat timing; the defaults suit production. - * @returns Stops listening and closes the connection. - */ -export function listenForAuthInvalidation({ - heartbeatMs = HEARTBEAT_MS, - heartbeatTimeoutMs = HEARTBEAT_TIMEOUT_MS, -}: ListenOptions = {}): () => Promise { - if (stopListening) return stopListening; - if (env.NODB) return async () => {}; - - let client: PoolClient | null = null; - let stopped = false; - let retryDelay = RETRY_MIN_MS; - let retryTimer: ReturnType | null = null; - let heartbeat: ReturnType | null = null; - - const onNotification = (message: Notification) => { - if (message.channel !== authInvalidateChannel || !message.payload) return; - const invalidation = parseAuthInvalidation(message.payload); - if (invalidation) dropCachedAuth(invalidation); - else log.warn('Ignored a malformed auth invalidation', { payload: message.payload }); - }; - - /** Closes the connection for good: a LISTENing client never goes back to the pool. */ - const drop = (lost: PoolClient) => { - lost.off('notification', onNotification); - lost.off('error', onLost); - lost.off('end', onLost); - // A late socket error from the closing connection must not surface as an unhandled 'error' event. - lost.on('error', () => {}); - lost.release(true); - }; - - const scheduleConnect = () => { - if (stopped || retryTimer) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void connect(); - }, retryDelay); - retryTimer.unref(); - retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS); - }; - - function onLost(error?: Error) { - const lost = client; - client = null; - if (heartbeat) clearInterval(heartbeat); - heartbeat = null; - if (!lost) return; - state = 'connecting'; - drop(lost); - log.warn('Auth invalidation listener lost its connection, reconnecting', { error }); - scheduleConnect(); - } - - /** Repeats the LISTEN; a failure, or no answer within the timeout, loses the connection. */ - async function beat(listening: PoolClient) { - const failure = await withinTimeout(listening.query(listenStatement), heartbeatTimeoutMs, 'The heartbeat LISTEN'); - if (failure && client === listening) onLost(failure); - } - - async function connect() { - let next: PoolClient | undefined; - try { - next = await openDedicatedConnection(onLost); - next.on('notification', onNotification); - next.on('end', onLost); - // An unanswered LISTEN fails the connect too: nothing else would start the heartbeat or schedule a retry. - const failure = await withinTimeout(next.query(listenStatement), heartbeatTimeoutMs, 'The LISTEN'); - if (failure) throw failure; - if (stopped) return drop(next); - client = next; - state = 'listening'; - retryDelay = RETRY_MIN_MS; - // Any entry cached while nothing listened may have missed its invalidation. - clearCachedAuth(); - heartbeat = setInterval(() => { - if (client) void beat(client); - }, heartbeatMs); - heartbeat.unref(); - } catch (error) { - if (next && next !== client) drop(next); - log.warn('Auth invalidation listener failed to connect, retrying', { error }); - scheduleConnect(); - } - } - - state = 'connecting'; - void connect(); - - stopListening = async () => { - stopped = true; - state = 'stopped'; - stopListening = null; - if (retryTimer) clearTimeout(retryTimer); - if (heartbeat) clearInterval(heartbeat); - const current = client; - client = null; - if (current) drop(current); - }; - return stopListening; -} diff --git a/backend/src/middlewares/guard/membership-cache.ts b/backend/src/middlewares/guard/membership-cache.ts new file mode 100644 index 000000000..9f54d82db --- /dev/null +++ b/backend/src/middlewares/guard/membership-cache.ts @@ -0,0 +1,38 @@ +import { eq, getTableColumns } from 'drizzle-orm'; +import { baseDb } from '#/db/db'; +import { TTLCache } from '#/lib/ttl-cache'; +import { actorsTable } from '#/modules/actors/actors-db'; +import type { MembershipBaseModel } from '#/modules/memberships/helpers/select'; +import { membershipsTable } from '#/modules/memberships/memberships-db'; + +export type CachedMemberships = (MembershipBaseModel & { createdBy: string | null })[]; + +/** + * A user's memberships with the `actors.bindings_version` they were read at. An entry answers only to that version, + * which a trigger replaces on every membership write (`db/membership-rules.ts`), so nothing drops entries: the TTL + * bounds memory alone. + */ +const membershipCache = new TTLCache<{ version: string; memberships: CachedMemberships }>({ maxSize: 5000, defaultTtl: 30 * 60_000 }); + +/** + * The user's memberships at the bindings version the request read with its session or token. A miss reads the + * memberships with the current version in one statement and caches them under it, so a list is never stored under a + * version newer than itself. + * @param userId - The acting user. + * @param bindingsVersion - `actors.bindings_version` as the request read it. + * @returns The memberships, at least as current as the version. + */ +export const loadMemberships = async (userId: string, bindingsVersion: string): Promise => { + const cached = membershipCache.get(userId); + if (cached?.version === bindingsVersion) return cached.memberships; + + const rows = await baseDb + .select({ version: actorsTable.bindingsVersion, membership: getTableColumns(membershipsTable) }) + .from(actorsTable) + .leftJoin(membershipsTable, eq(membershipsTable.userId, actorsTable.id)) + .where(eq(actorsTable.id, userId)); + + const memberships = rows.flatMap(({ membership }) => (membership ? [membership] : [])); + if (rows[0]) membershipCache.set(userId, { version: rows[0].version, memberships }); + return memberships; +}; diff --git a/backend/src/middlewares/guard/no-impersonation-guard.ts b/backend/src/middlewares/guard/no-impersonation-guard.ts deleted file mode 100644 index 736987271..000000000 --- a/backend/src/middlewares/guard/no-impersonation-guard.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { AppError } from '#/core/error'; -import { xMiddleware } from '#/core/x-middleware'; -import type { SessionFacts } from '#/modules/auth/sessions-db'; - -/** - * Refuses an impersonation: the admin acts as the user, never on the account itself, its sessions or how it is - * protected. The one spelling of this answer, for the guard below and for `requireStepUp`. - * @throws AppError 403 `impersonation_forbidden`. - */ -export const refuseImpersonation = (session: SessionFacts): void => { - if (session.type === 'impersonation') throw new AppError(403, 'impersonation_forbidden', 'warn'); -}; - -/** After `userGuard`: the session must be the browser's own. Stepping up, revoking sessions, impersonating again. */ -export const noImpersonationGuard = xMiddleware( - { - functionName: 'noImpersonationGuard', - type: 'x-guard', - name: 'noImpersonation', - description: 'Refused while impersonating: the admin acts as the user, never on the account itself', - }, - async (ctx, next) => { - refuseImpersonation(ctx.var.session); - await next(); - }, -); diff --git a/backend/src/middlewares/guard/org-cache.ts b/backend/src/middlewares/guard/org-cache.ts index 2f314aaac..bf95cd912 100644 --- a/backend/src/middlewares/guard/org-cache.ts +++ b/backend/src/middlewares/guard/org-cache.ts @@ -3,10 +3,7 @@ import type { OrganizationModel } from '#/modules/organization/organization-db'; const cacheKey = (tenantId: string, orgId: string) => `${tenantId}:${orgId}`; -const cache = new TTLCache({ - maxSize: 5000, - defaultTtl: 60_000, -}); +const cache = new TTLCache({ maxSize: 5000, defaultTtl: 60_000 }); export const getOrgCache = (tenantId: string, orgId: string): OrganizationModel | undefined => { return cache.get(cacheKey(tenantId, orgId)); @@ -28,5 +25,3 @@ export const invalidateOrgCacheByTenant = (tenantId: string): number => { export const clearOrgCache = (): void => { cache.clear(); }; - -export const orgCacheStats = () => cache.stats; diff --git a/backend/src/middlewares/guard/org-guard.test.ts b/backend/src/middlewares/guard/org-guard.test.ts index 4c8102e40..b2364eefa 100644 --- a/backend/src/middlewares/guard/org-guard.test.ts +++ b/backend/src/middlewares/guard/org-guard.test.ts @@ -14,15 +14,7 @@ const TENANT_ID = 'tenant-1'; const ORG_ID = 'org-1'; const OTHER_ORG_ID = 'org-2'; -const orgRow = { - id: ORG_ID, - tenantId: TENANT_ID, - entityType: 'organization', - name: 'Org', - slug: 'org', - organizationFlags: {}, - setupConfig: {}, -}; +const orgRow = { id: ORG_ID, tenantId: TENANT_ID, entityType: 'organization', name: 'Org', slug: 'org', organizationFlags: {}, setupConfig: {} }; /** * Membership row as the guard sees it. `channelType` is widened past cella's own vocabulary on @@ -35,24 +27,13 @@ const membership = (channelType: string, organizationId: string) => const emptyDb = { select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }) }; -const mockCtx = (opts: { - memberships: unknown[]; - isSystemAdmin?: boolean; - organizationId?: string; - tenantId?: string; - db?: unknown; -}) => ({ +const mockCtx = (opts: { memberships: unknown[]; isSystemAdmin?: boolean; organizationId?: string; tenantId?: string; db?: unknown }) => ({ req: { param: () => opts.organizationId ?? ORG_ID }, var: { db: (opts.db ?? emptyDb) as never, memberships: opts.memberships, // The guard reads the actor's bindings; for a session those are the memberships. - actor: { - kind: 'user', - id: 'user-1', - bindings: opts.memberships, - scopes: null, - }, + actor: { kind: 'user', id: 'user-1', bindings: opts.memberships, scopes: null }, isSystemAdmin: opts.isSystemAdmin ?? false, tenantId: opts.tenantId ?? TENANT_ID, }, @@ -169,13 +150,7 @@ describe('orgGuard — organization lookup within the tenant', () => { }); expect((await runExpectingError(asMember)).status).toBe(404); - const asSystemAdmin = mockCtx({ - memberships: [], - isSystemAdmin: true, - organizationId: organization.id, - tenantId: otherTenantId, - db: baseDb, - }); + const asSystemAdmin = mockCtx({ memberships: [], isSystemAdmin: true, organizationId: organization.id, tenantId: otherTenantId, db: baseDb }); expect((await runExpectingError(asSystemAdmin)).status).toBe(404); }); diff --git a/backend/src/middlewares/guard/org-guard.ts b/backend/src/middlewares/guard/org-guard.ts index fa4202f74..9ae90b7c9 100644 --- a/backend/src/middlewares/guard/org-guard.ts +++ b/backend/src/middlewares/guard/org-guard.ts @@ -14,16 +14,10 @@ import { getOrgCache, setOrgCache } from './org-cache'; * the RLS transaction. */ export const orgGuard = xMiddleware( - { - functionName: 'orgGuard', - type: 'x-guard', - name: 'org', - description: 'Validates organization membership within tenant context', - }, + { functionName: 'orgGuard', type: 'x-guard', name: 'org', description: 'Validates organization membership within tenant context' }, async (ctx, next) => { const organizationId = ctx.req.param('organizationId'); - if (!organizationId) - throw new AppError(400, 'invalid_request', 'error', { meta: { reason: 'Missing organizationId parameter' } }); + if (!organizationId) throw new AppError(400, 'invalid_request', 'error', { meta: { reason: 'Missing organizationId parameter' } }); const db = ctx.var.db; // Role bindings of whoever is acting: a user's memberships, or a service account's stored bindings. @@ -61,15 +55,11 @@ export const orgGuard = xMiddleware( // Deeper channel rows carry organizationId as an ancestor column, so a sub-channel member is // in the org. This guard only rejects callers with no foothold at all; the permission engine // does the fine-grained work. - const orgMembership = - memberships.find((m) => m.organizationId === organization.id && m.channelType === 'organization') || null; + const orgMembership = memberships.find((m) => m.organizationId === organization.id && m.channelType === 'organization') || null; const isInOrganization = orgMembership !== null || memberships.some((m) => m.organizationId === organization.id); if (!isSystemAdmin && !isInOrganization) throw missing(); // A service account's binding is not a membership row; the organization-level membership is a user's only. - const orgWithMembership = { - ...organization, - membership: orgMembership && isMembershipRow(orgMembership) ? orgMembership : null, - }; + const orgWithMembership = { ...organization, membership: orgMembership && isMembershipRow(orgMembership) ? orgMembership : null }; // membership is the organization-level row: null for system admins, and for members who hold // rows only in channels below the organization diff --git a/backend/src/middlewares/guard/service-guard.ts b/backend/src/middlewares/guard/service-guard.ts index 34f0a3039..8ea3acc19 100644 --- a/backend/src/middlewares/guard/service-guard.ts +++ b/backend/src/middlewares/guard/service-guard.ts @@ -5,25 +5,23 @@ import { AppError } from '#/core/error'; import { xMiddleware } from '#/core/x-middleware'; import { baseDb } from '#/db/db'; import { getApiKeyCache, setApiKeyCache } from '#/middlewares/guard/api-key-cache'; -import { getMembershipCache, setMembershipCache } from '#/middlewares/guard/auth-cache'; +import { loadMemberships } from '#/middlewares/guard/membership-cache'; import { getTokenGrantCache, setTokenGrantCache, type TokenGrantEntry } from '#/middlewares/guard/token-grant-cache'; import { serviceBurstLimiter } from '#/middlewares/rate-limiter/limiters'; -import { membershipsTable } from '#/modules/memberships/memberships-db'; import { grantRefusal } from '#/modules/oauth-server/grant-policy'; -import { findConsentOfUser } from '#/modules/oauth-server/oauth-server-queries'; +import { findLiveGrantBindings } from '#/modules/oauth-server/oauth-server-queries'; import { resourceMetadataUrl } from '#/modules/oauth-server/resources'; import { bearerJwtFrom, type VerifiedAccessToken, verifyAccessToken } from '#/modules/oauth-server/verify-access-token'; import { apiKeyFrom, apiKeyRefusal, parseApiKey } from '#/modules/service-accounts/helpers/api-key'; import { findApiKeyWithAccount } from '#/modules/service-accounts/service-accounts-queries'; -import { usersTable } from '#/modules/user/user-db'; +import { type UserModel, usersTable } from '#/modules/user/user-db'; export const unauthorized = (reason: string) => new AppError(401, 'unauthorized', 'warn', { meta: { reason } }); /** The route's tenant and organization ids as the URL carries them; every machine guard binds the key or token to them. */ export function routeTarget(ctx: Context): { tenantId: string; organizationId?: string } { const tenantId = ctx.req.param('tenantId')?.toLowerCase(); - if (!tenantId) - throw new AppError(400, 'invalid_request', 'error', { meta: { reason: 'Missing tenantId parameter' } }); + if (!tenantId) throw new AppError(400, 'invalid_request', 'error', { meta: { reason: 'Missing tenantId parameter' } }); return { tenantId, organizationId: ctx.req.param('organizationId') }; } @@ -33,28 +31,18 @@ export function routeTarget(ctx: Context): { tenantId: string; organization * grant, and only while the grant policy holds the grant or API key it names. Tokens and keys never carry system * admin: that stays with a session and its IP allow-list. */ -export async function setActorFromToken( - ctx: Context, - jwt: string, - scope: { tenantId: string; organizationId?: string }, -): Promise { +export async function setActorFromToken(ctx: Context, jwt: string, scope: { tenantId: string; organizationId?: string }): Promise { const token = await verifyAccessToken(jwt, scope); - const grant = await resolveTokenGrant(token); - if (grant.refusal !== null) throw unauthorized(grant.refusal); - - if (grant.kind === 'user') { - const { user } = grant; - let memberships = getMembershipCache(user.id); - if (!memberships) { - memberships = await baseDb.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); - setMembershipCache(user.id, memberships); - } + + if (token.kind === 'user') { + const { user, bindingsVersion } = await resolveUserToken(token); + const memberships = await loadMemberships(user.id, bindingsVersion); ctx.set('user', user); ctx.set('userId', user.id); ctx.set('memberships', memberships); ctx.set('actor', { kind: 'user', id: user.id, bindings: memberships, scopes: token.scopes }); } else { - const { account } = grant; + const account = await resolveServiceToken(token); ctx.set('actor', { kind: 'service', id: account.id, @@ -67,40 +55,44 @@ export async function setActorFromToken( ctx.set('db', baseDb); } +type UserToken = Extract; + /** - * The grant policy's verdict on the grant (per tenant) or API key a token names, cached with the actor's row. A key - * expires by the clock alone, unannounced, so its rule runs at every use, on a cached key as on a fresh one. + * A person's token: the grant it names and the user's bindings version are read at every use, so a revoked grant + * stops the token and a membership change narrows it at the next request in every process. The grant policy's verdict + * with the user row is cached per grant, tenant and bindings version (`token-grant-cache.ts`). */ -async function resolveTokenGrant(token: VerifiedAccessToken): Promise { - const entry = getTokenGrantCache(token) ?? (await loadTokenGrant(token)); - if (entry.refusal !== null || entry.kind === 'user') return entry; - const refusal = apiKeyRefusal(entry.apiKey, entry.account); - return refusal ? { refusal } : entry; +async function resolveUserToken(token: UserToken): Promise<{ user: UserModel; bindingsVersion: string }> { + const live = await findLiveGrantBindings({ var: { db: baseDb } }, { grantId: token.grantId, userId: token.actorId }); + if (!live) throw unauthorized('grant_revoked'); + + const { bindingsVersion } = live; + const entry = getTokenGrantCache(token, bindingsVersion) ?? (await loadTokenGrant(token, bindingsVersion)); + if (entry.refusal !== null) throw unauthorized(entry.refusal); + return { user: entry.user, bindingsVersion }; } -/** What the database holds on the token's grant or key, cached for the token's next use. */ -async function loadTokenGrant(token: VerifiedAccessToken): Promise { - let entry: TokenGrantEntry; - if (token.kind === 'user') { - // A revoked grant, or one a replayed code or refresh token revoked, is deleted: its tokens stop with it. - const grant = await findConsentOfUser({ var: { db: baseDb } }, { grantId: token.grantId, userId: token.actorId }); - const refusal = grant - ? await grantRefusal({ userId: token.actorId, clientId: token.clientId, tenantId: token.tenantId }) - : 'grant_revoked'; - const [user] = refusal ? [] : await baseDb.select().from(usersTable).where(eq(usersTable.id, token.actorId)); - entry = user ? { refusal: null, kind: 'user', user } : { refusal: refusal ?? 'unknown_user' }; - } else { - // The key the token was minted with, which must belong to the token's account. - const found = await findApiKeyWithAccount( - { var: { db: baseDb } }, - { key: { id: token.keyId }, actorId: token.actorId }, - ); - entry = found ? { refusal: null, kind: 'service', ...found } : { refusal: 'invalid_api_key' }; - } - setTokenGrantCache(token, entry); +/** The grant policy's verdict on a live grant, with the user row, cached for the token's next uses. */ +async function loadTokenGrant(token: UserToken, bindingsVersion: string): Promise { + const refusal = await grantRefusal({ userId: token.actorId, clientId: token.clientId, tenantId: token.tenantId }); + const [user] = refusal ? [] : await baseDb.select().from(usersTable).where(eq(usersTable.id, token.actorId)); + const entry: TokenGrantEntry = user ? { refusal: null, user } : { refusal: refusal ?? 'unknown_user' }; + setTokenGrantCache(token, bindingsVersion, entry); return entry; } +/** + * A service account's token: the key it was minted with and its account, read in one statement at every use, so a + * revoked or expired key or a disabled account stops it at the next request in every process. + */ +async function resolveServiceToken(token: Extract) { + const found = await findApiKeyWithAccount({ var: { db: baseDb } }, { key: { id: token.keyId }, actorId: token.actorId }); + if (!found) throw unauthorized('invalid_api_key'); + const refusal = apiKeyRefusal(found.apiKey, found.account); + if (refusal) throw unauthorized(refusal); + return found.account; +} + /** The key and its account in one read, cached by hash; a revoke, roll, or disable invalidates the account's keys. */ async function resolveApiKey(hash: string) { const cached = getApiKeyCache(hash); @@ -122,8 +114,7 @@ export const serviceGuard = xMiddleware( type: 'x-guard', security: [{ apiKey: [] }, { oauth2: [] }], name: 'service', - description: - 'Requires a secret API key or an access token and sets the service account or the consenting user as the actor', + description: 'Requires a secret API key or an access token and sets the service account or the consenting user as the actor', }, async (ctx, next) => { const target = routeTarget(ctx); @@ -136,10 +127,8 @@ export const serviceGuard = xMiddleware( const raw = apiKeyFrom(ctx); if (!raw) { // RFC 9728: the challenge names where the API face publishes its metadata. - ctx.header( - 'WWW-Authenticate', - `Bearer resource_metadata="${resourceMetadataUrl({ face: 'api', tenantId: target.tenantId })}"`, - ); + const metadata = resourceMetadataUrl({ face: 'api', tenantId: target.tenantId }); + ctx.header('WWW-Authenticate', `Bearer resource_metadata="${metadata}"`); throw unauthorized('missing_api_key'); } diff --git a/backend/src/middlewares/guard/session-cache.ts b/backend/src/middlewares/guard/session-cache.ts new file mode 100644 index 000000000..ffe94d007 --- /dev/null +++ b/backend/src/middlewares/guard/session-cache.ts @@ -0,0 +1,42 @@ +import { TTLCache } from '#/lib/ttl-cache'; +import type { ResolvedSession } from '#/modules/auth/general/helpers/session'; + +/** + * Sessions by the hash of their token, for 10 seconds, so a page's burst of requests reads its session once. An entry + * carries the user's system role and bindings version, so a process drops a user's entries as soon as it learns of a + * change: the writer through `invalidateCache.user` and `revokeSessions`, the API process through CDC + * (`modules/auth/general/session-listeners.ts`). Any other process sees the change within the 10 seconds. + */ +const sessionCache = new TTLCache({ + maxSize: 5000, + defaultTtl: 10_000, + onDispose: (secretHash, entry) => { + const hashes = userIndex.get(entry.user.id); + hashes?.delete(secretHash); + if (hashes?.size === 0) userIndex.delete(entry.user.id); + }, +}); + +/** The token hashes cached per user, so a change to the user drops all of them. */ +const userIndex = new Map>(); + +export const getCachedSession = (secretHash: string): ResolvedSession | undefined => sessionCache.get(secretHash); + +export const setCachedSession = (secretHash: string, entry: ResolvedSession): void => { + sessionCache.set(secretHash, entry); + const hashes = userIndex.get(entry.user.id) ?? new Set(); + hashes.add(secretHash); + userIndex.set(entry.user.id, hashes); +}; + +/** After a change to the user's sessions, row, memberships or system role. */ +export const dropCachedSessions = (userId: string): void => { + for (const secretHash of userIndex.get(userId) ?? []) sessionCache.delete(secretHash); + userIndex.delete(userId); +}; + +/** Drops every entry: what a test does to stand in for the TTL passing. */ +export const clearSessionCache = (): void => { + sessionCache.clear(); + userIndex.clear(); +}; diff --git a/backend/src/middlewares/guard/sys-admin-guard.ts b/backend/src/middlewares/guard/sys-admin-guard.ts index d2d52b898..ea3789cad 100644 --- a/backend/src/middlewares/guard/sys-admin-guard.ts +++ b/backend/src/middlewares/guard/sys-admin-guard.ts @@ -1,28 +1,30 @@ import type { MiddlewareHandler } from 'hono'; import { every } from 'hono/combine'; import { ipRestriction } from 'hono/ip-restriction'; -import { appConfig } from 'shared'; import { scrubUrl } from 'shared/utils/scrub-url'; import { AppError } from '#/core/error'; import { setMiddlewareExtension } from '#/core/x-middleware'; -import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { sendSecurityInboxEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { refuseImpersonation } from '#/modules/auth/step-up/helpers/step-up'; import { getIp } from '#/utils/get-ip'; import { env } from '../../env'; const allowList = env.SYSTEM_ADMIN_IP_ALLOWLIST === 'none' ? [] : env.SYSTEM_ADMIN_IP_ALLOWLIST.split(','); -/** Only users holding the 'admin' system role proceed; anyone else triggers a security notification. */ +/** + * Only users holding the 'admin' system role proceed; anyone else triggers a security notification. An impersonation + * is refused first: system administration is done as oneself, and the role check would judge the impersonated user + * and raise an alert about the admin's own request. + */ const sysAdminCheck: MiddlewareHandler = async (ctx, next) => { const user = ctx.var.user; const isSystemAdmin = ctx.var.isSystemAdmin; + refuseImpersonation(ctx.var.session); + if (!isSystemAdmin) { const ip = getIp(ctx) ?? 'unknown'; - sendAccountSecurityEmail({ email: appConfig.securityEmail, name: 'Security' }, 'sysadmin-fail', { - ip, - route: scrubUrl(ctx.req.path), - timestamp: new Date().toISOString(), - }); + sendSecurityInboxEmail('sysadmin-fail', { ip, route: scrubUrl(ctx.req.path), timestamp: new Date().toISOString() }); throw new AppError(403, 'no_sysadmin', 'warn', { meta: { user: user.id } }); } @@ -39,11 +41,7 @@ const combinedMiddleware: MiddlewareHandler = every( { allowList }, async (remote) => { const ip = remote.addr ?? 'unknown'; - sendAccountSecurityEmail({ email: appConfig.securityEmail, name: 'Security' }, 'sysadmin-fail', { - ip, - route: 'ip-restricted', - timestamp: new Date().toISOString(), - }); + sendSecurityInboxEmail('sysadmin-fail', { ip, route: 'ip-restricted', timestamp: new Date().toISOString() }); throw new AppError(403, 'forbidden', 'warn'); }, ), @@ -53,5 +51,5 @@ export const sysAdminGuard = setMiddlewareExtension(combinedMiddleware, { functionName: 'sysAdminGuard', type: 'x-guard', name: 'sysAdmin', - description: 'Requires system admin + IP whitelist', + description: 'Requires system admin + IP whitelist, never an impersonation', }); diff --git a/backend/src/middlewares/guard/tenant-cache.ts b/backend/src/middlewares/guard/tenant-cache.ts index bd77a2fae..a56f13566 100644 --- a/backend/src/middlewares/guard/tenant-cache.ts +++ b/backend/src/middlewares/guard/tenant-cache.ts @@ -1,10 +1,7 @@ import { TTLCache } from '#/lib/ttl-cache'; import type { TenantModel } from '#/modules/tenants/tenants-db'; -const cache = new TTLCache({ - maxSize: 1000, - defaultTtl: 60_000, -}); +const cache = new TTLCache({ maxSize: 1000, defaultTtl: 60_000 }); /** Also what a test seeds so the guard needs no database. */ export const getTenantCache = (tenantId: string): TenantModel | undefined => cache.get(tenantId); @@ -20,5 +17,3 @@ export const invalidateTenantCache = (tenantId: string): void => { export const clearTenantCache = (): void => { cache.clear(); }; - -export const tenantCacheStats = () => cache.stats; diff --git a/backend/src/middlewares/guard/tenant-guard.test.ts b/backend/src/middlewares/guard/tenant-guard.test.ts index ab11d2c9e..f4b9629ef 100644 --- a/backend/src/middlewares/guard/tenant-guard.test.ts +++ b/backend/src/middlewares/guard/tenant-guard.test.ts @@ -14,14 +14,7 @@ const OTHER_TENANT_ID = 'tenant2'; const tenantRow = (status = 'active') => ({ id: TENANT_ID, status, createdBy: 'founder', restrictions: {} }) as never; const membership = (tenantId: string) => - ({ - tenantId, - channelType: 'organization', - channelId: 'org-1', - organizationId: 'org-1', - role: 'member', - userId: 'u1', - }) as never; + ({ tenantId, channelType: 'organization', channelId: 'org-1', organizationId: 'org-1', role: 'member', userId: 'u1' }) as never; type Actor = { kind: 'user' | 'service'; id: string; bindings: unknown[]; scopes: null; tenantId?: string }; @@ -103,9 +96,7 @@ describe('tenantGuard', () => { expect(member.status).toBe(403); expect(member.meta).toEqual({ resource: 'tenant', tenantStatus: 'suspended' }); - const outsider = await runExpectingError( - mockCtx({ actor: user([membership(OTHER_TENANT_ID)]), tenantId: TENANT_ID }), - ); + const outsider = await runExpectingError(mockCtx({ actor: user([membership(OTHER_TENANT_ID)]), tenantId: TENANT_ID })); expect(outsider.status).toBe(403); expect(outsider.meta).toEqual({ resource: 'tenant' }); diff --git a/backend/src/middlewares/guard/tenant-guard.ts b/backend/src/middlewares/guard/tenant-guard.ts index a58bae08b..7e7b805c9 100644 --- a/backend/src/middlewares/guard/tenant-guard.ts +++ b/backend/src/middlewares/guard/tenant-guard.ts @@ -42,8 +42,7 @@ export const tenantGuard = xMiddleware( const tenantId = rawTenantId.toLowerCase(); const actor = ctx.var.actor; - if (!actor) - throw new AppError(401, 'unauthorized', 'warn', { message: 'tenantGuard requires userGuard or serviceGuard' }); + if (!actor) throw new AppError(401, 'unauthorized', 'warn', { message: 'tenantGuard requires userGuard or serviceGuard' }); // A service actor's tenant comes from its key, never from the URL: the two must agree, checked before any lookup // so a key learns nothing about other tenants. diff --git a/backend/src/middlewares/guard/token-grant-cache.ts b/backend/src/middlewares/guard/token-grant-cache.ts index f506ef17f..2b8fcbe77 100644 --- a/backend/src/middlewares/guard/token-grant-cache.ts +++ b/backend/src/middlewares/guard/token-grant-cache.ts @@ -1,51 +1,44 @@ import { TTLCache } from '#/lib/ttl-cache'; import type { UserGrantRefusal } from '#/modules/oauth-server/grant-policy'; import type { VerifiedAccessToken } from '#/modules/oauth-server/verify-access-token'; -import type { ApiKeyModel } from '#/modules/service-accounts/api-keys-db'; -import type { ApiKeyRefusal } from '#/modules/service-accounts/helpers/api-key'; -import type { ServiceAccountModel } from '#/modules/service-accounts/service-accounts-db'; import type { UserModel } from '#/modules/user/user-db'; +/** The grant policy's answer on a person's live grant in one tenant, with the user row the token's actor is built from. */ +export type TokenGrantEntry = { refusal: UserGrantRefusal } | { refusal: null; user: UserModel }; + /** - * The grant policy's answer on an access token's grant or API key, with the row the token's actor is built from. A - * service token's entry holds its key and account: `apiKeyRefusal` answers at every use, since a key expires - * unannounced. + * A verdict with the tenant and client its token names, so a change to either finds every verdict it affects, and the + * bindings version it was reached at, since the policy asks whether the user is a member of the tenant. */ -export type TokenGrantEntry = - | { refusal: UserGrantRefusal | ApiKeyRefusal | 'grant_revoked' } - | { refusal: null; kind: 'user'; user: UserModel } - | { - refusal: null; - kind: 'service'; - account: ServiceAccountModel; - apiKey: Pick; - }; - -/** A verdict with the tenant and client its token names, so a change to either finds every verdict it affects. */ interface CachedVerdict { entry: TokenGrantEntry; tenantId: string; clientId: string; + bindingsVersion: string; } +type UserToken = Extract; + /** - * Keyed `::` or `:`, so every verdict about one actor, or on one grant, - * drops by prefix. Whatever ends a grant or key, or changes the user, the account, an installation or a tenant's - * policy, drops the verdicts it affects in every process through `auth_invalidate`. + * Keyed `::`, so every verdict about one user, or on one grant, drops by prefix. The guard + * reads the grant and the bindings version at every use; the rest of what this caches (the user row, an installed app, + * the tenant's policy) is dropped here at once by `invalidateCache` and holds for at most 15 seconds in other processes. */ -const tokenGrantCache = new TTLCache({ maxSize: 5000, defaultTtl: 30_000 }); +const tokenGrantCache = new TTLCache({ maxSize: 5000, defaultTtl: 15_000 }); -const keyOf = (token: VerifiedAccessToken) => - token.kind === 'user' ? `${token.actorId}:${token.grantId}:${token.tenantId}` : `${token.actorId}:${token.keyId}`; +const keyOf = (token: UserToken) => `${token.actorId}:${token.grantId}:${token.tenantId}`; -export const getTokenGrantCache = (token: VerifiedAccessToken): TokenGrantEntry | undefined => - tokenGrantCache.get(keyOf(token))?.entry; +/** The cached verdict when it was reached at the bindings version the request read. */ +export const getTokenGrantCache = (token: UserToken, bindingsVersion: string): TokenGrantEntry | undefined => { + const cached = tokenGrantCache.get(keyOf(token)); + return cached?.bindingsVersion === bindingsVersion ? cached.entry : undefined; +}; -export const setTokenGrantCache = (token: VerifiedAccessToken, entry: TokenGrantEntry): void => { - tokenGrantCache.set(keyOf(token), { entry, tenantId: token.tenantId, clientId: token.clientId }); +export const setTokenGrantCache = (token: UserToken, bindingsVersion: string, entry: TokenGrantEntry): void => { + tokenGrantCache.set(keyOf(token), { entry, tenantId: token.tenantId, clientId: token.clientId, bindingsVersion }); }; -/** After a change to an actor's user row, memberships, account, keys or grants. */ +/** After a change to a user's row. */ export const invalidateTokenGrantsByActor = (actorId: string): void => { tokenGrantCache.invalidateByPrefix(`${actorId}:`); }; @@ -57,10 +50,5 @@ export const invalidateTokenGrant = (accountId: string, grantId: string): void = /** After a tenant's policy changes, or with `clientId` one installation in it: the verdicts on tokens naming it. */ export const invalidateTokenGrantsByTenant = (tenantId: string, clientId?: string): void => { - tokenGrantCache.invalidateWhere( - (verdict) => verdict.tenantId === tenantId && (clientId === undefined || verdict.clientId === clientId), - ); + tokenGrantCache.invalidateWhere((verdict) => verdict.tenantId === tenantId && (clientId === undefined || verdict.clientId === clientId)); }; - -/** Drops every verdict: a process whose invalidation channel reconnects may have missed messages. */ -export const clearTokenGrantCache = (): void => tokenGrantCache.clear(); diff --git a/backend/src/middlewares/guard/token-guard.test.ts b/backend/src/middlewares/guard/token-guard.test.ts index 3a66d34ac..bb67a4ec4 100644 --- a/backend/src/middlewares/guard/token-guard.test.ts +++ b/backend/src/middlewares/guard/token-guard.test.ts @@ -43,13 +43,7 @@ interface Forgery { } /** A token signed under the server's `kid`, well-formed for this route unless a forgery says otherwise. */ -const signed = async ({ - key, - alg = 'RS256', - issuer = appConfig.oauthUrl, - audience = resource, - expiresAt = '1h', -}: Forgery = {}) => { +const signed = async ({ key, alg = 'RS256', issuer = appConfig.oauthUrl, audience = resource, expiresAt = '1h' }: Forgery = {}) => { const [signingJwk] = (await loadSigningJwks()).keys; return new SignJWT(claims) .setProtectedHeader({ alg, kid: signingJwk.kid, typ: 'at+jwt' }) diff --git a/backend/src/middlewares/guard/token-guard.ts b/backend/src/middlewares/guard/token-guard.ts index 91130e1e3..7aaefca08 100644 --- a/backend/src/middlewares/guard/token-guard.ts +++ b/backend/src/middlewares/guard/token-guard.ts @@ -1,13 +1,14 @@ import { AppError } from '#/core/error'; import { xMiddleware } from '#/core/x-middleware'; import { routeTarget, setActorFromToken, unauthorized } from '#/middlewares/guard/service-guard'; -import { serviceBurstLimiter } from '#/middlewares/rate-limiter/limiters'; import { resourceMetadataUrl } from '#/modules/oauth-server/resources'; import { bearerJwtFrom } from '#/modules/oauth-server/verify-access-token'; /** * The MCP face accepts only tokens from the app's own authorization server (D12): no sessions, no API keys. A missing or - * invalid token answers with the RFC 9728 challenge, which is how an MCP client discovers where to authorize. + * invalid token answers with the RFC 9728 challenge, which is how an MCP client discovers where to authorize. It charges + * no burst budget: a tool call counts once, at the route it runs (`serviceGuard`), and the endpoint's own requests + * count against `mcpRequestLimiter`. */ export const tokenGuard = xMiddleware( { @@ -15,18 +16,12 @@ export const tokenGuard = xMiddleware( type: 'x-guard', security: [{ oauth2: [] }], name: 'token', - description: - 'Requires an access token from the authorization server and sets the consenting user or service account as the actor', + description: 'Requires an access token from the authorization server and sets the consenting user or service account as the actor', }, async (ctx, next) => { const target = routeTarget(ctx); - if (!target.organizationId) - throw new AppError(400, 'invalid_request', 'error', { meta: { reason: 'Missing organizationId parameter' } }); - const metadata = resourceMetadataUrl({ - face: 'mcp', - tenantId: target.tenantId, - organizationId: target.organizationId, - }); + if (!target.organizationId) throw new AppError(400, 'invalid_request', 'error', { meta: { reason: 'Missing organizationId parameter' } }); + const metadata = resourceMetadataUrl({ face: 'mcp', tenantId: target.tenantId, organizationId: target.organizationId }); const jwt = bearerJwtFrom(ctx); if (!jwt) { @@ -37,12 +32,9 @@ export const tokenGuard = xMiddleware( await setActorFromToken(ctx, jwt, target); } catch (error) { const reason = error instanceof AppError ? String(error.meta?.reason ?? 'invalid_token') : 'invalid_token'; - ctx.header( - 'WWW-Authenticate', - `Bearer error="invalid_token", error_description="${reason}", resource_metadata="${metadata}"`, - ); + ctx.header('WWW-Authenticate', `Bearer error="invalid_token", error_description="${reason}", resource_metadata="${metadata}"`); throw error; } - return serviceBurstLimiter(ctx, next); + await next(); }, ); diff --git a/backend/src/middlewares/guard/user-guard.ts b/backend/src/middlewares/guard/user-guard.ts index 59c352dc1..8bee98736 100644 --- a/backend/src/middlewares/guard/user-guard.ts +++ b/backend/src/middlewares/guard/user-guard.ts @@ -1,15 +1,14 @@ -import { eq } from 'drizzle-orm'; import { xMiddleware } from '#/core/x-middleware'; import { baseDb } from '#/db/db'; import { resolveSession } from '#/modules/auth/general/helpers/session'; -import { membershipsTable } from '#/modules/memberships/memberships-db'; import { isSystemAccessAllowed } from '#/utils/system-access'; import { updateLastSeenAt } from '../update-last-seen'; -import { getMembershipCache, setMembershipCache } from './auth-cache'; +import { loadMemberships } from './membership-cache'; /** * Authenticates the session (an impersonation only on top of its admin's session) and sets user, session facts, - * memberships and base db context from short TTL caches. + * memberships and base db context: the session is read per request, the memberships come from the cache at its + * bindings version. */ export const userGuard = xMiddleware( { @@ -21,7 +20,7 @@ export const userGuard = xMiddleware( }, async (ctx, next) => { // A refused cookie is deleted, so the browser stops presenting it. - const { session, user, hasSystemRole } = await resolveSession(ctx, { clearOnError: true }); + const { session, user, hasSystemRole, bindingsVersion } = await resolveSession(ctx, { clearOnError: true }); ctx.set('user', user); ctx.set('userId', user.id); @@ -30,12 +29,7 @@ export const userGuard = xMiddleware( ctx.set('isSystemAdmin', hasSystemRole && isSystemAccessAllowed(ctx)); ctx.set('db', baseDb); - // Memberships cached separately with longer TTL (keyed by userId) - let memberships = getMembershipCache(user.id); - if (!memberships) { - memberships = await baseDb.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id)); - setMembershipCache(user.id, memberships); - } + const memberships = await loadMemberships(user.id, bindingsVersion); ctx.set('memberships', memberships); ctx.set('actor', { kind: 'user', id: user.id, bindings: memberships, scopes: null }); diff --git a/backend/src/middlewares/logger.ts b/backend/src/middlewares/logger.ts index 782a9f906..05afe4e4c 100644 --- a/backend/src/middlewares/logger.ts +++ b/backend/src/middlewares/logger.ts @@ -1,21 +1,14 @@ import type { MiddlewareHandler } from 'hono'; -import { requestId } from 'hono/request-id'; import { appConfig } from 'shared'; import { requestLogger } from '#/lib/pino'; import { isBenchTraffic } from '#/utils/logger'; -// Instantiate requestId middleware once at module scope to reuse it across requests. -const requestIdMiddleware = requestId(); - -/** Logs requests with timing, status, and user id, correlated by Hono's requestId. pino-pretty formats in dev. */ +/** Logs requests with timing, status, user id and the request id set before it. pino-pretty formats in dev. */ export const loggerMiddleware: MiddlewareHandler = async (ctx, next) => { - await requestIdMiddleware(ctx, async () => {}); - const start = Date.now(); const { url, method } = ctx.req; // The logger's `url` serializer scrubs tokens out of the path and query. const path = url.replace(appConfig.backendUrl, ''); - const reqId = ctx.get('requestId'); await next(); @@ -26,7 +19,7 @@ export const loggerMiddleware: MiddlewareHandler = async (ctx, next) => { // Suppress bench traffic logs in development (only log errors) if (isBenchTraffic(userId, ctx.get('tenantId')) && status < 500) return; - const logData = { requestId: reqId, method, url: path, status, responseTime, userId }; + const logData = { requestId: ctx.get('requestId'), method, url: path, status, responseTime, userId }; if (status >= 500) requestLogger.error(logData); else if (status >= 400) requestLogger.warn(logData); diff --git a/backend/src/middlewares/product-cache/app-product-cache.ts b/backend/src/middlewares/product-cache/app-product-cache.ts index 3746fd660..90b1b905c 100644 --- a/backend/src/middlewares/product-cache/app-product-cache.ts +++ b/backend/src/middlewares/product-cache/app-product-cache.ts @@ -4,10 +4,7 @@ import { log } from '#/utils/logger'; const cacheTtl = 10 * 60 * 1000; -const cacheConfig = { - maxSize: 5000, - defaultTtl: cacheTtl, -}; +const cacheConfig = { maxSize: 5000, defaultTtl: cacheTtl }; /** Enriched entity response, keyed by entity. */ type CacheValue = Record; diff --git a/backend/src/middlewares/product-cache/cache-invalidation.ts b/backend/src/middlewares/product-cache/cache-invalidation.ts index 0961c9673..3c20e3e7f 100644 --- a/backend/src/middlewares/product-cache/cache-invalidation.ts +++ b/backend/src/middlewares/product-cache/cache-invalidation.ts @@ -16,11 +16,7 @@ function handleActivityEvent(event: ActivityEvent): void { const invalidated = productCache.invalidateProduct(entityType, subjectId); if (invalidated) { - log.debug('Entity cache invalidated', { - entityType, - subjectId, - action, - }); + log.debug('Entity cache invalidated', { entityType, subjectId, action }); } } diff --git a/backend/src/middlewares/product-cache/presets.test.ts b/backend/src/middlewares/product-cache/presets.test.ts index 3d9382747..07ed06beb 100644 --- a/backend/src/middlewares/product-cache/presets.test.ts +++ b/backend/src/middlewares/product-cache/presets.test.ts @@ -4,19 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const productCacheGet = vi.fn(); const productCacheSet = vi.fn(); vi.mock('./app-product-cache', () => ({ - productCache: { - get: (...a: unknown[]) => productCacheGet(...a), - set: (...a: unknown[]) => productCacheSet(...a), - }, + productCache: { get: (...a: unknown[]) => productCacheGet(...a), set: (...a: unknown[]) => productCacheSet(...a) }, })); const checkAccess = vi.fn(); vi.mock('#/permissions', () => ({ checkAccess: (...a: unknown[]) => checkAccess(...a) })); const accessFrom = vi.fn((): Record => ({})); vi.mock('#/permissions/access', () => ({ accessFrom: () => accessFrom() })); const buildSubjectFromEntity = vi.fn((..._a: unknown[]) => ({})); -vi.mock('#/permissions/build-subject', () => ({ - buildSubjectFromEntity: (...a: unknown[]) => buildSubjectFromEntity(...a), -})); +vi.mock('#/permissions/build-subject', () => ({ buildSubjectFromEntity: (...a: unknown[]) => buildSubjectFromEntity(...a) })); const { productCache } = await import('./presets'); diff --git a/backend/src/middlewares/product-cache/presets.ts b/backend/src/middlewares/product-cache/presets.ts index 950c1a1f8..360cfa84c 100644 --- a/backend/src/middlewares/product-cache/presets.ts +++ b/backend/src/middlewares/product-cache/presets.ts @@ -14,12 +14,7 @@ import { productCache as productCacheStore } from './app-product-cache'; */ export const productCache = (entityType: ProductEntityType): MiddlewareHandler => xMiddleware( - { - functionName: 'productCache', - type: 'x-cache', - name: 'app', - description: 'Entity-keyed detail cache with per-request read authorization', - }, + { functionName: 'productCache', type: 'x-cache', name: 'app', description: 'Entity-keyed detail cache with per-request read authorization' }, async (ctx, next) => { const id = ctx.req.param('id'); if (!id) { diff --git a/backend/src/middlewares/rate-limiter/core.ts b/backend/src/middlewares/rate-limiter/core.ts index ddf8840ee..037a1dd34 100644 --- a/backend/src/middlewares/rate-limiter/core.ts +++ b/backend/src/middlewares/rate-limiter/core.ts @@ -1,23 +1,10 @@ import { RateLimiterRes } from 'rate-limiter-flexible'; import { AppError } from '#/core/error'; import { xMiddleware } from '#/core/x-middleware'; -import { - extractIdentifiers, - getRateLimiterInstance, - openBucket, - rateLimitError, - subjectSegment, -} from '#/middlewares/rate-limiter/helpers'; +import { extractIdentifiers, getRateLimiterInstance, openBucket, rateLimitError, subjectSegment } from '#/middlewares/rate-limiter/helpers'; import { restoreDebt, syncFromDb, takeDebt, tryFastConsume } from '#/middlewares/rate-limiter/points-cache'; import { reserveTiers, settleTiers, slowTier } from '#/middlewares/rate-limiter/tiers'; -import type { - Outcome, - RateLimiterHandler, - RateLimiterOpts, - RateLimitKeyPart, - RateLimitMode, - Tier, -} from '#/middlewares/rate-limiter/types'; +import type { Outcome, RateLimiterHandler, RateLimiterOpts, RateLimitKeyPart, RateLimitMode, Tier } from '#/middlewares/rate-limiter/types'; import { log } from '#/utils/logger'; export const defaultOptions = { @@ -30,11 +17,7 @@ export const defaultOptions = { }; /** Opens a bucket before its first consume; the store falls back to its in-memory insurance while the database is unreachable. */ -async function openBucketSafely( - store: ReturnType, - rateLimitKey: string, - durationSeconds: number, -) { +async function openBucketSafely(store: ReturnType, rateLimitKey: string, durationSeconds: number) { try { await openBucket(store, rateLimitKey, durationSeconds); } catch (err) { @@ -54,12 +37,7 @@ async function openBucketSafely( * @param identifiers - Key parts or fallback chains composing the subject identifier. * @param opts - Limits and middleware metadata. */ -export const rateLimiter = ( - mode: RateLimitMode, - key: string, - identifiers: RateLimitKeyPart[], - opts?: RateLimiterOpts, -): RateLimiterHandler => { +export const rateLimiter = (mode: RateLimitMode, key: string, identifiers: RateLimitKeyPart[], opts?: RateLimiterOpts): RateLimiterHandler => { const { limits, functionName, name, description, getConsumePoints, getPointsBudget } = opts ?? {}; const config = { ...defaultOptions, ...limits }; const keyPrefix = `${key}_${mode}`; @@ -67,9 +45,7 @@ export const rateLimiter = ( const store = getRateLimiterInstance({ ...config, keyPrefix, inMemoryBlock: mode === 'limit' }); /** The buckets a reserved attempt counts in: the route's own and, behind a failure budget, the 24-hour one. */ const tiers: Tier[] = - mode === 'limit' - ? [] - : [{ store, limits: config, counts: isFailMode ? 'fail' : 'success', resetsOnSuccess: mode === 'failseries' }]; + mode === 'limit' ? [] : [{ store, limits: config, counts: isFailMode ? 'fail' : 'success', resetsOnSuccess: mode === 'failseries' }]; if (isFailMode) tiers.push(slowTier(keyPrefix)); const handler = xMiddleware( @@ -103,10 +79,7 @@ export const rateLimiter = ( // Clamp tenant budgets without mutating the shared prefix limiter; a zero tenant budget uses the global ceiling const consumePoints = getConsumePoints ? await getConsumePoints(ctx) : 1; const tenantBudget = getPointsBudget ? getPointsBudget(ctx) : null; - const effectiveBudget = - tenantBudget === null - ? config.points - : Math.min(tenantBudget > 0 ? tenantBudget : config.points, config.points); + const effectiveBudget = Math.min(tenantBudget !== null && tenantBudget > 0 ? tenantBudget : config.points, config.points); // Fast path: an in-process counter skips the DB while the key is well under budget. if (getPointsBudget && tryFastConsume(rateLimitKey, consumePoints, effectiveBudget) === 'allow') { diff --git a/backend/src/middlewares/rate-limiter/helpers.ts b/backend/src/middlewares/rate-limiter/helpers.ts index 520d5c93e..8c201c70a 100644 --- a/backend/src/middlewares/rate-limiter/helpers.ts +++ b/backend/src/middlewares/rate-limiter/helpers.ts @@ -6,12 +6,7 @@ import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; import { baseDb as db } from '#/db/db'; import { env } from '#/env'; -import type { - Identifiers, - LimiterStore, - RateLimiterHandler, - RateLimitIdentifier, -} from '#/middlewares/rate-limiter/types'; +import type { Identifiers, LimiterStore, RateLimiterHandler, RateLimitIdentifier } from '#/middlewares/rate-limiter/types'; import { rateLimitsTable } from '#/modules/auth/rate-limits-db'; import { getIp } from '#/utils/get-ip'; import { hashRateLimitSubject } from '#/utils/hash-pii'; @@ -56,10 +51,7 @@ export const getRateLimiterInstance = ({ inMemoryBlock = true, ...options }: Rat insuranceLimiter: insurance, // Both blocks last blockDuration: an in-memory block without a duration of its own ends with the window, and the // library then skips the database block every process reads. A zero blockDuration blocks for the rest of the window. - ...(inMemoryBlock && { - inMemoryBlockOnConsumed: options.points, - inMemoryBlockDuration: options.blockDuration, - }), + ...(inMemoryBlock && { inMemoryBlockOnConsumed: options.points, inMemoryBlockDuration: options.blockDuration }), }); insurances.set(instance, insurance); } @@ -88,11 +80,7 @@ export const openBucket = async (store: LimiterStore, rateLimitKey: string, dura await db .insert(rateLimitsTable) .values({ key: store.getKey(rateLimitKey), points: 0, expire }) - .onConflictDoUpdate({ - target: rateLimitsTable.key, - set: { points: 0, expire }, - setWhere: lte(rateLimitsTable.expire, now), - }); + .onConflictDoUpdate({ target: rateLimitsTable.key, set: { points: 0, expire }, setWhere: lte(rateLimitsTable.expire, now) }); }; /** @@ -140,9 +128,7 @@ export const reserveAttempt = async ( store: LimiterStore, rateLimitKey: string, limits: { points: number; duration: number }, -): Promise< - { granted: true; store: LimiterStore; state: RateLimiterRes } | { granted: false; state: RateLimiterRes } -> => { +): Promise<{ granted: true; store: LimiterStore; state: RateLimiterRes } | { granted: false; state: RateLimiterRes }> => { let holder = store; let taken: RateLimiterRes | null; try { @@ -170,13 +156,7 @@ export const refundAttempt = async (store: LimiterStore, rateLimitKey: string) = await db .update(rateLimitsTable) .set({ points: sql`${rateLimitsTable.points} - 1` }) - .where( - and( - eq(rateLimitsTable.key, store.getKey(rateLimitKey)), - gt(rateLimitsTable.points, 0), - gt(rateLimitsTable.expire, new Date()), - ), - ); + .where(and(eq(rateLimitsTable.key, store.getKey(rateLimitKey)), gt(rateLimitsTable.points, 0), gt(rateLimitsTable.expire, new Date()))); return; } // In memory a bucket reset or expired since reads back below zero: the point goes back where it came from. @@ -190,24 +170,13 @@ export const refundAttempt = async (store: LimiterStore, rateLimitKey: string) = * reopen it. * @returns Whether the bucket was spent and is blocked now. */ -export const blockSpentBucket = async ( - store: LimiterStore, - rateLimitKey: string, - points: number, - blockSeconds: number, -): Promise => { +export const blockSpentBucket = async (store: LimiterStore, rateLimitKey: string, points: number, blockSeconds: number): Promise => { const now = new Date(); if (store instanceof RateLimiterDrizzle) { const blocked = await db .update(rateLimitsTable) .set({ expire: new Date(now.getTime() + blockSeconds * 1000) }) - .where( - and( - eq(rateLimitsTable.key, store.getKey(rateLimitKey)), - gte(rateLimitsTable.points, points), - gt(rateLimitsTable.expire, now), - ), - ) + .where(and(eq(rateLimitsTable.key, store.getKey(rateLimitKey)), gte(rateLimitsTable.points, points), gt(rateLimitsTable.expire, now))) .returning({ key: rateLimitsTable.key }); return blocked.length > 0; } @@ -237,53 +206,26 @@ export const subjectSegment = (identifier: RateLimitIdentifier, value: string): return `${identifier}:${value}`; }; -export const extractIdentifiers = async ( - ctx: Context, - identifiersToExtract: RateLimitIdentifier[], -): Promise => { - const results: Identifiers = { - email: null, - ip: null, - userId: null, - actorId: null, - tenantId: null, - }; - - for (const identifier of identifiersToExtract) { - switch (identifier) { - case 'email': { - // Normalize the email exactly like validation so aliases share a bucket; this runs before Zod, so guard the type - if (ctx.req.header('content-type')?.includes('application/json')) { - try { - const body = (await ctx.req.json()) as { email?: unknown }; - if (typeof body.email === 'string' && body.email) results.email = body.email.toLowerCase().trim(); - } catch {} - } - break; - } - - case 'ip': { - results.ip = getIp(ctx); - break; - } - case 'userId': { - const user = ctx.var.user; - if (user) results.userId = user.id; - break; - } - case 'actorId': { - const actor = ctx.var.actor; - if (actor) results.actorId = actor.id; - break; - } - case 'tenantId': { - const tenantId = ctx.var.tenantId; - if (tenantId) results.tenantId = tenantId; - break; - } - } - } +/** How each identifier reads its value from the request; the map covers every identifier. */ +const identifierReaders = { + // Normalize the email exactly like validation so aliases share a bucket; this runs before Zod, so guard the type + email: async (ctx) => { + if (!ctx.req.header('content-type')?.includes('application/json')) return null; + try { + const body = (await ctx.req.json()) as { email?: unknown }; + if (typeof body.email === 'string' && body.email) return body.email.toLowerCase().trim(); + } catch {} + return null; + }, + ip: (ctx) => getIp(ctx), + userId: (ctx) => ctx.var.user?.id ?? null, + actorId: (ctx) => ctx.var.actor?.id ?? null, + tenantId: (ctx) => ctx.var.tenantId || null, +} satisfies Record) => string | null | Promise>; +export const extractIdentifiers = async (ctx: Context, identifiersToExtract: RateLimitIdentifier[]): Promise => { + const results: Identifiers = { email: null, ip: null, userId: null, actorId: null, tenantId: null }; + for (const identifier of identifiersToExtract) results[identifier] = await identifierReaders[identifier](ctx); return results; }; diff --git a/backend/src/middlewares/rate-limiter/limiters.ts b/backend/src/middlewares/rate-limiter/limiters.ts index bbe14254d..f8ced6037 100644 --- a/backend/src/middlewares/rate-limiter/limiters.ts +++ b/backend/src/middlewares/rate-limiter/limiters.ts @@ -47,10 +47,11 @@ export const totpVerificationLimiter = rateLimiter('failseries', 'totpVerificati /** * Keyed per account: a session guessing second factors is blocked whatever IP it uses; a proof that verifies clears - * the series. + * the series. A wrong factor answers 401 (404 for one the user does not hold); the 403 refusing an impersonation + * guesses nothing and spends none of the user's attempts. */ export const stepUpLimiter = rateLimiter('failseries', 'stepUp', ['userId'], { - limits: { points: 5, duration: 60 * 60, blockDuration: 60 * 30, successStatusCodes: [200, 201, 204] }, + limits: { points: 5, duration: 60 * 60, blockDuration: 60 * 30, successStatusCodes: [200, 201, 204], failStatusCodes: [401, 404] }, description: 'Blocks the account for 30 min after 5 failed second-factor checks on step-up', }); @@ -103,6 +104,12 @@ export const serviceBurstLimiter = rateLimiter('limit', 'serviceBurst', ['actorI description: 'Max 30 requests/second per service account', }); +/** Per-second ceiling for MCP endpoint requests, a bucket of its own: the route a tool call runs charges the burst. */ +export const mcpRequestLimiter = rateLimiter('limit', 'mcpRequest', ['actorId'], { + limits: { points: 30, duration: 1, blockDuration: 0 }, + description: 'Max 30 MCP requests/second per account', +}); + /** Backpressure for the read fan-out one SSE notification triggers; a 429 rides the client's invalidate-and-backoff. */ export const syncReadLimiter = rateLimiter('limit', 'syncRead', [['userId', 'ip']], { limits: { points: 5000, duration: 60 * 60, blockDuration: 60 * 5 }, diff --git a/backend/src/middlewares/rate-limiter/points-cache.ts b/backend/src/middlewares/rate-limiter/points-cache.ts index 1af6d2efc..36e312b72 100644 --- a/backend/src/middlewares/rate-limiter/points-cache.ts +++ b/backend/src/middlewares/rate-limiter/points-cache.ts @@ -16,10 +16,7 @@ const MAX_ENTRIES = 50_000; const WINDOW_MS = 60 * 60 * 1000; -const cache = new TTLCache({ - maxSize: MAX_ENTRIES, - defaultTtl: WINDOW_MS, -}); +const cache = new TTLCache({ maxSize: MAX_ENTRIES, defaultTtl: WINDOW_MS }); /** * Local consumption accrues as debt settled by `takeDebt`, so each process reaches the threshold before its first flush. diff --git a/backend/src/middlewares/rate-limiter/tests/budget-enforcement.test.ts b/backend/src/middlewares/rate-limiter/tests/budget-enforcement.test.ts index ee22b7b32..6a8434f23 100644 --- a/backend/src/middlewares/rate-limiter/tests/budget-enforcement.test.ts +++ b/backend/src/middlewares/rate-limiter/tests/budget-enforcement.test.ts @@ -6,9 +6,7 @@ import { memoryStores } from './memory-stores'; // Undo the setup.ts mock: these tests drive the real middleware against in-memory stores, end to end vi.unmock('#/middlewares/rate-limiter/core'); -vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => - (await import('./memory-stores')).memoryStoresMock(importOriginal), -); +vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => (await import('./memory-stores')).memoryStoresMock(importOriginal)); // Must import AFTER mocks are set up const { rateLimiter } = await import('#/middlewares/rate-limiter/core'); diff --git a/backend/src/middlewares/rate-limiter/tests/identifier-validation.test.ts b/backend/src/middlewares/rate-limiter/tests/identifier-validation.test.ts index 57df28a9a..f3711c042 100644 --- a/backend/src/middlewares/rate-limiter/tests/identifier-validation.test.ts +++ b/backend/src/middlewares/rate-limiter/tests/identifier-validation.test.ts @@ -7,9 +7,7 @@ import { memoryStores } from './memory-stores'; // Undo the setup.ts mock: these tests need the real rateLimiter to derive the key. vi.unmock('#/middlewares/rate-limiter/core'); -vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => - (await import('./memory-stores')).memoryStoresMock(importOriginal), -); +vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => (await import('./memory-stores')).memoryStoresMock(importOriginal)); const { rateLimiter } = await import('#/middlewares/rate-limiter/core'); const { subjectSegment } = await import('#/middlewares/rate-limiter/helpers'); @@ -24,14 +22,15 @@ function jsonRequest(path: string, body: Record) { }); } -/** A route behind a fresh `limit` limiter keyed on `identifiers`; `userId` signs its requests in. */ -function keyedRoute(identifiers: RateLimitKeyPart[], userId?: string) { +/** A route behind a fresh `limit` limiter keyed on `identifiers`; `userId` and `actorId` set who sends. */ +function keyedRoute(identifiers: RateLimitKeyPart[], userId?: string, actorId?: string) { const limiter = rateLimiter('limit', `key_${nanoid(8)}`, identifiers, { limits: { points: 10, duration: 60 } }); const app = new Hono(); app.onError(appErrorHandler); - if (userId) { + if (userId || actorId) { app.use(async (ctx, next) => { - ctx.set('user', { id: userId } as Env['Variables']['user']); + if (userId) ctx.set('user', { id: userId } as Env['Variables']['user']); + if (actorId) ctx.set('actor', { id: actorId } as Env['Variables']['actor']); await next(); }); } @@ -102,6 +101,28 @@ describe('rate limiter identifier validation', () => { }); }); + describe('actor identifier', () => { + const bare = () => new Request('http://localhost/test', { method: 'POST' }); + + it('keys on the actor id', async () => { + const route = keyedRoute(['actorId'], undefined, 'actor-1'); + expect((await route.app.request(bare(), undefined, emptyBindings)).status).toBe(200); + expect(route.keys()).toEqual(['actorId:actor-1']); + }); + + it('keeps the actor and the user as separate subjects', async () => { + const route = keyedRoute(['actorId', 'userId'], 'user-1', 'actor-1'); + expect((await route.app.request(bare(), undefined, emptyBindings)).status).toBe(200); + expect(route.keys()).toEqual(['actorId:actor-1userId:user-1']); + }); + + it('rejects when no actor is set, even for a signed-in user', async () => { + const route = keyedRoute(['actorId'], 'user-1'); + expect((await route.app.request(bare(), undefined, emptyBindings)).status).toBe(400); + expect(route.keys()).toEqual([]); + }); + }); + describe('pseudonymous subjects', () => { it('must not store an IP or an address in the clear via the key', () => { const ip = subjectSegment('ip', '1.2.3.4'); diff --git a/backend/src/middlewares/rate-limiter/tests/limiter-scope.test.ts b/backend/src/middlewares/rate-limiter/tests/limiter-scope.test.ts index eca908506..45c88fdb5 100644 --- a/backend/src/middlewares/rate-limiter/tests/limiter-scope.test.ts +++ b/backend/src/middlewares/rate-limiter/tests/limiter-scope.test.ts @@ -7,8 +7,7 @@ vi.unmock('#/middlewares/rate-limiter/core'); const { rateLimiter } = await import('#/middlewares/rate-limiter/core'); const { chargeLimiter } = await import('#/middlewares/rate-limiter/helpers'); -const workLimiter = () => - rateLimiter('limit', `scope_${nanoid(8)}`, ['ip'], { limits: { points: 3, duration: 60, blockDuration: 60 } }); +const workLimiter = () => rateLimiter('limit', `scope_${nanoid(8)}`, ['ip'], { limits: { points: 3, duration: 60, blockDuration: 60 } }); /** * A limiter charged where the work it bounds starts, from code that has no request context of its own. The charge diff --git a/backend/src/middlewares/rate-limiter/tests/memory-stores.ts b/backend/src/middlewares/rate-limiter/tests/memory-stores.ts index 02b7a9b6d..9f39379e2 100644 --- a/backend/src/middlewares/rate-limiter/tests/memory-stores.ts +++ b/backend/src/middlewares/rate-limiter/tests/memory-stores.ts @@ -10,12 +10,7 @@ export const memoryStores = new Map(); */ export const memoryStoresMock = async (importOriginal: () => Promise>) => ({ ...(await importOriginal()), - getRateLimiterInstance: (options: { - keyPrefix?: string; - points: number; - duration: number; - blockDuration?: number; - }) => { + getRateLimiterInstance: (options: { keyPrefix?: string; points: number; duration: number; blockDuration?: number }) => { const keyPrefix = options.keyPrefix ?? ''; const existing = memoryStores.get(keyPrefix); if (existing) return existing; diff --git a/backend/src/middlewares/rate-limiter/tests/tiers.test.ts b/backend/src/middlewares/rate-limiter/tests/tiers.test.ts index a73758a9f..8a7bb9de2 100644 --- a/backend/src/middlewares/rate-limiter/tests/tiers.test.ts +++ b/backend/src/middlewares/rate-limiter/tests/tiers.test.ts @@ -8,9 +8,7 @@ import { memoryStores } from './memory-stores'; // Undo the setup.ts mock: these tests drive the real middleware against in-memory stores. vi.unmock('#/middlewares/rate-limiter/core'); -vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => - (await import('./memory-stores')).memoryStoresMock(importOriginal), -); +vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => (await import('./memory-stores')).memoryStoresMock(importOriginal)); const { rateLimiter } = await import('#/middlewares/rate-limiter/core'); const { checkRateLimitStatus, subjectSegment } = await import('#/middlewares/rate-limiter/helpers'); @@ -24,8 +22,7 @@ function guardedRoute(mode: RateLimitMode, answer: (ctx: Context) => Respon const app = new Hono(); app.onError(appErrorHandler); app.post('/attempt', limiter, answer); - const attempt = (ip: string) => - app.request('http://localhost/attempt', { method: 'POST', headers: { 'x-forwarded-for': ip } }); + const attempt = (ip: string) => app.request('http://localhost/attempt', { method: 'POST', headers: { 'x-forwarded-for': ip } }); return { limiter, attempt }; } diff --git a/backend/src/middlewares/rate-limiter/tiers.ts b/backend/src/middlewares/rate-limiter/tiers.ts index d540d14b3..c59e3fd39 100644 --- a/backend/src/middlewares/rate-limiter/tiers.ts +++ b/backend/src/middlewares/rate-limiter/tiers.ts @@ -1,12 +1,6 @@ import type { Context } from 'hono'; import type { Env } from '#/core/context'; -import { - blockSpentBucket, - getRateLimiterInstance, - rateLimitError, - refundAttempt, - reserveAttempt, -} from '#/middlewares/rate-limiter/helpers'; +import { blockSpentBucket, getRateLimiterInstance, rateLimitError, refundAttempt, reserveAttempt } from '#/middlewares/rate-limiter/helpers'; import type { BucketLimits, LimiterStore, Outcome, Tier } from '#/middlewares/rate-limiter/types'; import { log } from '#/utils/logger'; diff --git a/backend/src/middlewares/rate-limiter/types.ts b/backend/src/middlewares/rate-limiter/types.ts index 9b66d2d20..d61ac46a4 100644 --- a/backend/src/middlewares/rate-limiter/types.ts +++ b/backend/src/middlewares/rate-limiter/types.ts @@ -35,11 +35,7 @@ export interface Tier extends Bucket { resetsOnSuccess: boolean; } -type LimiterStatusLists = { - successStatusCodes?: number[]; - failStatusCodes?: number[]; - ignoredStatusCodes?: number[]; -}; +type LimiterStatusLists = { successStatusCodes?: number[]; failStatusCodes?: number[]; ignoredStatusCodes?: number[] }; export type RateLimitOptions = Partial & LimiterStatusLists; diff --git a/backend/src/middlewares/update-last-seen.ts b/backend/src/middlewares/update-last-seen.ts index e95387494..0dabd9f05 100644 --- a/backend/src/middlewares/update-last-seen.ts +++ b/backend/src/middlewares/update-last-seen.ts @@ -18,10 +18,7 @@ export const updateLastSeenAt = (userId: string): void => { const timestamp = getIsoDate(); db.insert(userCountersTable) .values({ userId, lastSeenAt: timestamp }) - .onConflictDoUpdate({ - target: userCountersTable.userId, - set: { lastSeenAt: timestamp }, - }) + .onConflictDoUpdate({ target: userCountersTable.userId, set: { lastSeenAt: timestamp } }) .catch(() => { // Reset memory on failure so next request retries lastSeenMemory.delete(userId); diff --git a/backend/src/mocks/app-product-mocks.ts b/backend/src/mocks/app-product-mocks.ts index cfa9e294f..ad497a641 100644 --- a/backend/src/mocks/app-product-mocks.ts +++ b/backend/src/mocks/app-product-mocks.ts @@ -7,7 +7,4 @@ import { mockTask } from '#/modules/task/task-mocks'; * Merged into `productMocksByType`: one entry per app-owned product entity type, so the config-driven * insert suites (RLS, CDC, sequence) can seed those rows. The registry's `satisfies` enforces coverage. */ -export const appProductMocks = { - task: mockTask, - label: mockLabel, -} satisfies Partial>; +export const appProductMocks = { task: mockTask, label: mockLabel } satisfies Partial>; diff --git a/backend/src/mocks/mock-channel-counts.ts b/backend/src/mocks/mock-channel-counts.ts index 990291e85..26fe07d3a 100644 --- a/backend/src/mocks/mock-channel-counts.ts +++ b/backend/src/mocks/mock-channel-counts.ts @@ -12,9 +12,9 @@ export const generateMockChannelCounts = (channelType: ChannelEntityType, key: s const entities = withFakerSeed( `${key}:entities`, () => - Object.fromEntries( - descendants.map((entityType) => [entityType, faker.number.int({ min: 0, max: 500 })]), - ) as Partial>, + Object.fromEntries(descendants.map((entityType) => [entityType, faker.number.int({ min: 0, max: 500 })])) as Partial< + Record + >, ); const activity = withFakerSeed( @@ -23,9 +23,7 @@ export const generateMockChannelCounts = (channelType: ChannelEntityType, key: s Object.fromEntries( productDescendants.map((entityType) => { const created = faker.date.recent({ days: 30, refDate: MOCK_REF_DATE }); - const updated = faker.datatype.boolean({ probability: 2 / 3 }) - ? faker.date.between({ from: created, to: MOCK_REF_DATE }).getTime() - : null; + const updated = faker.datatype.boolean({ probability: 2 / 3 }) ? faker.date.between({ from: created, to: MOCK_REF_DATE }).getTime() : null; return [entityType, { created: created.getTime(), updated }]; }), ) as Partial>, @@ -36,17 +34,9 @@ export const generateMockChannelCounts = (channelType: ChannelEntityType, key: s `${key}:entitiesSelf`, () => Object.fromEntries( - Object.entries(entities).map(([entityType, total]) => [ - entityType, - faker.number.int({ min: 0, max: total ?? 0 }), - ]), + Object.entries(entities).map(([entityType, total]) => [entityType, faker.number.int({ min: 0, max: total ?? 0 })]), ) as Partial>, ); - return { - membership: generateMockMembershipCounts(`${key}:membership`), - entities, - entitiesSelf, - activity, - }; + return { membership: generateMockMembershipCounts(`${key}:membership`), entities, entitiesSelf, activity }; }; diff --git a/backend/src/mocks/mock-channel-id-columns.ts b/backend/src/mocks/mock-channel-id-columns.ts index bd90aa61f..e415fea53 100644 --- a/backend/src/mocks/mock-channel-id-columns.ts +++ b/backend/src/mocks/mock-channel-id-columns.ts @@ -12,10 +12,7 @@ import { mockUuid } from './mock-nanoid'; type MockChannelIdColumns = EntityIdColumns; -type MockEntityChannelIdColumns = EntityIdColumns< - (AncestorChannelType | RelatedChannelType) & EntityType, - string ->; +type MockEntityChannelIdColumns = EntityIdColumns<(AncestorChannelType | RelatedChannelType) & EntityType, string>; const mockIdColumns = (entityTypes: Iterable) => { const columns: Record = {}; @@ -25,17 +22,11 @@ const mockIdColumns = (entityTypes: Iterable) => { return columns; }; -export const generateMockChannelIdColumns = (): MockChannelIdColumns => - mockIdColumns(appConfig.channelEntityTypes) as MockChannelIdColumns; +export const generateMockChannelIdColumns = (): MockChannelIdColumns => mockIdColumns(appConfig.channelEntityTypes) as MockChannelIdColumns; /** Generates the hierarchy-derived channel ID columns carried by one product entity. */ -export const generateMockEntityChannelIdColumns = ( - entityType: E, -): MockEntityChannelIdColumns => - mockIdColumns([ - ...hierarchy.getOrderedAncestors(entityType), - ...hierarchy.getRelatedChannels(entityType), - ]) as MockEntityChannelIdColumns; +export const generateMockEntityChannelIdColumns = (entityType: E): MockEntityChannelIdColumns => + mockIdColumns([...hierarchy.getOrderedAncestors(entityType), ...hierarchy.getRelatedChannels(entityType)]) as MockEntityChannelIdColumns; type MockActivityChannelIdColumns = EntityIdColumns & EntityType, string>; diff --git a/backend/src/mocks/mock-entity-columns.ts b/backend/src/mocks/mock-entity-columns.ts index a34e9bddf..819be4ca5 100644 --- a/backend/src/mocks/mock-entity-columns.ts +++ b/backend/src/mocks/mock-entity-columns.ts @@ -52,10 +52,7 @@ type MockProductColumns = MockTenantEntityColumns, 'entityType'>>; /** Mirrors `productColumns`. Entity-specific and hierarchy-derived columns stay in the owning module. */ -export const mockProductColumns = ( - entityType: T, - options: MockProductColumnOptions = {}, -): MockProductColumns => { +export const mockProductColumns = (entityType: T, options: MockProductColumnOptions = {}): MockProductColumns => { const createdBy = options.createdBy === undefined ? mockUuid() : options.createdBy; return { ...mockTenantEntityColumns(entityType, options), @@ -88,10 +85,7 @@ type MockChannelColumnOptions = Partial( - entityType: T, - options: MockChannelColumnOptions = {}, -): MockChannelColumns => { +export const mockChannelColumns = (entityType: T, options: MockChannelColumnOptions = {}): MockChannelColumns => { const base = mockTenantEntityColumns(entityType, options); return { ...base, diff --git a/backend/src/mocks/mock-membership-counts.ts b/backend/src/mocks/mock-membership-counts.ts index e7dd21765..bb8d97a35 100644 --- a/backend/src/mocks/mock-membership-counts.ts +++ b/backend/src/mocks/mock-membership-counts.ts @@ -16,9 +16,5 @@ export const generateMockMembershipCounts = (key: string) => total += count; } - return { - ...roleCounts, - pending: faker.number.int({ min: 0, max: 50 }), - total, - }; + return { ...roleCounts, pending: faker.number.int({ min: 0, max: 50 }), total }; }); diff --git a/backend/src/mocks/mock-paginated.ts b/backend/src/mocks/mock-paginated.ts index c59b794c5..65aecc7ed 100644 --- a/backend/src/mocks/mock-paginated.ts +++ b/backend/src/mocks/mock-paginated.ts @@ -1,9 +1,5 @@ /** Matches paginationSchema: `{ items: T[], total: number }`. */ -export const mockPaginated = ( - mockFn: (key?: string) => T, - count = 2, - total = count, -): { items: T[]; total: number } => ({ +export const mockPaginated = (mockFn: (key?: string) => T, count = 2, total = count): { items: T[]; total: number } => ({ items: Array.from({ length: count }, (_, i) => mockFn(`item:${i}`)), total, }); diff --git a/backend/src/mocks/mock-stx.ts b/backend/src/mocks/mock-stx.ts index 45b30b287..106e880c1 100644 --- a/backend/src/mocks/mock-stx.ts +++ b/backend/src/mocks/mock-stx.ts @@ -1,8 +1,4 @@ import type { StxBase } from '#/schemas/sync-transaction-schemas'; import { mockUuid } from './mock-nanoid'; -export const mockStx = (): StxBase => ({ - mutationId: mockUuid(), - sourceId: mockUuid(), - fieldTimestamps: {}, -}); +export const mockStx = (): StxBase => ({ mutationId: mockUuid(), sourceId: mockUuid(), fieldTimestamps: {} }); diff --git a/backend/src/mocks/mock-timestamps.ts b/backend/src/mocks/mock-timestamps.ts index 6cc5e31ae..07b27738c 100644 --- a/backend/src/mocks/mock-timestamps.ts +++ b/backend/src/mocks/mock-timestamps.ts @@ -6,8 +6,5 @@ export const MOCK_REF_DATE = new Date('2025-01-01T00:00:00.000Z'); /** createdAt in the past, updatedAt between createdAt and refDate. Must run inside withFakerSeed(). */ export const mockTimestamps = (refDate = MOCK_REF_DATE) => { const createdAt = faker.date.past({ refDate }); - return { - createdAt: createdAt.toISOString(), - updatedAt: faker.date.between({ from: createdAt, to: refDate }).toISOString(), - }; + return { createdAt: createdAt.toISOString(), updatedAt: faker.date.between({ from: createdAt, to: refDate }).toISOString() }; }; diff --git a/backend/src/mocks/product-mock-registry.ts b/backend/src/mocks/product-mock-registry.ts index 78c6c2366..5e78d012b 100644 --- a/backend/src/mocks/product-mock-registry.ts +++ b/backend/src/mocks/product-mock-registry.ts @@ -11,10 +11,7 @@ export type ProductMockFn = (key?: string) => Record; * The template registers its own product entities here; apps add theirs in `appProductMocks`. * Exhaustive typing and a drift test keep the shared product-seeding suites aligned with app schemas. */ -export const productMocksByType = { - attachment: mockAttachment, - ...appProductMocks, -} as const satisfies Record; +export const productMocksByType = { attachment: mockAttachment, ...appProductMocks } as const satisfies Record; /** Create an insert-ready product mock by dropping generated columns and applying overrides last. */ export function buildInsertableProduct( @@ -31,9 +28,7 @@ export function buildInsertableProduct( ); // Nullable ancestors insert as null (org-homed) unless overridden: the SELECT-shape mock invents // ids that never satisfy the ancestor foreign keys channelRelationColumns declares. - const nullableAncestorKeys = new Set( - hierarchy.getNullableAncestors(entityType).map((type) => appConfig.entityIdColumnKeys[type]), - ); + const nullableAncestorKeys = new Set(hierarchy.getNullableAncestors(entityType).map((type) => appConfig.entityIdColumnKeys[type])); const row: Record = {}; for (const [prop, value] of Object.entries(mock)) { if (generatedProps.has(prop)) continue; diff --git a/backend/src/modules/activities/activities-mocks.ts b/backend/src/modules/activities/activities-mocks.ts index 555c6045e..8706950d3 100644 --- a/backend/src/modules/activities/activities-mocks.ts +++ b/backend/src/modules/activities/activities-mocks.ts @@ -25,10 +25,7 @@ export const mockActivity = (key = 'activity:default', overrides?: Partial(), kind: varchar({ enum: actorKinds }).notNull(), + /** A new random value on every write to the user's memberships (`db/membership-rules.ts`); keys the membership cache. */ + bindingsVersion: uuid().notNull().defaultRandom(), createdAt: timestampColumns.createdAt, }); diff --git a/backend/src/modules/actors/actors-queries.ts b/backend/src/modules/actors/actors-queries.ts new file mode 100644 index 000000000..e57cff189 --- /dev/null +++ b/backend/src/modules/actors/actors-queries.ts @@ -0,0 +1,33 @@ +import { inArray } from 'drizzle-orm'; +import type { DbContext } from '#/core/context'; +import { type ActorKind, actorsTable } from '#/modules/actors/actors-db'; + +interface InsertActorsOpts { + ids: string[]; + kind: ActorKind; + /** Leave existing ids alone. */ + onConflictDoNothing?: boolean; +} + +/** + * Actor rows for ids about to get a kind row. Returns the ids this call inserted, so a caller cleaning up after a + * skipped kind row never touches a pre-existing actor. + */ +export async function insertActors(ctx: DbContext, { ids, kind, onConflictDoNothing = false }: InsertActorsOpts): Promise { + if (ids.length === 0) return []; + const insert = ctx.var.db + .insert(actorsTable) + .values(ids.map((id) => ({ id, kind }))) + .returning({ id: actorsTable.id }); + const rows = onConflictDoNothing ? await insert.onConflictDoNothing() : await insert; + return rows.map((row) => row.id); +} + +interface DeleteDanglingActorsOpts { + ids: string[]; +} + +/** Removes actors this call created whose kind row was skipped, so every actor keeps a kind row. */ +export async function deleteDanglingActors(ctx: DbContext, { ids }: DeleteDanglingActorsOpts): Promise { + if (ids.length > 0) await ctx.var.db.delete(actorsTable).where(inArray(actorsTable.id, ids)); +} diff --git a/backend/src/modules/actors/helpers/insert-actors.ts b/backend/src/modules/actors/helpers/insert-actors.ts deleted file mode 100644 index da2fa6122..000000000 --- a/backend/src/modules/actors/helpers/insert-actors.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { inArray } from 'drizzle-orm'; -import type { DbOrTx } from '#/db/db'; -import { type ActorKind, actorsTable } from '#/modules/actors/actors-db'; - -/** - * Actor rows for ids about to get a kind row; with `onConflictDoNothing`, existing ids are left alone. Returns - * the ids this call inserted, so a caller cleaning up after a skipped kind row never touches a pre-existing actor. - */ -export async function insertActors( - tx: DbOrTx, - ids: string[], - kind: ActorKind, - { onConflictDoNothing = false } = {}, -): Promise { - if (ids.length === 0) return []; - const insert = tx - .insert(actorsTable) - .values(ids.map((id) => ({ id, kind }))) - .returning({ id: actorsTable.id }); - const rows = onConflictDoNothing ? await insert.onConflictDoNothing() : await insert; - return rows.map((row) => row.id); -} - -/** Removes actors this call created whose kind row was skipped, so every actor keeps a kind row. */ -export async function deleteDanglingActors(tx: DbOrTx, ids: string[]): Promise { - if (ids.length > 0) await tx.delete(actorsTable).where(inArray(actorsTable.id, ids)); -} diff --git a/backend/src/modules/attachment/attachment-db.ts b/backend/src/modules/attachment/attachment-db.ts index 76ad30567..a64f182da 100644 --- a/backend/src/modules/attachment/attachment-db.ts +++ b/backend/src/modules/attachment/attachment-db.ts @@ -3,7 +3,7 @@ import { tenantSelectPolicy, writeThroughPolicies } from '#/db/rls-helpers'; import { channelRelationColumns, channelRelationIndexes } from '#/db/utils/channel-relation-columns'; import { maxLength } from '#/db/utils/constraints'; import { organizationForeignKey } from '#/db/utils/organization-foreign-key'; -import { mentionableColumns, productColumns } from '#/db/utils/product-columns'; +import { productColumns } from '#/db/utils/product-columns'; import type { AttachmentKeys } from '#/modules/attachment/attachment-schema'; /** Each attachment belongs to exactly one tenant and organization: the RLS isolation boundary. */ @@ -27,7 +27,6 @@ export const attachmentsTable = snakeCase.table( .$type() .notNull() .default({} as AttachmentKeys), - ...mentionableColumns, ...channelRelationColumns('attachment'), }, (table) => [ diff --git a/backend/src/modules/attachment/attachment-mocks.ts b/backend/src/modules/attachment/attachment-mocks.ts index 07cc7a6cc..582f8c00b 100644 --- a/backend/src/modules/attachment/attachment-mocks.ts +++ b/backend/src/modules/attachment/attachment-mocks.ts @@ -1,12 +1,5 @@ import { faker } from '@faker-js/faker'; -import { - generateMockEntityChannelIdColumns, - mockBatchResponse, - mockNanoid, - mockPaginated, - mockProductColumns, - withFakerSeed, -} from '#/mocks'; +import { generateMockEntityChannelIdColumns, mockBatchResponse, mockNanoid, mockPaginated, mockProductColumns, withFakerSeed } from '#/mocks'; import type { AttachmentModel } from '#/modules/attachment/attachment-db'; import { mockAuditUsers } from '#/schemas/entity-base-mocks'; @@ -26,7 +19,6 @@ export const mockAttachment = (key = 'attachment:default'): AttachmentModel => convertedContentType: null, size: String(faker.number.int({ min: 1000, max: 10_000_000 })), keys: { original: `uploads/${mockNanoid()}/${filename}` }, - mentions: [], ...channelIds, }; }); diff --git a/backend/src/modules/attachment/attachment-queries.ts b/backend/src/modules/attachment/attachment-queries.ts index 02fe6bfe0..731bb2302 100644 --- a/backend/src/modules/attachment/attachment-queries.ts +++ b/backend/src/modules/attachment/attachment-queries.ts @@ -6,10 +6,7 @@ import { attachmentsTable } from '#/modules/attachment/attachment-db'; // Every read and write below carries the request's tenant + organization predicate, so the // result is the same with RLS bypassed; the RLS transaction wrappers stay the backstop. -export const insertAttachments = async ( - ctx: DbContext, - { attachments }: { attachments: (typeof attachmentsTable.$inferInsert)[] }, -) => { +export const insertAttachments = async (ctx: DbContext, { attachments }: { attachments: (typeof attachmentsTable.$inferInsert)[] }) => { const { db } = ctx.var; return db.insert(attachmentsTable).values(attachments).onConflictDoNothing().returning(); }; @@ -36,21 +33,12 @@ interface DeleteAttachmentsByIdsOpts { } /** Soft-deletes the rows and returns them, for the `attachment.deleted` event. */ -export const deleteAttachmentsByIds = async ( - ctx: ActorContext, - { ids, deletedAt, deletedBy }: DeleteAttachmentsByIdsOpts, -) => { +export const deleteAttachmentsByIds = async (ctx: ActorContext, { ids, deletedAt, deletedBy }: DeleteAttachmentsByIdsOpts) => { const { db } = ctx.var; return db .update(attachmentsTable) .set({ deletedAt, deletedBy, updatedAt: deletedAt, updatedBy: deletedBy }) - .where( - and( - inArray(attachmentsTable.id, ids), - requestScopeWhere(ctx, attachmentsTable), - isNull(attachmentsTable.deletedAt), - ), - ) + .where(and(inArray(attachmentsTable.id, ids), requestScopeWhere(ctx, attachmentsTable), isNull(attachmentsTable.deletedAt))) .returning(); }; @@ -64,11 +52,5 @@ export const findAttachmentsByIds = async (ctx: ActorContext, { ids }: FindAttac return db .select() .from(attachmentsTable) - .where( - and( - inArray(attachmentsTable.id, ids), - requestScopeWhere(ctx, attachmentsTable), - isNull(attachmentsTable.deletedAt), - ), - ); + .where(and(inArray(attachmentsTable.id, ids), requestScopeWhere(ctx, attachmentsTable), isNull(attachmentsTable.deletedAt))); }; diff --git a/backend/src/modules/attachment/attachment-routes.ts b/backend/src/modules/attachment/attachment-routes.ts index 9302de8ea..f04f2e19d 100644 --- a/backend/src/modules/attachment/attachment-routes.ts +++ b/backend/src/modules/attachment/attachment-routes.ts @@ -1,12 +1,7 @@ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { actorGuard, orgGuard, tenantGuard } from '#/middlewares/guard'; import { productCache } from '#/middlewares/product-cache'; -import { - bulkPointsLimiter, - presignedUrlLimiter, - singlePointsLimiter, - syncReadLimiter, -} from '#/middlewares/rate-limiter/limiters'; +import { bulkPointsLimiter, presignedUrlLimiter, singlePointsLimiter, syncReadLimiter } from '#/middlewares/rate-limiter/limiters'; import { attachmentCreateManyStxBodySchema, attachmentCreateResponseSchema, @@ -18,240 +13,116 @@ import { } from '#/modules/attachment/attachment-schema'; import { batchResponseSchema, - errorResponseRefs, fullResponseQuerySchema, idInTenantOrgParamSchema, idsWithStxBodySchema, paginationSchema, tenantOrgParamSchema, } from '#/schemas'; -import { - mockAttachmentResponse, - mockBatchAttachmentsResponse, - mockPaginatedAttachmentsResponse, -} from './attachment-mocks'; -import { createAttachmentsOp } from './operations/create-attachments'; -import { deleteAttachmentsOp } from './operations/delete-attachments'; -import { getAttachmentOp } from './operations/get-attachment'; -import { getAttachmentsOp } from './operations/get-attachments'; -import { updateAttachmentOp } from './operations/update-attachment'; +import { mockAttachmentResponse, mockBatchAttachmentsResponse, mockPaginatedAttachmentsResponse } from './attachment-mocks'; -const attachmentRoutes = { - getAttachments: createXRoute({ - operationId: 'getAttachments', - 'x-tool': { - enabled: true, - description: - 'List attachments of the organization with optional search, sorting and paging. Returns metadata and the description as text.', - approvalRequired: false, - category: 'attachments', - entity: 'attachment', - execute: (ctx, { query }) => getAttachmentsOp(ctx, query), - }, +const attachmentRoutes = createXRoutes(['attachments', 'cella', 'product'], { + getAttachments: xRoute({ method: 'get', path: '/', xGuard: [actorGuard, tenantGuard, orgGuard], // Sync-driven read backpressure on the delta path (template pattern for app product lists) xRateLimiter: [syncReadLimiter], - tags: ['attachments', 'cella', 'product'], + xTool: { + description: 'List attachments of the organization with optional search, sorting and paging. Returns metadata and the description as text.', + approvalRequired: false, + entity: 'attachment', + }, summary: 'Get attachments', description: 'Returns a paginated list of attachments for the organization.', - request: { - params: tenantOrgParamSchema, - query: attachmentListQuerySchema, - }, - responses: { - 200: { - description: 'Attachments', - content: { - 'application/json': { - schema: paginationSchema(attachmentSchema), - example: mockPaginatedAttachmentsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, query: attachmentListQuerySchema }, + responses: { 200: json('Attachments', paginationSchema(attachmentSchema), mockPaginatedAttachmentsResponse()) }, }), - createAttachments: createXRoute({ - operationId: 'createAttachments', - 'x-tool': { - enabled: true, - description: - 'Register already uploaded files as attachments. Give each a name, filename, MIME type, size and the storage key of the upload.', - approvalRequired: true, - category: 'attachments', - entity: 'attachment', - execute: (ctx, { body }) => createAttachmentsOp(ctx, body), - }, + createAttachments: xRoute({ method: 'post', path: '/', xGuard: [actorGuard, tenantGuard, orgGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['attachments', 'cella', 'product'], - summary: 'Create attachments', - description: - 'Registers one or more new attachments after client side upload. Includes metadata like name, type, and linked entity.', - request: { - params: tenantOrgParamSchema, - body: { - required: true, - content: { 'application/json': { schema: attachmentCreateManyStxBodySchema } }, - }, + xTool: { + description: 'Register already uploaded files as attachments. Give each a name, filename, MIME type, size and the storage key of the upload.', + approvalRequired: true, + entity: 'attachment', }, + summary: 'Create attachments', + description: 'Registers one or more new attachments after client side upload. Includes metadata like name, type, and linked entity.', + request: { params: tenantOrgParamSchema, body: jsonBody(attachmentCreateManyStxBodySchema) }, responses: { - 200: { - description: 'Attachments already created (idempotent)', - content: { - 'application/json': { schema: attachmentCreateResponseSchema, example: mockBatchAttachmentsResponse() }, - }, - }, - 201: { - description: 'Attachments created', - content: { - 'application/json': { schema: attachmentCreateResponseSchema, example: mockBatchAttachmentsResponse() }, - }, - }, - ...errorResponseRefs, + 200: json('Attachments already created (idempotent)', attachmentCreateResponseSchema, mockBatchAttachmentsResponse()), + 201: json('Attachments created', attachmentCreateResponseSchema, mockBatchAttachmentsResponse()), }, }), - getAttachment: createXRoute({ - operationId: 'getAttachment', - 'x-tool': { - enabled: true, - description: 'Read one attachment: its metadata and the description as text.', - approvalRequired: false, - category: 'attachments', - entity: 'attachment', - execute: (ctx, { params }) => getAttachmentOp(ctx, params.id), - }, + getAttachment: xRoute({ method: 'get', path: '/{id}', xGuard: [actorGuard, tenantGuard, orgGuard], xCache: [productCache('attachment')], - tags: ['attachments', 'cella', 'product'], + xTool: { + description: 'Read one attachment: its metadata and the description as text.', + approvalRequired: false, + entity: 'attachment', + }, summary: 'Get attachment', description: 'Returns a single attachment by ID. Served from the CDC-invalidated entity detail cache.', - request: { - params: idInTenantOrgParamSchema, - }, - responses: { - 200: { - description: 'Attachment', - content: { 'application/json': { schema: attachmentSchema, example: mockAttachmentResponse() } }, - }, - ...errorResponseRefs, - }, + request: { params: idInTenantOrgParamSchema }, + responses: { 200: json('Attachment', attachmentSchema, mockAttachmentResponse()) }, }), - updateAttachment: createXRoute({ - operationId: 'updateAttachment', - 'x-tool': { - enabled: true, - description: 'Rename an attachment or replace its description.', - approvalRequired: true, - category: 'attachments', - entity: 'attachment', - // The transaction is server-built, so field timestamps come from the server clock. - execute: (ctx, { params, body }) => updateAttachmentOp(ctx, params.id, body, { serverOrigin: true }), - }, + updateAttachment: xRoute({ method: 'put', path: '/{id}', xGuard: [actorGuard, tenantGuard, orgGuard], xRateLimiter: [singlePointsLimiter], - tags: ['attachments', 'cella', 'product'], - summary: 'Update attachment', - description: 'Updates metadata of an attachment, such as its name or associated entity.', - request: { - params: idInTenantOrgParamSchema, - query: fullResponseQuerySchema, - body: { - required: true, - content: { 'application/json': { schema: attachmentUpdateStxBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Attachment was updated', - content: { 'application/json': { schema: attachmentSchema, example: mockAttachmentResponse() } }, - }, - ...errorResponseRefs, - }, - }), - deleteAttachments: createXRoute({ - operationId: 'deleteAttachments', - 'x-tool': { - enabled: true, - description: 'Delete attachments by id. The stored files stay in storage.', + xTool: { + description: 'Rename an attachment or replace its description.', approvalRequired: true, - category: 'attachments', entity: 'attachment', - execute: (ctx, { body }) => deleteAttachmentsOp(ctx, Array.isArray(body.ids) ? body.ids : [body.ids]), }, + summary: 'Update attachment', + description: 'Updates metadata of an attachment, such as its name or associated entity.', + request: { params: idInTenantOrgParamSchema, query: fullResponseQuerySchema, body: jsonBody(attachmentUpdateStxBodySchema) }, + responses: { 200: json('Attachment was updated', attachmentSchema, mockAttachmentResponse()) }, + }), + deleteAttachments: xRoute({ method: 'delete', path: '/', xGuard: [actorGuard, tenantGuard, orgGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['attachments', 'cella', 'product'], + xTool: { + description: 'Delete attachments by id. The stored files stay in storage.', + approvalRequired: true, + entity: 'attachment', + }, summary: 'Delete attachments', description: 'Deletes one or more attachment records by ID. This does not delete the underlying file in storage.', - request: { - params: tenantOrgParamSchema, - body: { - required: true, - content: { 'application/json': { schema: idsWithStxBodySchema() } }, - }, - }, - responses: { - 200: { - description: 'Success', - content: { - 'application/json': { - schema: batchResponseSchema(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, body: jsonBody(idsWithStxBodySchema()) }, + responses: { 200: json('Success', batchResponseSchema()) }, }), - getPresignedUrls: createXRoute({ - operationId: 'getPresignedUrls', + getPresignedUrls: xRoute({ method: 'post', path: '/presigned-urls', xGuard: [actorGuard, tenantGuard, orgGuard], xRateLimiter: [presignedUrlLimiter], - tags: ['attachments', 'cella', 'product'], summary: 'Get presigned URLs', description: 'Signs download URLs for up to 50 private attachment files in one call, referenced by id + variant. Missing and denied ids come back in a uniform rejectedIds list (no 403/404 split), and the call succeeds even when every item is rejected. Public files should use the public CDN URL directly. Requires organization context.', - request: { - params: tenantOrgParamSchema, - body: { - required: true, - content: { 'application/json': { schema: presignedUrlsBodySchema } }, - }, - }, + request: { params: tenantOrgParamSchema, body: jsonBody(presignedUrlsBodySchema) }, responses: { - 200: { - description: 'Presigned URLs', - content: { - 'application/json': { - schema: batchResponseSchema(presignedUrlItemSchema), - example: { - data: [ - { - attachmentId: '01890a5d-ac96-774b-b302-0f3e2ae14a2a', - variant: 'thumbnail', - url: 'https://bucket.s3.nl-ams.scw.cloud/key?X-Amz-Signature=…', - }, - ], - rejectedIds: [], - }, + 200: json('Presigned URLs', batchResponseSchema(presignedUrlItemSchema), { + data: [ + { + attachmentId: '01890a5d-ac96-774b-b302-0f3e2ae14a2a', + variant: 'thumbnail', + url: 'https://bucket.s3.nl-ams.scw.cloud/key?X-Amz-Signature=…', }, - }, - }, - ...errorResponseRefs, + ], + rejectedIds: [], + }), }, }), -}; +}); export { attachmentRoutes }; diff --git a/backend/src/modules/attachment/attachment-schema.ts b/backend/src/modules/attachment/attachment-schema.ts index bcc32f868..f887c65dc 100644 --- a/backend/src/modules/attachment/attachment-schema.ts +++ b/backend/src/modules/attachment/attachment-schema.ts @@ -3,10 +3,7 @@ import { schemaTags } from '#/core/openapi-helpers'; import { evolutionContract } from '#/core/schema-evolution/evolution-contract'; import { createInsertSchema, createSelectSchema, describeFields } from '#/db/utils/drizzle-schema'; import { attachmentsTable } from '#/modules/attachment/attachment-db'; -import { - attachmentPlacementFieldsSchema, - validateAttachmentPlacement, -} from '#/modules/attachment/helpers/attachment-placement'; +import { attachmentPlacementFieldsSchema, validateAttachmentPlacement } from '#/modules/attachment/helpers/attachment-placement'; import { productViewCountSchema } from '#/modules/entities/entities-schema'; import { batchResponseSchema, maxLength, paginationQuerySchema, stxBaseSchema, validUuidSchema } from '#/schemas'; import { nullableUserMinimalBaseSchema } from '#/schemas/minimal-base'; @@ -29,21 +26,12 @@ const attachmentFieldDescriptions = { } as const; const keysRefinement = { keys: attachmentKeysSchema }; -const attachmentInsertSchema = describeFields( - createInsertSchema(attachmentsTable, keysRefinement), - attachmentFieldDescriptions, -); -const attachmentSelectSchema = describeFields( - createSelectSchema(attachmentsTable, keysRefinement), - attachmentFieldDescriptions, -); - -// `mentions` is server-owned input to the notification fan-out; the client never reads it. -const { mentions: _mentions, ...attachmentWireShape } = attachmentSelectSchema.shape; +const attachmentInsertSchema = describeFields(createInsertSchema(attachmentsTable, keysRefinement), attachmentFieldDescriptions); +const attachmentSelectSchema = describeFields(createSelectSchema(attachmentsTable, keysRefinement), attachmentFieldDescriptions); export const attachmentSchema = z .object({ - ...attachmentWireShape, + ...attachmentSelectSchema.shape, createdBy: nullableUserMinimalBaseSchema, updatedBy: nullableUserMinimalBaseSchema, stx: stxBaseSchema, @@ -123,19 +111,10 @@ export const attachmentVariantSchema = z.enum(['original', 'preview', 'thumbnail */ export const presignedUrlsBodySchema = z.object({ items: z - .array( - z.object({ - attachmentId: validUuidSchema, - variant: attachmentVariantSchema.default('original'), - }), - ) + .array(z.object({ attachmentId: validUuidSchema, variant: attachmentVariantSchema.default('original') })) .min(1) .max(50), }); /** Missing and denied ids collapse into one `rejectedIds` list, so the two are indistinguishable. */ -export const presignedUrlItemSchema = z.object({ - attachmentId: validUuidSchema, - variant: attachmentVariantSchema, - url: z.string(), -}); +export const presignedUrlItemSchema = z.object({ attachmentId: validUuidSchema, variant: attachmentVariantSchema, url: z.string() }); diff --git a/backend/src/modules/attachment/helpers/attachment-placement.ts b/backend/src/modules/attachment/helpers/attachment-placement.ts index d71ce9253..602bc8a62 100644 --- a/backend/src/modules/attachment/helpers/attachment-placement.ts +++ b/backend/src/modules/attachment/helpers/attachment-placement.ts @@ -20,9 +20,7 @@ import { validUuidSchema } from '#/schemas'; const nullableAncestors = new Set(hierarchy.getNullableAncestors('attachment')); /** Sub-organization ancestors an attachment can home at, deepest first; none in cella. */ // Compared as string so cella's organization-only chain does not infer a `never[]` predicate. -const placementAncestors = hierarchy - .getOrderedAncestors('attachment') - .filter((type) => (type as string) !== 'organization'); +const placementAncestors = hierarchy.getOrderedAncestors('attachment').filter((type) => (type as string) !== 'organization'); const placementKey = (type: string) => appConfig.entityIdColumnKeys[type as ChannelEntityType]; /** @@ -33,10 +31,7 @@ const placementKey = (type: string) => appConfig.entityIdColumnKeys[type as Chan * ancestor column is null. cella has no sub-organization ancestors, so its rows are org-homed. */ export const attachmentPlacementFieldsSchema = Object.fromEntries( - placementAncestors.map((type) => [ - placementKey(type), - nullableAncestors.has(type) ? validUuidSchema.optional() : validUuidSchema, - ]), + placementAncestors.map((type) => [placementKey(type), nullableAncestors.has(type) ? validUuidSchema.optional() : validUuidSchema]), ) as Record>; /** A create-body item as the placement seam sees it; apps narrow to their placement fields. */ @@ -48,25 +43,17 @@ type SubOrgAncestor = Exclude, 'organization'> * Ancestor id columns to stamp on the inserted row, typed like the table columns: strict ancestors * are `string`, nullable ones `string | null`; empty for org-homed rows. */ -export type ResolvedAttachmentPlacement = EntityIdColumns< - Exclude> & EntityType, - string -> & +export type ResolvedAttachmentPlacement = EntityIdColumns> & EntityType, string> & EntityIdColumns> & EntityType, string | null>; const providedHome = (item: AttachmentPlacementInput) => placementAncestors.filter((type) => typeof item[placementKey(type)] === 'string' && item[placementKey(type)]); /** Per-item create-body validation, anchored at the returned path relative to the item: one home id at most. */ -export const validateAttachmentPlacement = ( - item: AttachmentPlacementInput, -): { path: (string | number)[]; message: string } | null => { +export const validateAttachmentPlacement = (item: AttachmentPlacementInput): { path: (string | number)[]; message: string } | null => { const provided = providedHome(item); if (provided.length <= 1) return null; - return { - path: [placementKey(provided[0])], - message: 'Ambiguous placement: send only the deepest home id (its ancestors are derived server-side)', - }; + return { path: [placementKey(provided[0])], message: 'Ambiguous placement: send only the deepest home id (its ancestors are derived server-side)' }; }; /** @@ -74,13 +61,8 @@ export const validateAttachmentPlacement = ( * the request scope, plus that row's own ancestor ids; never client input above the home. No id * means org-homed, which the fields schema only allows when no strict ancestor exists. */ -export const resolveAttachmentPlacement = async ( - ctx: OrgContext, - input: AttachmentPlacementInput, -): Promise => { - const columns: Record = Object.fromEntries( - placementAncestors.map((type) => [placementKey(type), null]), - ); +export const resolveAttachmentPlacement = async (ctx: OrgContext, input: AttachmentPlacementInput): Promise => { + const columns: Record = Object.fromEntries(placementAncestors.map((type) => [placementKey(type), null])); const home = providedHome(input)[0]; if (!home) return columns as ResolvedAttachmentPlacement; @@ -101,13 +83,10 @@ export const resolveAttachmentPlacement = async ( * The channel type attachments home at: the deepest strict ancestor, else the organization. Apps * with nullable placement (rows home at any depth) keep the organization here and read org-wide. */ -const homeChannelType = - hierarchy.getOrderedAncestors('attachment').find((type) => !nullableAncestors.has(type)) ?? 'organization'; +const homeChannelType = hierarchy.getOrderedAncestors('attachment').find((type) => !nullableAncestors.has(type)) ?? 'organization'; /** Column holding a row's home channel id: list reads compile the caller's grant scope against it. */ -export const attachmentHomeColumnKey = appConfig.entityIdColumnKeys[ - homeChannelType -] as keyof typeof attachmentsTable.$inferSelect; +export const attachmentHomeColumnKey = appConfig.entityIdColumnKeys[homeChannelType] as keyof typeof attachmentsTable.$inferSelect; /** * Home channel a list or delta read narrows to, from the `channelId` query param; undefined reads @@ -115,10 +94,7 @@ export const attachmentHomeColumnKey = appConfig.entityIdColumnKeys[ * home type inside the request scope. With the organization as home there is no narrower channel, so * other ids are unknown. */ -export const resolveAttachmentHomeScope = async ( - ctx: OrgContext, - channelId: string | undefined, -): Promise => { +export const resolveAttachmentHomeScope = async (ctx: OrgContext, channelId: string | undefined): Promise => { if (!channelId || channelId === ctx.var.organization.id) return undefined; // Compared as string so cella's organization-only hierarchy does not narrow `homeChannelType` to never. if ((homeChannelType as string) === 'organization') { @@ -139,10 +115,7 @@ export interface AttachmentSeedPlacement { // fork: raak seeds one batch per project, mirroring the project's publicity onto its attachments /** One batch per seeded project, mirroring the project's publicity onto its attachments. */ -export const seedAttachmentPlacements = async ( - db: DB, - organizations: { id: string; tenantId: string }[], -): Promise => { +export const seedAttachmentPlacements = async (db: DB, organizations: { id: string; tenantId: string }[]): Promise => { const organizationIds = new Set(organizations.map((org) => org.id)); const projects = await db .select({ diff --git a/backend/src/modules/attachment/helpers/signed-url.test.ts b/backend/src/modules/attachment/helpers/signed-url.test.ts index b264a817a..30c2cf3c6 100644 --- a/backend/src/modules/attachment/helpers/signed-url.test.ts +++ b/backend/src/modules/attachment/helpers/signed-url.test.ts @@ -1,18 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; // The presigner signs locally (HMAC), so fake credentials produce a real URL offline. -vi.mock('#/env', () => ({ - env: { S3_ACCESS_KEY_ID: 'test-access-key', S3_ACCESS_KEY_SECRET: 'test-secret' }, -})); +vi.mock('#/env', () => ({ env: { S3_ACCESS_KEY_ID: 'test-access-key', S3_ACCESS_KEY_SECRET: 'test-secret' } })); const { getSignedUrlFromKey } = await import('./signed-url'); describe('getSignedUrlFromKey', () => { it('signs private keys with the default 24h expiry', async () => { - const url = await getSignedUrlFromKey('org/attachments/original/a.jpg', { - publicBucket: false, - bucketName: 'private-bucket', - }); + const url = await getSignedUrlFromKey('org/attachments/original/a.jpg', { publicBucket: false, bucketName: 'private-bucket' }); const parsed = new URL(url); expect(parsed.pathname).toContain('org/attachments/original/a.jpg'); @@ -22,11 +17,7 @@ describe('getSignedUrlFromKey', () => { }); it('honors an explicit expiresIn', async () => { - const url = await getSignedUrlFromKey('key.png', { - publicBucket: false, - bucketName: 'private-bucket', - expiresIn: 300, - }); + const url = await getSignedUrlFromKey('key.png', { publicBucket: false, bucketName: 'private-bucket', expiresIn: 300 }); expect(new URL(url).searchParams.get('X-Amz-Expires')).toBe('300'); }); @@ -36,15 +27,9 @@ describe('getSignedUrlFromKey', () => { }); it('must not sign a blob: key, a local blob URL included', async () => { - for (const key of [ - 'blob:http://localhost:3000/0199a1b2-c3d4-7e5f-8a6b-7c8d9e0f1a2c', - 'blob:/../org/contract.pdf', - ]) { + for (const key of ['blob:http://localhost:3000/0199a1b2-c3d4-7e5f-8a6b-7c8d9e0f1a2c', 'blob:/../org/contract.pdf']) { for (const publicBucket of [false, true]) { - await expect( - getSignedUrlFromKey(key, { publicBucket, bucketName: 'private-bucket' }), - key, - ).rejects.toMatchObject({ + await expect(getSignedUrlFromKey(key, { publicBucket, bucketName: 'private-bucket' }), key).rejects.toMatchObject({ status: 500, type: 'server_error', }); @@ -58,9 +43,7 @@ describe('getSignedUrlFromKey', () => { vi.doMock('#/env', () => ({ env: { S3_ACCESS_KEY_ID: '', S3_ACCESS_KEY_SECRET: '' } })); const { getSignedUrlFromKey: signUnconfigured } = await import('./signed-url'); - await expect( - signUnconfigured('key.png', { publicBucket: false, bucketName: 'private-bucket' }), - ).rejects.toMatchObject({ + await expect(signUnconfigured('key.png', { publicBucket: false, bucketName: 'private-bucket' })).rejects.toMatchObject({ status: 503, type: 'server_error', }); diff --git a/backend/src/modules/attachment/helpers/signed-url.ts b/backend/src/modules/attachment/helpers/signed-url.ts index cf96417f4..57e130a08 100644 --- a/backend/src/modules/attachment/helpers/signed-url.ts +++ b/backend/src/modules/attachment/helpers/signed-url.ts @@ -41,10 +41,7 @@ interface GetUrlOptions { * presigned for `expiresIn` seconds (default 24h). A `blob:` key names no stored object, only a * browser's local file: it throws, and is never signed or turned into a bucket URL. */ -export async function getSignedUrlFromKey( - Key: string, - { publicBucket, bucketName, expiresIn = 86400 }: GetUrlOptions, -): Promise { +export async function getSignedUrlFromKey(Key: string, { publicBucket, bucketName, expiresIn = 86400 }: GetUrlOptions): Promise { if (Key.startsWith('blob:')) { throw new AppError(500, 'server_error', 'error', { message: 'A blob: key names no stored object to sign' }); } diff --git a/backend/src/modules/attachment/helpers/storage-key.ts b/backend/src/modules/attachment/helpers/storage-key.ts index 43a1b4274..b193c3d0a 100644 --- a/backend/src/modules/attachment/helpers/storage-key.ts +++ b/backend/src/modules/attachment/helpers/storage-key.ts @@ -11,11 +11,7 @@ export const isLocalBlobUrl = (key: string): boolean => { if (!key.startsWith('blob:')) return false; try { const url = new URL(key.slice('blob:'.length)); - return ( - (url.protocol === 'http:' || url.protocol === 'https:') && - isUuid(url.pathname.slice(1)) && - key === `blob:${url.origin}${url.pathname}` - ); + return (url.protocol === 'http:' || url.protocol === 'https:') && isUuid(url.pathname.slice(1)) && key === `blob:${url.origin}${url.pathname}`; } catch { return false; } @@ -30,9 +26,7 @@ const isLocalKey = (key: string) => key === '' || isLocalBlobUrl(key); * tenant's object. */ export function namesOwnStorage(keys: AttachmentKeys, organizationId: string): boolean { - return Object.values(keys).every( - (key) => key === undefined || isLocalKey(key) || isOrganizationKey(key, organizationId), - ); + return Object.values(keys).every((key) => key === undefined || isLocalKey(key) || isOrganizationKey(key, organizationId)); } /** diff --git a/backend/src/modules/attachment/operations/create-attachments.ts b/backend/src/modules/attachment/operations/create-attachments.ts index d9e8e798f..70aafa853 100644 --- a/backend/src/modules/attachment/operations/create-attachments.ts +++ b/backend/src/modules/attachment/operations/create-attachments.ts @@ -32,10 +32,7 @@ export async function createAttachmentsOp(ctx: OrgContext, rawInput: CreateAttac const existing = await checkIdempotency(ctx, attachmentsTable, batchStxId); if (existing) return { data: await withAuditUsers(ctx, existing), rejectedIds: [] as string[] }; - const currentAttachments = await getOrganizationEntityCount(ctx, { - organizationId: organization.id, - entityType: 'attachment', - }); + const currentAttachments = await getOrganizationEntityCount(ctx, { organizationId: organization.id, entityType: 'attachment' }); if (attachmentRestrictions !== 0 && currentAttachments + input.length > attachmentRestrictions) { throw new AppError(429, 'restrict_by_org', 'warn', { entityType: 'attachment' }); @@ -77,7 +74,7 @@ export async function createAttachmentsOp(ctx: OrgContext, rawInput: CreateAttac const createdAttachments = await tenantContext(ctx, async (txCtx) => { const rows = await insertAttachments(txCtx, { attachments: attachmentsToInsert }); - // Inside the transaction, so handlers such as mention derivation join the write. + // Inside the transaction, so mutation handlers join the write. await dispatchMutation(txCtx, 'attachment.created', { after: rows }); return rows; }); diff --git a/backend/src/modules/attachment/operations/delete-attachments.ts b/backend/src/modules/attachment/operations/delete-attachments.ts index 3d51282d9..85459ec47 100644 --- a/backend/src/modules/attachment/operations/delete-attachments.ts +++ b/backend/src/modules/attachment/operations/delete-attachments.ts @@ -6,10 +6,7 @@ import { splitByPermission } from '#/permissions/split-by-permission'; import { getIsoDate } from '#/utils/iso-date'; import { log } from '#/utils/logger'; -export async function deleteAttachmentsOp( - ctx: ActorContext, - ids: string[], -): Promise<{ data: []; rejectedIds: string[] }> { +export async function deleteAttachmentsOp(ctx: ActorContext, ids: string[]): Promise<{ data: []; rejectedIds: string[] }> { const { allowedIds, rejectedIds } = await splitByPermission(ctx, 'delete', 'attachment', ids); const deletedAt = getIsoDate(); const deletedBy = ctx.var.actor.id; diff --git a/backend/src/modules/attachment/operations/get-attachments.ts b/backend/src/modules/attachment/operations/get-attachments.ts index fea06019e..2b8fa95e3 100644 --- a/backend/src/modules/attachment/operations/get-attachments.ts +++ b/backend/src/modules/attachment/operations/get-attachments.ts @@ -8,11 +8,7 @@ import { requestScopeWhere } from '#/db/utils/request-scope'; import { attachmentsTable } from '#/modules/attachment/attachment-db'; import type { attachmentListQuerySchema } from '#/modules/attachment/attachment-schema'; import { attachmentHomeColumnKey, resolveAttachmentHomeScope } from '#/modules/attachment/helpers/attachment-placement'; -import { - getOrganizationEntityCount, - productViewCountJoin, - productViewCountSelect, -} from '#/modules/entities/entities-queries'; +import { getOrganizationEntityCount, productViewCountJoin, productViewCountSelect } from '#/modules/entities/entities-queries'; import { productCountersTable } from '#/modules/entities/product-counters-db'; import { auditUserSelect, coalesceAuditUsers, createdByUser, updatedByUser } from '#/modules/user/helpers/audit-user'; import { actorFrom } from '#/permissions/access'; @@ -39,12 +35,7 @@ export async function getAttachmentsOp(ctx: OrgContext, input: GetAttachmentsInp actor, homeChannelId ? { homeChannelId } : undefined, ); - const scopeWhere = buildCollectionReadWhere( - readFilter, - attachmentsTable, - attachmentsTable[attachmentHomeColumnKey], - actor, - ); + const scopeWhere = buildCollectionReadWhere(readFilter, attachmentsTable, attachmentsTable[attachmentHomeColumnKey], actor); if (scopeWhere.kind === 'none') { return { items: [], total: 0 }; @@ -86,11 +77,7 @@ export async function getAttachmentsOp(ctx: OrgContext, input: GetAttachmentsInp sort, order, fallback: ['createdAt', 'desc'], - columns: { - name: attachmentsTable.name, - createdAt: attachmentsTable.createdAt, - contentType: attachmentsTable.contentType, - }, + columns: { name: attachmentsTable.name, createdAt: attachmentsTable.createdAt, contentType: attachmentsTable.contentType }, tieBreaker: attachmentsTable.id, }); @@ -108,11 +95,7 @@ export async function getAttachmentsOp(ctx: OrgContext, input: GetAttachmentsInp const whereClause = and(...filters); const itemsQuery = db - .select({ - ...attachmentCols, - ...auditUserSelect, - viewCount: productViewCountSelect(), - }) + .select({ ...attachmentCols, ...auditUserSelect, viewCount: productViewCountSelect() }) .from(attachmentsTable) .leftJoin(productCountersTable, productViewCountJoin(attachmentsTable.id)) .leftJoin(createdByUser, eq(createdByUser.id, attachmentsTable.createdBy)) @@ -125,10 +108,7 @@ export async function getAttachmentsOp(ctx: OrgContext, input: GetAttachmentsInp const totalSource: ListTotalSource = isDelta ? { kind: 'pageLength' } : counterEligible - ? { - kind: 'counter', - getTotal: () => getOrganizationEntityCount(readCtx, { organizationId, entityType: 'attachment' }), - } + ? { kind: 'counter', getTotal: () => getOrganizationEntityCount(readCtx, { organizationId, entityType: 'attachment' }) } : { kind: 'exact', getTotal: async () => { diff --git a/backend/src/modules/attachment/operations/get-presigned-urls.test.ts b/backend/src/modules/attachment/operations/get-presigned-urls.test.ts index e5fbbfdce..d1da65e69 100644 --- a/backend/src/modules/attachment/operations/get-presigned-urls.test.ts +++ b/backend/src/modules/attachment/operations/get-presigned-urls.test.ts @@ -3,24 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { UserContext } from '#/core/context'; // Boundaries mocked: the RLS transaction passes through; DB query, signer and permission are stubbed. -vi.mock('#/db/tenant-context', () => ({ - tenantRead: (ctx: UserContext, fn: (c: UserContext) => unknown) => fn(ctx), -})); +vi.mock('#/db/tenant-context', () => ({ tenantRead: (ctx: UserContext, fn: (c: UserContext) => unknown) => fn(ctx) })); const findAttachmentsByIds = vi.fn(); -vi.mock('#/modules/attachment/attachment-queries', () => ({ - findAttachmentsByIds: (...args: unknown[]) => findAttachmentsByIds(...args), -})); +vi.mock('#/modules/attachment/attachment-queries', () => ({ findAttachmentsByIds: (...args: unknown[]) => findAttachmentsByIds(...args) })); const getSignedUrlFromKey = vi.fn(); -vi.mock('#/modules/attachment/helpers/signed-url', () => ({ - getSignedUrlFromKey: (...args: unknown[]) => getSignedUrlFromKey(...args), -})); +vi.mock('#/modules/attachment/helpers/signed-url', () => ({ getSignedUrlFromKey: (...args: unknown[]) => getSignedUrlFromKey(...args) })); const checkAccessBatch = vi.fn(); vi.mock('#/permissions', () => ({ checkAccessBatch: (...args: unknown[]) => checkAccessBatch(...args) })); vi.mock('#/permissions/access', () => ({ accessFrom: () => ({ actorId: 'user-1', memberships: [] }) })); const buildSubjectFromEntity = vi.fn(); -vi.mock('#/permissions/build-subject', () => ({ - buildSubjectFromEntity: (...args: unknown[]) => buildSubjectFromEntity(...args), -})); +vi.mock('#/permissions/build-subject', () => ({ buildSubjectFromEntity: (...args: unknown[]) => buildSubjectFromEntity(...args) })); const { getPresignedUrlsOp } = await import('./get-presigned-urls'); @@ -32,20 +24,14 @@ const attachmentA = { organizationId: 'org-1', createdBy: 'user-1', bucketName: appConfig.s3.privateBucket, - keys: { - original: 'org-1/user-1/a.jpg', - preview: 'org-1/user-1/a-preview.jpg', - }, + keys: { original: 'org-1/user-1/a.jpg', preview: 'org-1/user-1/a-preview.jpg' }, }; const attachmentB = { id: 'att-b', organizationId: 'org-1', createdBy: 'user-2', bucketName: appConfig.s3.privateBucket, - keys: { - original: 'org-1/user-2/b.jpg', - converted: 'org-1/user-2/b.pdf', - }, + keys: { original: 'org-1/user-2/b.jpg', converted: 'org-1/user-2/b.pdf' }, }; /** Allow every subject the engine sees, keyed like the real BatchPermissionResult. */ @@ -78,16 +64,8 @@ describe('getPresignedUrlsOp: fail-closed batch signing', () => { expect(getSignedUrlFromKey).toHaveBeenCalledTimes(2); expect(res).toEqual({ data: [ - { - attachmentId: 'att-a', - variant: 'preview', - url: `https://signed.example/${attachmentA.keys.preview}`, - }, - { - attachmentId: 'att-b', - variant: 'original', - url: `https://signed.example/${attachmentB.keys.original}`, - }, + { attachmentId: 'att-a', variant: 'preview', url: `https://signed.example/${attachmentA.keys.preview}` }, + { attachmentId: 'att-b', variant: 'original', url: `https://signed.example/${attachmentB.keys.original}` }, ], rejectedIds: [], }); @@ -109,10 +87,7 @@ describe('getPresignedUrlsOp: fail-closed batch signing', () => { const res = await getPresignedUrlsOp(ctx, { items: [{ attachmentId: 'att-a', variant: 'converted' }] }); - expect(getSignedUrlFromKey).toHaveBeenCalledWith(attachmentA.keys.original, { - bucketName: appConfig.s3.privateBucket, - publicBucket: false, - }); + expect(getSignedUrlFromKey).toHaveBeenCalledWith(attachmentA.keys.original, { bucketName: appConfig.s3.privateBucket, publicBucket: false }); expect(res.data[0]?.variant).toBe('converted'); }); @@ -152,10 +127,7 @@ describe('getPresignedUrlsOp: fail-closed batch signing', () => { }); it('rejects a whole id when a requested variant names storage outside its organization, never signing it', async () => { - const planted = { - ...attachmentB, - keys: { original: attachmentB.keys.original, preview: 'org-2/user-9/secret.jpg' }, - }; + const planted = { ...attachmentB, keys: { original: attachmentB.keys.original, preview: 'org-2/user-9/secret.jpg' } }; const foreignBucket = { ...attachmentA, id: 'att-c', bucketName: 'another-apps-bucket' }; findAttachmentsByIds.mockResolvedValue([attachmentA, planted, foreignBucket]); allowAll([attachmentA, planted, foreignBucket]); diff --git a/backend/src/modules/attachment/operations/get-presigned-urls.ts b/backend/src/modules/attachment/operations/get-presigned-urls.ts index 17856b8b4..ea1fe0391 100644 --- a/backend/src/modules/attachment/operations/get-presigned-urls.ts +++ b/backend/src/modules/attachment/operations/get-presigned-urls.ts @@ -3,11 +3,7 @@ import type { UserContext } from '#/core/context'; import { tenantRead } from '#/db/tenant-context'; import type { AttachmentModel } from '#/modules/attachment/attachment-db'; import { findAttachmentsByIds } from '#/modules/attachment/attachment-queries'; -import type { - attachmentVariantSchema, - presignedUrlItemSchema, - presignedUrlsBodySchema, -} from '#/modules/attachment/attachment-schema'; +import type { attachmentVariantSchema, presignedUrlItemSchema, presignedUrlsBodySchema } from '#/modules/attachment/attachment-schema'; import { getSignedUrlFromKey } from '#/modules/attachment/helpers/signed-url'; import { isSignableKey } from '#/modules/attachment/helpers/storage-key'; import { checkAccessBatch } from '#/permissions'; @@ -24,8 +20,7 @@ interface PresignedUrlsResult { } /** Resolved from the row, never client input; an ungenerated variant falls back to `original`. */ -const selectVariantKey = (attachment: AttachmentModel, variant: AttachmentVariant): string => - attachment.keys[variant] ?? attachment.keys.original; +const selectVariantKey = (attachment: AttachmentModel, variant: AttachmentVariant): string => attachment.keys[variant] ?? attachment.keys.original; /** * Signs private-bucket download URLs for up to 50 attachments the caller may read. diff --git a/backend/src/modules/attachment/operations/update-attachment.ts b/backend/src/modules/attachment/operations/update-attachment.ts index d90259513..2734e6e5c 100644 --- a/backend/src/modules/attachment/operations/update-attachment.ts +++ b/backend/src/modules/attachment/operations/update-attachment.ts @@ -1,4 +1,5 @@ import type { z } from '@hono/zod-openapi'; +import { deriveDocument } from 'shared/utils/derive-description-core'; import type { ActorContext } from '#/core/context'; import { tenantContext } from '#/db/tenant-context'; import { dispatchMutation } from '#/lib/mutation-bus'; @@ -6,7 +7,6 @@ import { updateAttachment } from '#/modules/attachment/attachment-queries'; import { attachmentContract, type attachmentUpdateStxBodySchema } from '#/modules/attachment/attachment-schema'; import { withAuditUser } from '#/modules/user/helpers/audit-user'; import { getValidProduct } from '#/permissions/get-valid-product'; -import { keywordsFromDocument } from '#/utils/description-document'; import { getIsoDate } from '#/utils/iso-date'; import { log } from '#/utils/logger'; @@ -14,7 +14,7 @@ type UpdateAttachmentInput = z.infer; /** * Also the attachment's Yjs materializer: the relay calls it with `materialized` for a collaborative description. - * `serverOrigin` stamps the fields with the server clock, for a transaction the server built (an MCP tool, the relay). + * `serverOrigin` stamps the fields with the server clock, for a transaction the server built (the Yjs relay). */ export async function updateAttachmentOp( ctx: ActorContext, @@ -40,7 +40,7 @@ export async function updateAttachmentOp( ...(resolved.changed ? resolved.values : {}), // A changed document re-derives the search column, on client edits and Yjs materializations alike. ...(resolved.changed && resolved.values.description !== undefined - ? { keywords: keywordsFromDocument(resolved.values.description as string | null) } + ? { keywords: deriveDocument(resolved.values.description as string | null).keywords } : {}), updatedAt: getIsoDate(), updatedBy: actorId, diff --git a/backend/src/modules/auth/auth-events.ts b/backend/src/modules/auth/auth-events.ts index 66b69a961..fdcdae14e 100644 --- a/backend/src/modules/auth/auth-events.ts +++ b/backend/src/modules/auth/auth-events.ts @@ -3,6 +3,6 @@ import type { SessionEndReason } from '#/modules/auth/sessions-db'; /** Auth lifecycle events for other modules; sessions are not CDC-tracked, so they cannot travel the activity bus. Handlers catch their own errors. */ export const authEvents = new EventEmitter<{ - /** Sessions ended through `endSessions` (`all`: every session of the user), so connections bound to them can be closed. */ + /** Sessions revoked through `revokeSessions` (`all`: every session of the user), so their connections can close. */ 'session.revoked': [{ userId: string; sessionIds: string[] | 'all'; reason: SessionEndReason }]; }>(); diff --git a/backend/src/modules/auth/auth-mocks.ts b/backend/src/modules/auth/auth-mocks.ts index 4aaf41681..5a24a3c97 100644 --- a/backend/src/modules/auth/auth-mocks.ts +++ b/backend/src/modules/auth/auth-mocks.ts @@ -2,10 +2,7 @@ import { faker } from '@faker-js/faker'; import { mockPastIsoDate, mockUuid, withFakerSeed } from '#/mocks'; export const mockPasskeyChallengeResponse = (key = 'passkey-challenge:default') => - withFakerSeed(key, () => ({ - challenge: faker.string.alphanumeric(43), - credentialIds: [faker.string.alphanumeric(32)], - })); + withFakerSeed(key, () => ({ challenge: faker.string.alphanumeric(43), credentialIds: [faker.string.alphanumeric(32)] })); export const mockPasskeyResponse = (key = 'passkey:default') => withFakerSeed(key, () => { diff --git a/backend/src/modules/auth/auth-queries.ts b/backend/src/modules/auth/auth-queries.ts index 8d0ab48ac..0c06d99b3 100644 --- a/backend/src/modules/auth/auth-queries.ts +++ b/backend/src/modules/auth/auth-queries.ts @@ -1,74 +1,9 @@ -import { and, eq, getColumns, isNull } from 'drizzle-orm'; +import { and, eq, isNull } from 'drizzle-orm'; import { appConfig } from 'shared'; import type { DbContext } from '#/core/context'; -import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; import { hasLiveInvitationToken } from '#/modules/auth/tokens/tokens-queries'; -import { encryptTotpSecret } from '#/modules/auth/totps/helpers/totp-secret-encryption'; -import { totpsTable } from '#/modules/auth/totps/totps-db'; import { inactiveMembershipsTable } from '#/modules/memberships/inactive-memberships-db'; -interface FindCredentialIdsByUserOpts { - userId: string; -} - -export const findCredentialIdsByUser = async (ctx: DbContext, { userId }: FindCredentialIdsByUserOpts) => { - const { db } = ctx.var; - return db - .select({ credentialId: passkeysTable.credentialId }) - .from(passkeysTable) - .where(eq(passkeysTable.userId, userId)); -}; - -interface FindUserMfaOpts { - userId: string; -} - -export const findExistingTotp = async (ctx: DbContext, { userId }: FindUserMfaOpts) => { - const { db } = ctx.var; - const [existing] = await db.select().from(totpsTable).where(eq(totpsTable.userId, userId)).limit(1); - return existing; -}; - -export const findRemainingMfaMethods = async (ctx: DbContext, { userId }: FindUserMfaOpts) => { - const { db } = ctx.var; - const [passkeys, totps] = await Promise.all([ - db.select().from(passkeysTable).where(eq(passkeysTable.userId, userId)), - db.select().from(totpsTable).where(eq(totpsTable.userId, userId)), - ]); - return { passkeys, totps }; -}; - -interface InsertTotpOpts { - userId: string; - secret: string; - /** The time step of the code that confirmed the setup. */ - lastUsedStep: number; -} - -export const insertTotp = async (ctx: DbContext, { userId, secret, lastUsedStep }: InsertTotpOpts) => { - const { db } = ctx.var; - return db.insert(totpsTable).values({ userId, secret: encryptTotpSecret(secret), lastUsedStep }); -}; - -interface InsertPasskeyOpts { - values: typeof passkeysTable.$inferInsert; -} - -/** - * Insert a passkey and return the created row (excluding credentialId and publicKey), or undefined when its credential - * id is registered already, to this account or another. - */ -export const insertPasskey = async (ctx: DbContext, { values }: InsertPasskeyOpts) => { - const { db } = ctx.var; - const { credentialId: _, publicKey: __, ...passkeySelect } = getColumns(passkeysTable); - const [newPasskey] = await db - .insert(passkeysTable) - .values(values) - .onConflictDoNothing({ target: passkeysTable.credentialId }) - .returning(passkeySelect); - return newPasskey; -}; - interface HasPendingInvitationOpts { email: string; } diff --git a/backend/src/modules/auth/general/general-handlers.ts b/backend/src/modules/auth/general/general-handlers.ts index 32b8c7944..2212e627c 100644 --- a/backend/src/modules/auth/general/general-handlers.ts +++ b/backend/src/modules/auth/general/general-handlers.ts @@ -7,14 +7,15 @@ import { checkIpRateLimitStatus } from '#/middlewares/rate-limiter/helpers'; import { emailEnumLimiter } from '#/middlewares/rate-limiter/limiters'; import { authGeneralRoutes } from '#/modules/auth/general/general-routes'; import { deleteAuthCookie, getAuthCookie } from '#/modules/auth/general/helpers/cookie'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; import { linkHandlers } from '#/modules/auth/general/helpers/link-handlers'; import { isRecognizedBrowser } from '#/modules/auth/general/helpers/recognized-browser'; import { resendInvitationEmail } from '#/modules/auth/general/helpers/resend-invitation'; +import { revokeSessions } from '#/modules/auth/general/helpers/revoke-sessions'; import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; import { readOwnSession, setUserSession } from '#/modules/auth/general/helpers/session'; import { acceptInvitationTokenOp } from '#/modules/auth/general/operations/accept-invitation-token'; import { getTokenDataOp } from '#/modules/auth/general/operations/get-token-data'; +import '#/modules/auth/general/session-listeners'; import { dropHeldMagicLink } from '#/modules/auth/magic/helpers/magic-link-browser'; import { sessionsTable } from '#/modules/auth/sessions-db'; import { readBoundToken, spendCookieToken } from '#/modules/auth/tokens/token-lifecycle'; @@ -95,12 +96,7 @@ app.openapi(authGeneralRoutes.stopImpersonation, async (ctx) => { .where(eq(sessionsTable.id, session.impersonatorSessionId)); if (!admin) throw new AppError(401, 'unauthorized', 'warn'); - await endSessions(ctx, { - userId: session.userId, - sessionIds: [session.id], - reason: 'impersonation_stopped', - by: admin.userId, - }); + await revokeSessions(ctx, { userId: session.userId, sessionIds: [session.id], reason: 'impersonation_stopped', by: admin.userId }); // The admin's session cookie never left this browser: without the impersonation cookie it authenticates again. deleteAuthCookie(ctx, 'impersonation'); @@ -137,14 +133,14 @@ app.openapi(authGeneralRoutes.signOut, async (ctx) => { if (!(await getAuthCookie(ctx, 'session'))) return ctx.body(null, 204); } - // The browser's session cookie goes, and an impersonation layered on it, which `endSessions` ends with it. + // The browser's session cookie goes, and an impersonation layered on it, which `revokeSessions` revokes with it. const sessionToken = await getAuthCookie(ctx, 'session'); deleteAuthCookie(ctx, 'session'); if (await getAuthCookie(ctx, 'impersonation')) deleteAuthCookie(ctx, 'impersonation'); const { session: currentSession } = await readOwnSession(sessionToken); - await endSessions(ctx, { + await revokeSessions(ctx, { userId: currentSession.userId, sessionIds: [currentSession.id], reason: 'sign_out', diff --git a/backend/src/modules/auth/general/general-routes.ts b/backend/src/modules/auth/general/general-routes.ts index bedf756c8..f5a7ad10b 100644 --- a/backend/src/modules/auth/general/general-routes.ts +++ b/backend/src/modules/auth/general/general-routes.ts @@ -1,202 +1,109 @@ import { z } from '@hono/zod-openapi'; -import type { StrategyGate } from '#/core/openapi-extensions'; -import { createXRoute } from '#/core/x-routes'; -import { crossTenantGuard, noImpersonationGuard, publicGuard, sysAdminGuard, userGuard } from '#/middlewares/guard'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { publicGuard, sysAdminGuard, userGuard } from '#/middlewares/guard'; import { isNoBot } from '#/middlewares/is-no-bot'; import { emailEnumLimiter, spamLimiter, tokenLimiter } from '#/middlewares/rate-limiter/limiters'; import { mockTokenDataResponse } from '#/modules/auth/auth-mocks'; import { emailBodySchema, invokableTokenTypes, tokenWithDataSchema } from '#/modules/auth/general/general-schema'; -import { cookieSchema, errorResponseRefs, locationSchema, validIdSchema, validUuidSchema } from '#/schemas'; +import { cookieSchema, locationSchema, validIdSchema, validUuidSchema } from '#/schemas'; import { channelBaseSchema } from '#/schemas/entity-base'; import { mockChannelBase } from '#/schemas/entity-base-mocks'; -/** A magic link belongs to the magic-link method; the other invokable tokens (invitations, verification) to none. */ -const magicLinkStrategy: StrategyGate = (ctx) => (ctx.req.param('type') === 'magic' ? 'magic' : null); - -const authGeneralRoutes = { - health: createXRoute({ +const authGeneralRoutes = createXRoutes(['auth', 'cella'], { + health: xRoute({ operationId: 'getAuthHealth', method: 'get', path: '/health', xGuard: [publicGuard], - tags: ['auth', 'cella'], summary: 'Auth health check', - description: - 'Returns auth health status including whether the client IP is rate-limited for email enumeration protection.', + description: 'Returns auth health status including whether the client IP is rate-limited for email enumeration protection.', responses: { - 200: { - description: 'Auth health status', - content: { - 'application/json': { - schema: z.object({ - restrictedMode: z.boolean(), - retryAfter: z.number().optional(), - }), - }, - }, - }, - ...errorResponseRefs, + 200: json('Auth health status', z.object({ restrictedMode: z.boolean(), retryAfter: z.number().optional() })), }, }), - startImpersonation: createXRoute({ - operationId: 'startImpersonation', + startImpersonation: xRoute({ method: 'post', path: '/impersonation/start', - // The impersonation refusal comes first: under an impersonation the system role check would judge the impersonated - // user, refuse them as no admin and raise a security alert about the admin's own request. - xGuard: [userGuard, noImpersonationGuard, sysAdminGuard], - tags: ['auth', 'cella'], + xGuard: [userGuard, sysAdminGuard], summary: 'Start impersonating', - description: - 'Allows a system admin to impersonate a specific user by ID, returning a temporary impersonation session.', - request: { - body: { required: true, content: { 'application/json': { schema: z.object({ targetUserId: validIdSchema }) } } }, - }, - responses: { - 204: { - description: 'Impersonating', - headers: z.object({ 'Set-Cookie': cookieSchema }), - }, - ...errorResponseRefs, - }, + description: 'Allows a system admin to impersonate a specific user by ID, returning a temporary impersonation session.', + request: { body: jsonBody(z.object({ targetUserId: validIdSchema })) }, + responses: { 204: { description: 'Impersonating', headers: z.object({ 'Set-Cookie': cookieSchema }) } }, }), - stopImpersonation: createXRoute({ - operationId: 'stopImpersonation', + stopImpersonation: xRoute({ method: 'post', path: '/impersonation/stop', xGuard: [userGuard], - tags: ['auth', 'cella'], summary: 'Stop impersonating', description: 'Ends impersonation by clearing the current impersonation session and restoring the admin context.', - responses: { - 204: { description: 'Stopped impersonating' }, - ...errorResponseRefs, - }, + responses: { 204: { description: 'Stopped impersonating' } }, }), - checkEmail: createXRoute({ - operationId: 'checkEmail', + checkEmail: xRoute({ method: 'post', path: '/check-email', xGuard: [publicGuard], xRateLimiter: [emailEnumLimiter], middleware: isNoBot, - tags: ['auth', 'cella'], summary: 'Check email', description: 'Tells whether this browser has signed in to the account with this email address before, by its device cookie. Any other browser gets `recognized: false`, whether or not the address has an account.', - request: { - body: { - required: true, - content: { 'application/json': { schema: emailBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Whether this browser is recognized for the address', - content: { 'application/json': { schema: z.object({ recognized: z.boolean() }) } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(emailBodySchema) }, + responses: { 200: json('Whether this browser is recognized for the address', z.object({ recognized: z.boolean() })) }, }), - invokeToken: createXRoute({ - operationId: 'invokeToken', - 'x-strategy': magicLinkStrategy, + invokeToken: xRoute({ method: 'get', path: '/invoke-token/{type}/{token}', xGuard: [publicGuard], xRateLimiter: [tokenLimiter('token')], middleware: isNoBot, - tags: ['auth', 'cella'], summary: 'Invoke token session', description: "Opens an emailed link of a link-carried token type: a magic link or a provider address verification signs in, a step-up link proves presence on this browser's session, an invitation hands the app a single-use token session in a cookie. Redirects to the app.", - request: { - params: z.object({ type: z.enum(invokableTokenTypes), token: z.string() }), - }, - responses: { - 302: { - description: 'Redirect with token session', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + request: { params: z.object({ type: z.enum(invokableTokenTypes), token: z.string() }) }, + responses: { 302: { description: 'Redirect with token session', headers: locationSchema } }, }), - getTokenData: createXRoute({ - operationId: 'getTokenData', + getTokenData: xRoute({ method: 'get', path: '/token/{type}/{id}', xGuard: [publicGuard], xRateLimiter: [tokenLimiter('token')], middleware: isNoBot, - tags: ['auth', 'cella'], summary: 'Get token data', - description: - 'Get basic token data from single-use token session, It returns basic data if the session is still valid.', - request: { - params: z.object({ type: z.enum(invokableTokenTypes), id: validIdSchema }), - }, - responses: { - 200: { - description: 'Token is valid', - content: { 'application/json': { schema: tokenWithDataSchema, example: mockTokenDataResponse() } }, - }, - ...errorResponseRefs, - }, + description: 'Get basic token data from single-use token session, It returns basic data if the session is still valid.', + request: { params: z.object({ type: z.enum(invokableTokenTypes), id: validIdSchema }) }, + responses: { 200: json('Token is valid', tokenWithDataSchema, mockTokenDataResponse()) }, }), - acceptInvitationToken: createXRoute({ - operationId: 'acceptInvitationToken', + acceptInvitationToken: xRoute({ method: 'post', path: '/invitation-token/accept', - xGuard: [userGuard, crossTenantGuard], + xGuard: [userGuard], xRateLimiter: [tokenLimiter('token')], middleware: isNoBot, - tags: ['auth', 'cella'], summary: 'Accept invitation token as current user', description: 'Accepts the membership invitation held in the single-use token session as the signed-in user, also when it was sent to a different email address. Only an invitation not yet bound to another user can be accepted this way.', request: {}, - responses: { - 200: { - description: 'Invitation was accepted', - content: { 'application/json': { schema: channelBaseSchema, example: mockChannelBase() } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Invitation was accepted', channelBaseSchema, mockChannelBase()) }, }), - resendInvitationWithToken: createXRoute({ - operationId: 'resendInvitationWithToken', + resendInvitationWithToken: xRoute({ method: 'post', path: '/resend-invitation', xGuard: [publicGuard], xRateLimiter: [spamLimiter], - tags: ['auth', 'cella'], summary: 'Resend invitation', description: 'Re-sends a pending invitation, named by the id of one of its tokens, to the address it went to. The fresh link replaces the older ones. Answers 204 whether or not an email went out.', - request: { - body: { required: true, content: { 'application/json': { schema: z.object({ tokenId: validUuidSchema }) } } }, - }, - responses: { - 204: { - description: 'Invitation email sent when the invitation is pending', - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(z.object({ tokenId: validUuidSchema })) }, + responses: { 204: { description: 'Invitation email sent when the invitation is pending' } }, }), - signOut: createXRoute({ - operationId: 'signOut', + signOut: xRoute({ method: 'post', path: '/sign-out', xGuard: [publicGuard], - tags: ['auth', 'cella'], summary: 'Sign out', - description: - 'Signs out the current user: the session is revoked (its row stays for the sessions list) and the cookie is cleared.', - responses: { - 204: { description: 'User signed out' }, - ...errorResponseRefs, - }, + description: 'Signs out the current user: the session is revoked (its row stays for the sessions list) and the cookie is cleared.', + responses: { 204: { description: 'User signed out' } }, }), -}; +}); export { authGeneralRoutes }; diff --git a/backend/src/modules/auth/general/general-schema.ts b/backend/src/modules/auth/general/general-schema.ts index ae0f1fa9a..17e15d7f3 100644 --- a/backend/src/modules/auth/general/general-schema.ts +++ b/backend/src/modules/auth/general/general-schema.ts @@ -6,20 +6,13 @@ import { validEmailSchema } from '#/schemas'; /** Token types invokable via a link: the link-carried ones. A cookie-carried token is never opened as a link. */ export const invokableTokenTypes = linkTokenTypes; -export const emailBodySchema = z.object({ - email: validEmailSchema, -}); +export const emailBodySchema = z.object({ email: validEmailSchema }); export const tokenWithDataSchema = z.object({ email: z.email(), userId: z.string().optional(), inactiveMembershipId: z.string().optional(), // What the invitation grants, so a signed-in visitor can confirm it before accepting as their own account. invitation: z - .object({ - entityType: z.enum(appConfig.channelEntityTypes), - entityName: z.string(), - role: z.enum(roles.all), - inviterName: z.string(), - }) + .object({ entityType: z.enum(appConfig.channelEntityTypes), entityName: z.string(), role: z.enum(roles.all), inviterName: z.string() }) .optional(), }); diff --git a/backend/src/modules/auth/general/helpers/cookie.ts b/backend/src/modules/auth/general/helpers/cookie.ts index 6653ba5ce..32030113b 100644 --- a/backend/src/modules/auth/general/helpers/cookie.ts +++ b/backend/src/modules/auth/general/helpers/cookie.ts @@ -46,8 +46,7 @@ const isLaxCookie = (name: CookieName) => isTokenType(name) ? tokenPolicies[name].sameSite === 'lax' : laxCookies.has(name) || name.startsWith('oauth-state-'); /** Effective wire name: hono prepends `__Host-` when the prefix option is active. For consumers naming the cookie outside this helper. */ -export const authCookieName = (name: CookieName) => - `${prefix === 'host' ? '__Host-' : ''}${appConfig.slug}-${name}-${appConfig.cookieVersion}`; +export const authCookieName = (name: CookieName) => `${prefix === 'host' ? '__Host-' : ''}${appConfig.slug}-${name}-${appConfig.cookieVersion}`; const versionedCookieName = (name: CookieName) => `${appConfig.slug}-${name}-${appConfig.cookieVersion}`; @@ -78,9 +77,7 @@ const openAuthCookie = (name: CookieName, sealed: string): string | undefined => const presented = sealed.slice(macAt + 1); const versionedName = versionedCookieName(name); - const valid = cookieSecrets.some((secret) => - safeEqual(presented, cookieMac(secret, versionedName, expiresAt, content)), - ); + const valid = cookieSecrets.some((secret) => safeEqual(presented, cookieMac(secret, versionedName, expiresAt, content))); return valid ? content : undefined; }; diff --git a/backend/src/modules/auth/general/helpers/device-info.test.ts b/backend/src/modules/auth/general/helpers/device-info.test.ts new file mode 100644 index 000000000..5d301c5d3 --- /dev/null +++ b/backend/src/modules/auth/general/helpers/device-info.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { type ClientHints, type DeviceInfo, parseDevice } from '#/modules/auth/general/helpers/device-info'; + +const ua = { + chromeWindows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + chromeLinux: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + chromeAndroid: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36', +}; + +const device = (name: string | null, type: DeviceInfo['type'], os: string | null, browser: string | null): DeviceInfo => ({ + name, + type, + os, + browser, +}); + +// Real User-Agent strings, current as of 2026. Android UAs since Chrome 110 carry the reduced "Android 10; K". +const userAgents: [string, string | undefined, DeviceInfo][] = [ + ['Chrome on Windows', ua.chromeWindows, device(null, 'desktop', 'Windows', 'Chrome')], + [ + 'Edge on Windows', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0', + device(null, 'desktop', 'Windows', 'Edge'), + ], + [ + 'Firefox on Windows', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0', + device(null, 'desktop', 'Windows', 'Firefox'), + ], + [ + 'Opera on Windows', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 OPR/123.0.0.0', + device(null, 'desktop', 'Windows', 'Opera'), + ], + [ + 'Chrome on macOS', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + device('Apple Macintosh', 'desktop', 'macOS', 'Chrome'), + ], + [ + 'Safari on macOS, also an iPad by default', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15', + device('Apple Macintosh', 'desktop', 'macOS', 'Safari'), + ], + [ + 'Firefox on macOS', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0', + device('Apple Macintosh', 'desktop', 'macOS', 'Firefox'), + ], + ['Chrome on Linux', ua.chromeLinux, device(null, 'desktop', 'Linux', 'Chrome')], + [ + 'Firefox on Ubuntu', + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0', + device(null, 'desktop', 'Linux', 'Firefox'), + ], + [ + 'Chrome on ChromeOS', + 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + device(null, 'desktop', 'ChromeOS', 'Chrome'), + ], + ['Chrome on Android, reduced', ua.chromeAndroid, device(null, 'mobile', 'Android', 'Chrome')], + [ + 'Chrome on an Android tablet', + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + device(null, 'desktop', 'Android', 'Chrome'), + ], + [ + 'Chrome on Android before UA reduction', + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Mobile Safari/537.36', + device('Pixel 7', 'mobile', 'Android', 'Chrome'), + ], + [ + 'Edge on Android', + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36 EdgA/140.0.0.0', + device(null, 'mobile', 'Android', 'Edge'), + ], + [ + 'Opera on Android', + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36 OPR/91.0.0.0', + device(null, 'mobile', 'Android', 'Opera'), + ], + [ + 'Samsung Internet, reduced', + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/28.0 Chrome/130.0.0.0 Mobile Safari/537.36', + device(null, 'mobile', 'Android', 'Samsung Internet'), + ], + [ + 'Samsung Internet with a model', + 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36', + device('SM-S918B', 'mobile', 'Android', 'Samsung Internet'), + ], + ['Firefox on Android', 'Mozilla/5.0 (Android 15; Mobile; rv:143.0) Gecko/143.0 Firefox/143.0', device(null, 'mobile', 'Android', 'Firefox')], + [ + 'Android WebView', + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP2A.240805.005; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/127.0.6533.103 Mobile Safari/537.36', + device('Pixel 8', 'mobile', 'Android', 'Android WebView'), + ], + [ + 'Safari on iPhone', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1', + device('Apple iPhone', 'mobile', 'iOS', 'Safari'), + ], + [ + 'Chrome on iPhone', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.7339.122 Mobile/15E148 Safari/604.1', + device('Apple iPhone', 'mobile', 'iOS', 'Chrome'), + ], + [ + 'Firefox on iPhone', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/143.0 Mobile/15E148 Safari/605.1.15', + device('Apple iPhone', 'mobile', 'iOS', 'Firefox'), + ], + [ + 'Edge on iPhone', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 EdgiOS/140.0.3485.94 Mobile/15E148 Safari/605.1.15', + device('Apple iPhone', 'mobile', 'iOS', 'Edge'), + ], + [ + 'Safari on iPad, mobile site', + 'Mozilla/5.0 (iPad; CPU OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1', + device('Apple iPad', 'desktop', 'iPadOS', 'Safari'), + ], + ['curl', 'curl/8.7.1', device(null, 'desktop', null, null)], + ['no User-Agent', undefined, device(null, 'desktop', null, null)], +]; + +describe('parseDevice', () => { + it.each(userAgents)('%s', (_label, userAgent, expected) => { + expect(parseDevice(userAgent)).toEqual(expected); + }); + + // Chromium sends these three hints by default; values are structured-header strings, quotes included. + const hinted: [string, string, ClientHints, DeviceInfo][] = [ + [ + 'Brave, which sends a Chrome UA', + ua.chromeWindows, + { brands: '"Chromium";v="140", "Brave";v="140", "Not=A?Brand";v="24"', mobile: '?0', platform: '"Windows"' }, + device(null, 'desktop', 'Windows', 'Brave'), + ], + [ + 'Chrome on Android requesting the desktop site', + ua.chromeLinux, + { brands: '"Google Chrome";v="140", "Chromium";v="140", "Not=A?Brand";v="24"', mobile: '?0', platform: '"Android"' }, + device(null, 'desktop', 'Android', 'Chrome'), + ], + ['the Chrome OS platform name', ua.chromeLinux, { platform: '"Chrome OS"' }, device(null, 'desktop', 'ChromeOS', 'Chrome')], + ['an Unknown platform', ua.chromeAndroid, { platform: '"Unknown"', mobile: '?1' }, device(null, 'mobile', 'Android', 'Chrome')], + ]; + + it.each(hinted)('prefers client hints: %s', (_label, userAgent, hints, expected) => { + expect(parseDevice(userAgent, hints)).toEqual(expected); + }); + + it('stores only fixed labels for crafted hints', () => { + const hints = { brands: 'x'.repeat(100_000), mobile: 'yes', platform: '"constructor"' }; + expect(parseDevice(ua.chromeAndroid, hints)).toEqual(device(null, 'mobile', 'Android', 'Chrome')); + }); + + it('drops a model too long to be real', () => { + const longModel = `Mozilla/5.0 (Linux; Android 14; ${'A'.repeat(300)}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36`; + expect(parseDevice(longModel).name).toBeNull(); + }); + + it('parses a huge header in bounded time', () => { + const junk = `Mozilla/5.0 (Linux; Android 1; ${'a Build/'.repeat(20_000)}`; + const started = performance.now(); + const result = parseDevice(junk); + expect(performance.now() - started).toBeLessThan(100); + for (const value of Object.values(result)) expect(String(value).length).toBeLessThanOrEqual(40); + }); +}); diff --git a/backend/src/modules/auth/general/helpers/device-info.ts b/backend/src/modules/auth/general/helpers/device-info.ts index 3d3b6be81..695c340af 100644 --- a/backend/src/modules/auth/general/helpers/device-info.ts +++ b/backend/src/modules/auth/general/helpers/device-info.ts @@ -1,25 +1,85 @@ import type { Context } from 'hono'; -import { UAParser } from 'ua-parser-js'; import type { Env } from '#/core/context'; -/** Extracts device name, type (mobile/desktop), OS, and browser from the User-Agent header. */ -export const deviceInfo = (ctx: Context) => { - const userAgent = ctx.req.header('User-Agent'); - const { device, os, browser } = UAParser(userAgent); - - const getName = () => { - if (device.model && device.vendor) return `${device.vendor} ${device.model}`; - return device.model || device.vendor || null; - }; - - const getType = (): 'mobile' | 'desktop' => { - return device.type === 'wearable' || device.type === 'mobile' ? 'mobile' : 'desktop'; - }; - - return { - name: getName(), - type: getType(), - os: os.name || null, - browser: browser.name || null, - }; +export type DeviceInfo = { name: string | null; type: 'mobile' | 'desktop'; os: string | null; browser: string | null }; + +/** The raw low-entropy client hint headers Chromium browsers send on every HTTPS request: Sec-CH-UA, -Mobile and -Platform. */ +export type ClientHints = { brands?: string; mobile?: string; platform?: string }; + +// First match wins. Edge, Opera and Samsung Internet add their token to a Chrome UA, Chrome's UA ends in "Safari/", and +// every iOS browser is WebKit with a token of its own. +const browserPatterns: [RegExp, string][] = [ + [/\bEdg(?:e|A|iOS)?\//, 'Edge'], + [/\b(?:OPR|OPiOS|OPT)\//, 'Opera'], + [/\bSamsungBrowser\//, 'Samsung Internet'], + [/\bYaBrowser\//, 'Yandex'], + [/\bVivaldi\//, 'Vivaldi'], + [/\b(?:Firefox|FxiOS)\//, 'Firefox'], + [/; wv\)/, 'Android WebView'], + [/\b(?:CriOS|Chrome|Chromium)\//, 'Chrome'], + [/\bVersion\/[\d.]+.*\bSafari\//, 'Safari'], +]; + +// iPhone UAs contain "like Mac OS X" and Android UAs contain "Linux", so the specific patterns come first. +const osPatterns: [RegExp, string][] = [ + [/\b(?:iPhone|iPod)\b/, 'iOS'], + [/\biPad\b/, 'iPadOS'], + [/\bAndroid\b/, 'Android'], + [/\bCrOS\b/, 'ChromeOS'], + [/\bWindows\b/, 'Windows'], + [/\bMac OS X\b/, 'macOS'], + [/\b(?:Linux|X11)\b/, 'Linux'], +]; + +// Sec-CH-UA lists the browser's own brand next to "Chromium" and a made-up one; Brave is only told apart here. +const hintBrands: [string, string][] = [ + ['Microsoft Edge', 'Edge'], + ['Opera', 'Opera'], + ['Brave', 'Brave'], + ['Samsung Internet', 'Samsung Internet'], + ['Android WebView', 'Android WebView'], + ['Google Chrome', 'Chrome'], +]; + +const hintPlatforms = new Map([ + ['Android', 'Android'], + ['Chrome OS', 'ChromeOS'], + ['Chromium OS', 'ChromeOS'], + ['Linux', 'Linux'], + ['macOS', 'macOS'], + ['Windows', 'Windows'], +]); + +const firstMatch = (ua: string, patterns: [RegExp, string][]) => patterns.find(([pattern]) => pattern.test(ua))?.[1] ?? null; + +/** The model an Android UA names, such as "Pixel 7". Reduced Chrome UAs send "K" in that slot and Firefox "Mobile" or "Tablet". */ +const androidModel = (ua: string) => { + const model = /\bAndroid [\d.]+; ([^;)]+?)(?: Build\/[^;)]*)?[;)]/.exec(ua)?.[1]?.trim(); + if (!model || model.length > 40 || /^(?:K|wv|Mobile|Tablet|rv:.*)$/.test(model)) return null; + return model; +}; + +/** + * Device name, type, OS and browser from a User-Agent string, with client hints taking precedence where sent. Every value + * but the Android model is a fixed label, and the model is length-capped, so a crafted header stores nothing unexpected. + * Tablets count as desktop, and an iPad in its default desktop mode reads as a Mac. + */ +export const parseDevice = (userAgent: string | undefined, hints: ClientHints = {}): DeviceInfo => { + const ua = (userAgent ?? '').slice(0, 500); + const platform = hints.platform?.trim().replace(/^"|"$/g, ''); + + const os = (platform && hintPlatforms.get(platform)) || firstMatch(ua, osPatterns); + const browser = hintBrands.find(([brand]) => hints.brands?.includes(`"${brand}"`))?.[1] ?? firstMatch(ua, browserPatterns); + const mobile = hints.mobile === '?1' || (hints.mobile !== '?0' && /\b(?:Mobi|iPhone|iPod)/.test(ua) && !/\b(?:iPad|Tablet)\b/.test(ua)); + const apple = /\b(iPhone|iPad|iPod|Macintosh)\b/.exec(ua)?.[1]; + + return { name: apple ? `Apple ${apple}` : androidModel(ua), type: mobile ? 'mobile' : 'desktop', os, browser }; }; + +/** The requesting device, from its User-Agent header and Chromium's client hints. */ +export const deviceInfo = (ctx: Context) => + parseDevice(ctx.req.header('User-Agent'), { + brands: ctx.req.header('Sec-CH-UA'), + mobile: ctx.req.header('Sec-CH-UA-Mobile'), + platform: ctx.req.header('Sec-CH-UA-Platform'), + }); diff --git a/backend/src/modules/auth/general/helpers/end-sessions.ts b/backend/src/modules/auth/general/helpers/end-sessions.ts deleted file mode 100644 index 9de67973c..000000000 --- a/backend/src/modules/auth/general/helpers/end-sessions.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { and, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm'; -import type { DbContext } from '#/core/context'; -import type { ActorId } from '#/db/utils/ids'; -import { dropCachedAuth, publishAuthInvalidation } from '#/middlewares/guard/invalidate-cache'; -import { authEvents } from '#/modules/auth/auth-events'; -import { - type SessionEndReason, - type SessionModel, - type SessionRevocationReason, - type SessionTypes, - sessionSafeColumns, - sessionsTable, -} from '#/modules/auth/sessions-db'; -import { deleteProviderSessionsOfUser } from '#/modules/oauth-server/oauth-server-queries'; -import { getIsoDate } from '#/utils/iso-date'; -import { log } from '#/utils/logger'; - -/** Which of the user's live sessions end: these ids, or all of them (optionally of one type). */ -type SessionSelection = { sessionIds: string[] } | { all: true; type?: SessionTypes }; - -/** - * Endings where the person leaves (signs out, ends their other sessions, turns MFA on): the authorization server's - * sessions of the user end too, so no browser keeps answering OAuth clients for them. Sign-in housekeeping and a - * stopped impersonation leave them. - */ -const endsProviderSessions = new Set(['sign_out', 'other_session', 'mfa_enabled']); - -export type EndSessionsOpts = SessionSelection & { - userId: string; - reason: SessionEndReason; - /** The actor whose request ends the sessions; null when the server does it during a sign-in. */ - by: ActorId | null; -}; - -/** - * The one way sessions end before their expiry. Stamps the user's selected live sessions with `revokedAt`, - * `revokedBy` and `revocationReason`, drops the user's cached sessions in every process, and closes the streams bound - * to them. A revoked session is never re-stamped, so the first ending is the one the sessions list shows; the row - * stays until the nightly sweep. `user_deleted` follows the delete, which took the rows along: nothing is stamped, - * and every stream of the user closes. An impersonation layered on an ended session ends with it as - * `impersonation_stopped` (a deleted admin's rows take theirs along), since only its admin's session can present it. - * - * The stamps and the `auth_invalidate` message commit together, inside the caller's transaction when there is one; - * this process drops its cache and closes the streams at the call, so call it last in a transaction. - * - * @param ctx - Any context with a database; the sign-in paths pass the base pool. - * @param opts - The user, which sessions (`sessionIds` or `all`), the reason and the acting actor. - * @returns The stamped sessions, secret stripped; empty for `user_deleted` and for sessions that had already ended. - */ -export const endSessions = async (ctx: DbContext, opts: EndSessionsOpts): Promise => { - const { userId, reason, by } = opts; - if ('sessionIds' in opts && opts.sessionIds.length === 0) return []; - - const selection = 'sessionIds' in opts ? inArray(sessionsTable.id, opts.sessionIds) : undefined; - const ofType = 'all' in opts && opts.type ? eq(sessionsTable.type, opts.type) : undefined; - - const { ended, layered } = await ctx.var.db.transaction(async (tx) => { - const stamp = (revocationReason: SessionRevocationReason, where: SQL | undefined) => - tx - .update(sessionsTable) - .set({ revokedAt: getIsoDate(), revokedBy: by, revocationReason }) - .where(and(isNull(sessionsTable.revokedAt), gt(sessionsTable.expiresAt, getIsoDate()), where)) - .returning(sessionSafeColumns); - - const stamped = - reason === 'user_deleted' ? [] : await stamp(reason, and(eq(sessionsTable.userId, userId), selection, ofType)); - const endedIds = stamped.map((session) => session.id); - const stopped = endedIds.length - ? await stamp('impersonation_stopped', inArray(sessionsTable.impersonatorSessionId, endedIds)) - : []; - - if (endsProviderSessions.has(reason)) await deleteProviderSessionsOfUser({ var: { db: tx } }, { userId }); - - for (const user of new Set([userId, ...stopped.map((session) => session.userId)])) { - await publishAuthInvalidation(tx, { user }); - } - return { ended: stamped, layered: stopped }; - }); - - dropCachedAuth({ user: userId }); - - const everySession = 'all' in opts && !opts.type; - if (everySession || ended.length > 0) { - authEvents.emit('session.revoked', { - userId, - sessionIds: everySession ? 'all' : ended.map((session) => session.id), - reason, - }); - } - for (const impersonation of layered) { - dropCachedAuth({ user: impersonation.userId }); - authEvents.emit('session.revoked', { - userId: impersonation.userId, - sessionIds: [impersonation.id], - reason: 'impersonation_stopped', - }); - } - log.info('Sessions ended', { userId, reason, count: ended.length, impersonationsStopped: layered.length }); - - return ended; -}; diff --git a/backend/src/modules/auth/general/helpers/finish-sign-in.ts b/backend/src/modules/auth/general/helpers/finish-sign-in.ts index dd149a37c..ce6a23108 100644 --- a/backend/src/modules/auth/general/helpers/finish-sign-in.ts +++ b/backend/src/modules/auth/general/helpers/finish-sign-in.ts @@ -1,9 +1,9 @@ import type { Context } from 'hono'; import { appConfig } from 'shared'; import type { Env } from '#/core/context'; -import { initiateMfa } from '#/modules/auth/general/helpers/mfa'; import { resolvePostAuthRedirectPath } from '#/modules/auth/general/helpers/redirect-path'; import { setUserSession } from '#/modules/auth/general/helpers/session'; +import { initiateMfa } from '#/modules/auth/mfa/operations/mfa-challenge'; import type { AuthStrategy } from '#/modules/auth/sessions-db'; import type { UserWithCounters } from '#/modules/user/helpers/select'; @@ -11,12 +11,7 @@ import type { UserWithCounters } from '#/modules/user/helpers/select'; * Shared tail of every browser-navigated sign-in flow: start an MFA challenge when required, otherwise set the session, * then 302 to the resolved post-auth path. The redirect is carried into the MFA challenge so it survives it. */ -export const finishSignIn = async ( - ctx: Context, - user: UserWithCounters, - strategy: AuthStrategy, - redirectPath?: string | null, -) => { +export const finishSignIn = async (ctx: Context, user: UserWithCounters, strategy: AuthStrategy, redirectPath?: string | null) => { const mfaRedirectPath = await initiateMfa(ctx, user); const resolvedPath = resolvePostAuthRedirectPath(user, { redirectPath, mfaPath: mfaRedirectPath }); diff --git a/backend/src/modules/auth/general/helpers/link-handlers.ts b/backend/src/modules/auth/general/helpers/link-handlers.ts index bd0fdee02..b7ff43d0c 100644 --- a/backend/src/modules/auth/general/helpers/link-handlers.ts +++ b/backend/src/modules/auth/general/helpers/link-handlers.ts @@ -1,11 +1,9 @@ import type { Context } from 'hono'; import { appConfig } from 'shared'; import type { Env } from '#/core/context'; +import { assertSwitchOn } from '#/middlewares/config-switch'; import { handleMagicLink } from '#/modules/auth/general/helpers/handle-magic'; -import { - explainOpenedMagicLink, - holdMagicLinkOutsideItsBrowser, -} from '#/modules/auth/magic/helpers/magic-link-browser'; +import { explainOpenedMagicLink, holdMagicLinkOutsideItsBrowser } from '#/modules/auth/magic/helpers/magic-link-browser'; import { claimMagicLinkOwner } from '#/modules/auth/magic/helpers/magic-sign-up'; import { handleOAuthVerification } from '#/modules/auth/oauth/helpers/handle-oauth-verification'; import { openStepUpLink } from '#/modules/auth/step-up/helpers/step-up-link'; @@ -21,7 +19,9 @@ type LinkHandler = (ctx: Context, rawToken: string) => Promise; * until it says what its link does, so no link falls through to another type's handling. */ export const linkHandlers = { + // Only a magic link belongs to a sign-in method, so this route's switch is checked here, per token type. magic: async (ctx, rawToken) => { + assertSwitchOn({ strategy: 'magic' }); const held = await holdMagicLinkOutsideItsBrowser(ctx, rawToken); if (held) return held; @@ -32,8 +32,7 @@ export const linkHandlers = { forgetLinkRequest(ctx, 'magic'); return handleMagicLink(ctx, token); }, - 'oauth-verification': async (ctx, rawToken) => - handleOAuthVerification(ctx, await invokeToken(ctx, { type: 'oauth-verification', rawToken })), + 'oauth-verification': async (ctx, rawToken) => handleOAuthVerification(ctx, await invokeToken(ctx, { type: 'oauth-verification', rawToken })), invitation: async (ctx, rawToken) => { const token = await invokeToken(ctx, { type: 'invitation', rawToken }); log.info('Token invoked, redirecting with single use token in cookie', { tokenId: token.id, userId: token.userId }); diff --git a/backend/src/modules/auth/general/helpers/mark-email-verified.ts b/backend/src/modules/auth/general/helpers/mark-email-verified.ts index 1ce96893f..febfd95c7 100644 --- a/backend/src/modules/auth/general/helpers/mark-email-verified.ts +++ b/backend/src/modules/auth/general/helpers/mark-email-verified.ts @@ -44,9 +44,7 @@ export const markEmailVerified = async (db: DbOrTx, { userId, email, via }: Emai /** For flows whose whole purpose is verification: an address the account does not hold fails the request. */ export const requireEmailVerified = async (db: DbOrTx, opts: EmailProofOpts): Promise => { if (await markEmailVerified(db, opts)) return; - throw new AppError(500, 'server_error', 'error', { - meta: { reason: 'verified_address_not_on_account', userId: opts.userId }, - }); + throw new AppError(500, 'server_error', 'error', { meta: { reason: 'verified_address_not_on_account', userId: opts.userId } }); }; /** @@ -59,11 +57,7 @@ export const addProvenEmail = async (db: DbOrTx, { userId, email, via }: EmailPr const [row] = await db .insert(emailsTable) .values({ email, userId, verified: true, verifiedAt: now, lastVerifiedVia: via, lastVerifiedAt: now }) - .onConflictDoUpdate({ - target: emailsTable.email, - set: proofStamps(via, now), - setWhere: eq(emailsTable.userId, userId), - }) + .onConflictDoUpdate({ target: emailsTable.email, set: proofStamps(via, now), setWhere: eq(emailsTable.userId, userId) }) .returning({ id: emailsTable.id }); if (!row) throw new AppError(409, 'oauth_conflict', 'warn'); diff --git a/backend/src/modules/auth/general/helpers/notify-sign-in.ts b/backend/src/modules/auth/general/helpers/notify-sign-in.ts index f02afa5e8..b94b97af2 100644 --- a/backend/src/modules/auth/general/helpers/notify-sign-in.ts +++ b/backend/src/modules/auth/general/helpers/notify-sign-in.ts @@ -2,11 +2,11 @@ import { and, eq, gt } from 'drizzle-orm'; import { appConfig } from 'shared'; import { baseDb as db } from '#/db/db'; import { devicesTable } from '#/modules/auth/devices-db'; -import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { sendAccountSecurityEmail, sendSecurityInboxEmail } from '#/modules/auth/general/helpers/send-account-security-email'; import type { SignInContext } from '#/modules/auth/general/helpers/session'; import type { AuthStrategy } from '#/modules/auth/sessions-db'; import type { UserModel } from '#/modules/user/user-db'; -import { getIsoDate } from '#/utils/iso-date'; +import { getIsoDate, utcStamp } from '#/utils/iso-date'; import { log } from '#/utils/logger'; import { TimeSpan } from '#/utils/time-span'; @@ -28,7 +28,8 @@ const inboxStrategies: AuthStrategy[] = ['magic', 'email']; const NOTICE_BUDGET = 3; const NOTICE_WINDOW = new TimeSpan(24, 'h'); -const strategyLabels: Record = { +/** Sign-in methods as people read them; a provider identity's issuer is its strategy slug. */ +export const strategyLabels: Record = { passkey: 'Passkey', totp: 'Authenticator app', github: 'GitHub', @@ -51,11 +52,7 @@ const countryName = (code: string, language: string) => { export const notifySignIn = ({ user, isSystemAdmin, context, strategy, newDevice }: SignInNotice) => { // A system admin session goes to the security inbox. Skipped in development, where every local sign-in would mail it. if (isSystemAdmin && appConfig.mode !== 'development') { - sendAccountSecurityEmail({ email: appConfig.securityEmail, name: 'Security' }, 'sysadmin-signin', { - email: user.email, - ip: context.rawIp ?? 'unknown', - timestamp: new Date().toISOString(), - }); + sendSecurityInboxEmail('sysadmin-signin', { email: user.email, ip: context.rawIp ?? 'unknown', timestamp: new Date().toISOString() }); } if (newDevice) void notifyNewSignIn({ user, context, strategy, newDevice }); @@ -75,10 +72,8 @@ export const notifyNewSignIn = async ({ try { const since = new Date(Date.now() - NOTICE_WINDOW.milliseconds()).toISOString(); - const sent = await db.$count( - devicesTable, - and(eq(devicesTable.userId, user.id), gt(devicesTable.notifiedAt, since)), - ); + const notifiedRecently = and(eq(devicesTable.userId, user.id), gt(devicesTable.notifiedAt, since)); + const sent = await db.$count(devicesTable, notifiedRecently); if (sent >= NOTICE_BUDGET) { log.info('New sign-in notice skipped: daily budget spent', { userId: user.id }); @@ -91,7 +86,7 @@ export const notifyNewSignIn = async ({ .where(and(eq(devicesTable.userId, user.id), eq(devicesTable.deviceIdHash, newDevice.deviceIdHash))); sendAccountSecurityEmail(user, 'new-sign-in', { - timestamp: `${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`, + timestamp: utcStamp(), browser: context.device.browser ?? 'unknown', os: context.device.os ?? 'unknown', // Omitted when GeoIP has no answer: the template then leaves the location line out entirely. diff --git a/backend/src/modules/auth/general/helpers/resend-invitation.ts b/backend/src/modules/auth/general/helpers/resend-invitation.ts index 7d9d90730..77a2b05fa 100644 --- a/backend/src/modules/auth/general/helpers/resend-invitation.ts +++ b/backend/src/modules/auth/general/helpers/resend-invitation.ts @@ -43,10 +43,7 @@ export const resendInvitationEmail = async (ctx: DbContext, oldToken: TokenRecor .for('update'); if (!invitation) return null; - const entity = await resolveEntity(txCtx, { - entityType: invitation.channelType, - identifier: invitation.channelId, - }); + const entity = await resolveEntity(txCtx, { entityType: invitation.channelType, identifier: invitation.channelId }); if (!entity) return null; // The new token replaces every older token of the invitation. @@ -77,7 +74,7 @@ export const resendInvitationEmail = async (ctx: DbContext, oldToken: TokenRecor if (invitation) { const { entity } = invitation; await sendInvitationMails(ctx, { - sender: sender ?? { name: 'System', thumbnailUrl: null }, + sender: sender ?? { name: 'System' }, channel: { type: invitation.channelType, slug: entity.slug, name: entity.name, role: invitation.role }, // A channel below the organization carries no default language here: the app's applies. organization: 'defaultLanguage' in entity ? entity : null, @@ -85,7 +82,7 @@ export const resendInvitationEmail = async (ctx: DbContext, oldToken: TokenRecor }); log.info('Membership invitation has been resent', { inactiveMembershipId: invitation.id, tokenId }); } else { - await sendInvitationMails(ctx, { sender: sender ?? { name: 'System', thumbnailUrl: null }, invited }); + await sendInvitationMails(ctx, { sender: sender ?? { name: 'System' }, invited }); log.info('System invitation has been resent', { tokenId }); } diff --git a/backend/src/modules/auth/general/helpers/revoke-sessions.ts b/backend/src/modules/auth/general/helpers/revoke-sessions.ts new file mode 100644 index 000000000..23ce5fc95 --- /dev/null +++ b/backend/src/modules/auth/general/helpers/revoke-sessions.ts @@ -0,0 +1,88 @@ +import { and, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm'; +import type { DbContext } from '#/core/context'; +import type { ActorId } from '#/db/utils/ids'; +import { dropCachedSessions } from '#/middlewares/guard/session-cache'; +import { authEvents } from '#/modules/auth/auth-events'; +import { + type SessionEndReason, + type SessionModel, + type SessionRevocationReason, + type SessionTypes, + sessionSafeColumns, + sessionsTable, +} from '#/modules/auth/sessions-db'; +import { deleteProviderSessionsOfUser } from '#/modules/oauth-server/oauth-server-queries'; +import { getIsoDate } from '#/utils/iso-date'; +import { log } from '#/utils/logger'; + +/** Which of the user's live sessions are revoked: these ids, or all of them (optionally of one type). */ +type SessionSelection = { sessionIds: string[] } | { all: true; type?: SessionTypes }; + +/** + * Revocations where the person leaves (signs out, revokes their other sessions, turns MFA on): the authorization + * server's sessions of the user are deleted too, so no browser keeps answering OAuth clients for them. Sign-in + * housekeeping and a stopped impersonation leave them. + */ +const deletesProviderSessions = new Set(['sign_out', 'other_session', 'mfa_enabled']); + +export type RevokeSessionsOpts = SessionSelection & { + userId: string; + reason: SessionEndReason; + /** The actor whose request revokes the sessions; null when the server does it during a sign-in. */ + by: ActorId | null; +}; + +/** + * The one way sessions end before their expiry. Stamps the user's selected live sessions with `revokedAt`, + * `revokedBy` and `revocationReason`, drops the user's cached sessions in this process and closes the streams bound to + * them; other processes stop serving the session within the session cache's 10 seconds. A revoked session is never + * re-stamped, so the first revocation is the one the sessions list shows; the row stays until the nightly sweep. + * `user_deleted` follows the delete, which took the rows along: nothing is stamped, and every stream of the user + * closes. An impersonation layered on a revoked session is revoked with it as `impersonation_stopped` (a deleted admin's + * rows take theirs along), since only its admin's session can present it. + * + * The stamps commit inside the caller's transaction when there is one; the cache drop and the streams happen at the + * call, so call it last in a transaction. + * + * @param ctx - Any context with a database; the sign-in paths pass the base pool. + * @param opts - The user, which sessions (`sessionIds` or `all`), the reason and the acting actor. + * @returns The stamped sessions, secret stripped; empty for `user_deleted` and for sessions that had already ended. + */ +export const revokeSessions = async (ctx: DbContext, opts: RevokeSessionsOpts): Promise => { + const { userId, reason, by } = opts; + if ('sessionIds' in opts && opts.sessionIds.length === 0) return []; + + const selection = 'sessionIds' in opts ? inArray(sessionsTable.id, opts.sessionIds) : undefined; + const ofType = 'all' in opts && opts.type ? eq(sessionsTable.type, opts.type) : undefined; + + const { ended, layered } = await ctx.var.db.transaction(async (tx) => { + const stamp = (revocationReason: SessionRevocationReason, where: SQL | undefined) => + tx + .update(sessionsTable) + .set({ revokedAt: getIsoDate(), revokedBy: by, revocationReason }) + .where(and(isNull(sessionsTable.revokedAt), gt(sessionsTable.expiresAt, getIsoDate()), where)) + .returning(sessionSafeColumns); + + const stamped = reason === 'user_deleted' ? [] : await stamp(reason, and(eq(sessionsTable.userId, userId), selection, ofType)); + const endedIds = stamped.map((session) => session.id); + const stopped = endedIds.length ? await stamp('impersonation_stopped', inArray(sessionsTable.impersonatorSessionId, endedIds)) : []; + + if (deletesProviderSessions.has(reason)) await deleteProviderSessionsOfUser({ var: { db: tx } }, { userId }); + return { ended: stamped, layered: stopped }; + }); + + dropCachedSessions(userId); + for (const impersonation of layered) dropCachedSessions(impersonation.userId); + + const everySession = 'all' in opts && !opts.type; + if (everySession || ended.length > 0) { + const sessionIds = everySession ? 'all' : ended.map((session) => session.id); + authEvents.emit('session.revoked', { userId, sessionIds, reason }); + } + for (const impersonation of layered) { + authEvents.emit('session.revoked', { userId: impersonation.userId, sessionIds: [impersonation.id], reason: 'impersonation_stopped' }); + } + log.info('Sessions revoked', { userId, reason, count: ended.length, impersonationsStopped: layered.length }); + + return ended; +}; diff --git a/backend/src/modules/auth/general/helpers/send-account-security-email.ts b/backend/src/modules/auth/general/helpers/send-account-security-email.ts index 6ee8506c5..ec05ce563 100644 --- a/backend/src/modules/auth/general/helpers/send-account-security-email.ts +++ b/backend/src/modules/auth/general/helpers/send-account-security-email.ts @@ -17,8 +17,10 @@ export const sendAccountSecurityEmail = ( log[type === 'new-sign-in' ? 'info' : 'warn'](`Security email: ${type}`, { email: recipient.email, ...details }); mailer - .prepareEmails(accountSecurityEmail, { name: recipient.name ?? '', type, details }, [ - { email: recipient.email, lng }, - ]) + .prepareEmails(accountSecurityEmail, { name: recipient.name ?? '', type, details }, [{ email: recipient.email, lng }]) .catch((err) => log.error('Failed to send security email', { type, err })); }; + +/** {@link sendAccountSecurityEmail} to the app's security inbox. */ +export const sendSecurityInboxEmail = (type: AccountSecurityType, details?: Record) => + sendAccountSecurityEmail({ email: appConfig.securityEmail, name: 'Security' }, type, details); diff --git a/backend/src/modules/auth/general/helpers/session.ts b/backend/src/modules/auth/general/helpers/session.ts index 5c40f9aaf..c78e8e65d 100644 --- a/backend/src/modules/auth/general/helpers/session.ts +++ b/backend/src/modules/auth/general/helpers/session.ts @@ -7,15 +7,16 @@ import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; import { baseDb as db } from '#/db/db'; import { lookupIp } from '#/lib/geoip'; -import { getSessionCache, type SessionCacheEntry, setSessionCache } from '#/middlewares/guard/auth-cache'; +import { getCachedSession, setCachedSession } from '#/middlewares/guard/session-cache'; +import { actorsTable } from '#/modules/actors/actors-db'; import { deleteAuthCookie, getAuthCookie, setAuthCookie } from '#/modules/auth/general/helpers/cookie'; import { deviceInfo } from '#/modules/auth/general/helpers/device-info'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; import { enrollDevice } from '#/modules/auth/general/helpers/enroll-device'; import { type NewDevice, notifySignIn } from '#/modules/auth/general/helpers/notify-sign-in'; -import { type AuthStrategy, type SessionTypes, sessionFactColumns, sessionsTable } from '#/modules/auth/sessions-db'; +import { revokeSessions } from '#/modules/auth/general/helpers/revoke-sessions'; +import { type AuthStrategy, type SessionFacts, type SessionTypes, sessionFactColumns, sessionsTable } from '#/modules/auth/sessions-db'; import { systemRolesTable } from '#/modules/system/system-roles-db'; -import { userSelect } from '#/modules/user/helpers/select'; +import { type UserWithCounters, userSelect } from '#/modules/user/helpers/select'; import { userCountersTable } from '#/modules/user/user-counters-db'; import { type UserModel, usersTable } from '#/modules/user/user-db'; import { getIp } from '#/utils/get-ip'; @@ -71,7 +72,7 @@ export const evictExcessSessions = async (userId: string): Promise => { if (excess.length === 0) return; const sessionIds = excess.map((s) => s.id); - await endSessions({ var: { db } }, { userId, sessionIds, reason: 'session_cap', by: null }); + await revokeSessions({ var: { db } }, { userId, sessionIds, reason: 'session_cap', by: null }); }; /** What the sign-in request says about the browser and the network. Raw IP and device id stay in memory; only their hashes are stored. */ @@ -161,7 +162,7 @@ export const createSession = async ( // A3: a browser holds at most one live session, so repeated sign-ins do not stack up. if (session.deviceIdHash) { const sessionIds = (await liveOwnSessions(user.id, session.deviceIdHash)).map((s) => s.id); - await endSessions({ var: { db } }, { userId: user.id, sessionIds, reason: 'replaced', by: null }); + await revokeSessions({ var: { db } }, { userId: user.id, sessionIds, reason: 'replaced', by: null }); } await evictExcessSessions(user.id); } @@ -174,10 +175,10 @@ export const createSession = async ( // lastSignInAt lives in user_counters to avoid CDC noise on the users table const lastSignInAt = getIsoDate(); - await db.insert(userCountersTable).values({ userId: user.id, lastSignInAt }).onConflictDoUpdate({ - target: userCountersTable.userId, - set: { lastSignInAt }, - }); + await db + .insert(userCountersTable) + .values({ userId: user.id, lastSignInAt }) + .onConflictDoUpdate({ target: userCountersTable.userId, set: { lastSignInAt } }); return { sessionId, sessionToken, timeSpan, newDevice }; }; @@ -186,12 +187,7 @@ export const createSession = async ( * Signs the user in on this browser: stores a session, sets its cookie and sends the sign-in notices. An impersonation * gets a cookie of its own, layered over the admin's session cookie, which stays: stopping returns the browser to it. */ -export const setUserSession = async ( - ctx: Context, - user: UserModel, - strategy: AuthStrategy, - type: SessionTypes = 'regular', -): Promise => { +export const setUserSession = async (ctx: Context, user: UserModel, strategy: AuthStrategy, type: SessionTypes = 'regular'): Promise => { const isSystemAdmin = await db .select() .from(systemRolesTable) @@ -205,13 +201,7 @@ export const setUserSession = async ( const context = await collectSignInContext(ctx, type); const impersonatorSessionId = type === 'impersonation' ? ctx.var.sessionId : null; - const { sessionToken, timeSpan, newDevice } = await createSession( - user, - context, - strategy, - type, - impersonatorSessionId, - ); + const { sessionToken, timeSpan, newDevice } = await createSession(user, context, strategy, type, impersonatorSessionId); if (type === 'impersonation') await setAuthCookie(ctx, 'impersonation', sessionToken, timeSpan); else { @@ -225,31 +215,41 @@ export const setUserSession = async ( if (type !== 'impersonation') log.info('User signed in', { strategy }); }; +/** A live session as a request presents it, with its user. */ +export interface ResolvedSession { + session: SessionFacts; + user: UserWithCounters; + /** Holds the admin system role. The rights also need an allowlisted request address, checked per request. */ + hasSystemRole: boolean; + /** `actors.bindings_version` at this read: the version the user's cached memberships must match. */ + bindingsVersion: string; +} + /** - * The live session a cookie's token names, with its user and whether the user holds the admin system role: from the - * auth cache, keyed by the token's hash, or else from the database, which stores only that hash. A cached entry - * answers only to the token itself and stops at the session's expiry; endings drop it through `endSessions`. + * The live session a cookie's token names, with its user, whether the user holds the admin system role and the + * version of the user's bindings: from the session cache (`session-cache.ts`), keyed by the token's hash, or else read + * by that hash, the only form the database stores. A cached entry stops at the session's expiry. * @throws AppError 401 `no_session` for an unknown token, `session_revoked` or `session_expired`. */ -export const readSession = async (sessionToken: string): Promise => { +export const readSession = async (sessionToken: string): Promise => { const secretHash = hashToken(sessionToken); - - const cached = getSessionCache(secretHash); + const cached = getCachedSession(secretHash); if (cached) { if (isExpiredDate(cached.session.expiresAt)) throw new AppError(401, 'session_expired', 'warn'); return cached; } - // The role is read whatever the address, so the cached entry is right for every request that hits it. const [result] = await db .select({ session: sessionFactColumns, revokedAt: sessionsTable.revokedAt, user: userSelect, systemRole: systemRolesTable.role, + bindingsVersion: actorsTable.bindingsVersion, }) .from(sessionsTable) .innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id)) + .innerJoin(actorsTable, eq(actorsTable.id, usersTable.id)) .leftJoin(systemRolesTable, eq(systemRolesTable.userId, usersTable.id)) .where(eq(sessionsTable.secret, secretHash)) .limit(1); @@ -258,8 +258,9 @@ export const readSession = async (sessionToken: string): Promise { * of it. * @throws AppError 401 without a token, for an unknown, revoked or expired one, or for an impersonation's. */ -export const readOwnSession = async (sessionToken: string | undefined): Promise => { +export const readOwnSession = async (sessionToken: string | undefined): Promise => { if (!sessionToken) throw new AppError(401, 'unauthorized', 'warn'); const entry = await readSession(sessionToken); if (entry.session.type === 'impersonation') throw new AppError(401, 'unauthorized', 'warn'); @@ -290,15 +291,12 @@ export const readOwnSession = async (sessionToken: string | undefined): Promise< * @throws AppError 401 without a session cookie, for an unknown, revoked or expired token, or an impersonation that * this browser's own session does not back. */ -export const resolveSession = async ( - ctx: Context, - { clearOnError = false }: { clearOnError?: boolean } = {}, -): Promise => { +export const resolveSession = async (ctx: Context, { clearOnError = false }: { clearOnError?: boolean } = {}): Promise => { const sessionToken = await getAuthCookie(ctx, 'session'); const impersonationToken = await getAuthCookie(ctx, 'impersonation'); // Only a refusal clears the cookie: it holds the only copy of the token, so a failed read (the database away) keeps it. - const clearIfRefused = async (cookie: 'session' | 'impersonation', read: () => Promise) => { + const clearIfRefused = async (cookie: 'session' | 'impersonation', read: () => Promise) => { try { return await read(); } catch (err) { @@ -329,5 +327,4 @@ export const resolveSession = async ( * expired or revoked token), while a failed read stays the request's failure, so the database being away never reads * as signed out. */ -export const findSession = (ctx: Context): Promise => - resolveSession(ctx).catch(refusalAsNull); +export const findSession = (ctx: Context): Promise => resolveSession(ctx).catch(refusalAsNull); diff --git a/backend/src/modules/auth/general/helpers/user.ts b/backend/src/modules/auth/general/helpers/user.ts index d5c00b4d0..ac5615032 100644 --- a/backend/src/modules/auth/general/helpers/user.ts +++ b/backend/src/modules/auth/general/helpers/user.ts @@ -7,8 +7,8 @@ import { claimEmailForUser } from '#/modules/auth/general/helpers/claim-email'; import type { EmailProof } from '#/modules/auth/general/helpers/mark-email-verified'; import { checkSlugAvailable } from '#/modules/entities/helpers/check-slug'; import { emailsTable } from '#/modules/user/emails-db'; -import { insertUsers } from '#/modules/user/helpers/insert-users'; import type { InsertUserModel, UserModel } from '#/modules/user/user-db'; +import { insertUsers } from '#/modules/user/user-queries'; import { getIsoDate } from '#/utils/iso-date'; /** @@ -36,15 +36,17 @@ export const handleCreateUser = async (ctx: DbContext, { newUser, via }: HandleC try { const normalizedEmail = newUser.email.toLowerCase().trim(); - const [user] = await insertUsers(db, [ - { - slug: slugAvailable ? newUser.slug : `${newUser.slug}-${nanoid(5)}`, - firstName: newUser.firstName, - email: normalizedEmail, - name: newUser.name, - language: appConfig.defaultLanguage, - }, - ]); + const [user] = await insertUsers(ctx, { + users: [ + { + slug: slugAvailable ? newUser.slug : `${newUser.slug}-${nanoid(5)}`, + firstName: newUser.firstName, + email: normalizedEmail, + name: newUser.name, + language: appConfig.defaultLanguage, + }, + ], + }); // The account's one email row, proven at creation. A taken address never gets here: the users insert above // already failed on its unique email. diff --git a/backend/src/modules/auth/general/operations/accept-invitation-token.ts b/backend/src/modules/auth/general/operations/accept-invitation-token.ts index b150a3fb8..db8687459 100644 --- a/backend/src/modules/auth/general/operations/accept-invitation-token.ts +++ b/backend/src/modules/auth/general/operations/accept-invitation-token.ts @@ -15,9 +15,7 @@ export async function acceptInvitationTokenOp(ctx: UserContext, tokenRecord: Tok // A token already linked to a user is that user's alone; possession of the link changes nothing. if (tokenRecord.userId && tokenRecord.userId !== user.id) throw new AppError(409, 'user_mismatch', 'warn'); - const entity = await handleMembershipInvitationOp(ctx, tokenRecord.inactiveMembershipId, 'accept', { - viaToken: true, - }); + const entity = await handleMembershipInvitationOp(ctx, tokenRecord.inactiveMembershipId, 'accept', { viaToken: true }); // Accepted by an account on another address than the one invited: tell the invited inbox, since it may not be theirs. if (tokenRecord.email !== user.email) { @@ -26,11 +24,10 @@ export async function acceptInvitationTokenOp(ctx: UserContext, tokenRecord: Tok invitedEmail: tokenRecord.email, userId: user.id, }); - sendAccountSecurityEmail( - { email: tokenRecord.email, name: slugFromEmail(tokenRecord.email) }, - 'invitation-accepted-elsewhere', - { entityName: entity.name, accountEmail: user.email }, - ); + sendAccountSecurityEmail({ email: tokenRecord.email, name: slugFromEmail(tokenRecord.email) }, 'invitation-accepted-elsewhere', { + entityName: entity.name, + accountEmail: user.email, + }); } return entity; diff --git a/backend/src/modules/auth/general/session-listeners.ts b/backend/src/modules/auth/general/session-listeners.ts new file mode 100644 index 000000000..92d1f06d6 --- /dev/null +++ b/backend/src/modules/auth/general/session-listeners.ts @@ -0,0 +1,21 @@ +import { activityBus, getEventData } from '#/lib/activity-bus'; +import { dropCachedSessions } from '#/middlewares/guard/session-cache'; + +/** + * CDC reports every committed change to users, memberships and system roles, whoever wrote it: each drops the user's + * cached sessions in the API process, so the next request reads the user row, role and bindings version again. + */ +for (const verb of ['created', 'updated', 'deleted'] as const) { + activityBus.on(`user.${verb}`, (event) => { + const user = getEventData(event, 'user'); + if (user?.id) dropCachedSessions(user.id); + }); + activityBus.on(`membership.${verb}`, (event) => { + const membership = getEventData(event, 'membership'); + if (membership?.userId) dropCachedSessions(membership.userId); + }); + activityBus.on(`system_role.${verb}`, (event) => { + const systemRole = getEventData(event, 'system_role'); + if (systemRole?.userId) dropCachedSessions(systemRole.userId); + }); +} diff --git a/backend/src/modules/auth/jobs/prune-devices.ts b/backend/src/modules/auth/jobs/prune-devices.ts index 508ff28e0..4cccbbe0c 100644 --- a/backend/src/modules/auth/jobs/prune-devices.ts +++ b/backend/src/modules/auth/jobs/prune-devices.ts @@ -14,10 +14,7 @@ const MAX_DEVICES_PER_USER = 50; export async function pruneDevices(now: Date = new Date()): Promise { const seenBefore = new Date(now.getTime() - DEVICE_TTL.milliseconds()).toISOString(); - const expired = await db - .delete(devicesTable) - .where(lt(devicesTable.lastSeenAt, seenBefore)) - .returning({ userId: devicesTable.userId }); + const expired = await db.delete(devicesTable).where(lt(devicesTable.lastSeenAt, seenBefore)).returning({ userId: devicesTable.userId }); const excess = await db .delete(devicesTable) diff --git a/backend/src/modules/auth/magic/helpers/magic-sign-up.ts b/backend/src/modules/auth/magic/helpers/magic-sign-up.ts index cafdcc621..4fa8b9dc6 100644 --- a/backend/src/modules/auth/magic/helpers/magic-sign-up.ts +++ b/backend/src/modules/auth/magic/helpers/magic-sign-up.ts @@ -23,10 +23,7 @@ export const claimMagicLinkOwner = async (tx: Tx, token: TokenRecord): Promise { // Opening the link in this browser signs in directly; elsewhere it asks for a confirmation first. await rememberLinkRequest(ctx, 'magic', tokenRecord.id); - const magicLinkUrl = new URL(`${appConfig.backendAuthUrl}/invoke-token/${tokenRecord.type}/${rawToken}`); + const magicLinkUrl = tokenLinkUrl('magic', rawToken); - const staticProps = { - magicLinkUrl: magicLinkUrl.toString(), - name: existingUser?.name ?? slugFromEmail(normalizedEmail), - isNewUser: !existingUser, - }; + const staticProps = { magicLinkUrl, name: existingUser?.name ?? slugFromEmail(normalizedEmail), isNewUser: !existingUser }; const recipients = [{ email: normalizedEmail, lng: existingUser?.language ?? appConfig.defaultLanguage }]; mailer.prepareEmails(magicLinkEmail, staticProps, recipients); if (appConfig.mode === 'development') { - console.info(`[magic-link] ${normalizedEmail} ${magicLinkUrl.toString()}`); + console.info(`[magic-link] ${normalizedEmail} ${magicLinkUrl}`); } log.info('Magic link email sent', { userId, signUp: !existingUser }); diff --git a/backend/src/modules/auth/magic/magic-routes.ts b/backend/src/modules/auth/magic/magic-routes.ts index 60ae9dd4b..46fefa3fb 100644 --- a/backend/src/modules/auth/magic/magic-routes.ts +++ b/backend/src/modules/auth/magic/magic-routes.ts @@ -1,70 +1,46 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { publicGuard } from '#/middlewares/guard'; import { isNoBot } from '#/middlewares/is-no-bot'; import { magicLinkLimiter, spamLimiter, tokenLimiter } from '#/middlewares/rate-limiter/limiters'; import { magicLinkBodySchema } from '#/modules/auth/magic/magic-schema'; -import { errorResponseRefs, locationSchema } from '#/schemas'; +import { locationSchema } from '#/schemas'; -const authMagicLinkRoutes = { - sendMagicLink: createXRoute({ - operationId: 'sendMagicLink', - 'x-strategy': 'magic', +const authMagicLinkRoutes = createXRoutes(['auth', 'cella'], { + sendMagicLink: xRoute({ method: 'post', path: '/magic/send', + xEnabledBy: { strategy: 'magic' }, xGuard: [publicGuard], xRateLimiter: [magicLinkLimiter, spamLimiter], middleware: isNoBot, - tags: ['auth', 'cella'], summary: 'Send magic link', - description: - 'Sends a magic link sign-in email to the specified address. Always returns 204 to prevent email enumeration.', - request: { - body: { - required: true, - content: { 'application/json': { schema: magicLinkBodySchema } }, - }, - }, - responses: { - 204: { description: 'Magic link email sent (or silently ignored if email not found)' }, - ...errorResponseRefs, - }, + description: 'Sends a magic link sign-in email to the specified address. Always returns 204 to prevent email enumeration.', + request: { body: jsonBody(magicLinkBodySchema) }, + responses: { 204: { description: 'Magic link email sent (or silently ignored if email not found)' } }, }), - getPendingMagicLink: createXRoute({ - operationId: 'getPendingMagicLink', - 'x-strategy': 'magic', + getPendingMagicLink: xRoute({ method: 'get', path: '/magic/pending', + xEnabledBy: { strategy: 'magic' }, xGuard: [publicGuard], xRateLimiter: [tokenLimiter('magic')], - tags: ['auth', 'cella'], summary: 'Get pending magic link', description: 'For a magic link opened in a browser that did not request it: the address it signs in, so the holder can recognize the account before confirming.', - responses: { - 200: { - description: 'The full address the held link signs in, as the confirm page shows it', - content: { 'application/json': { schema: z.object({ email: z.string() }) } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('The full address the held link signs in, as the confirm page shows it', z.object({ email: z.string() })) }, }), - confirmMagicLink: createXRoute({ - operationId: 'confirmMagicLink', - 'x-strategy': 'magic', + confirmMagicLink: xRoute({ method: 'post', path: '/magic/confirm', + xEnabledBy: { strategy: 'magic' }, xGuard: [publicGuard], xRateLimiter: [tokenLimiter('magic')], - tags: ['auth', 'cella'], summary: 'Confirm magic link', description: 'Signs in with the magic link this browser holds, confirmed from the app page. A form post from the app origin; redirects like opening the link.', - responses: { - 302: { description: 'Signed in, redirect to the app', headers: locationSchema }, - ...errorResponseRefs, - }, + responses: { 302: { description: 'Signed in, redirect to the app', headers: locationSchema } }, }), -}; +}); export { authMagicLinkRoutes }; diff --git a/backend/src/modules/auth/mfa/mfa-queries.ts b/backend/src/modules/auth/mfa/mfa-queries.ts new file mode 100644 index 000000000..1eebd4a6b --- /dev/null +++ b/backend/src/modules/auth/mfa/mfa-queries.ts @@ -0,0 +1,19 @@ +import { sql } from 'drizzle-orm'; +import type { DbContext } from '#/core/context'; +import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; +import { totpsTable } from '#/modules/auth/totps/totps-db'; + +interface GetHeldFactorsOpts { + userId: string; +} + +/** + * Which second factors the user holds, in one query. Inside `mfaFactorRules.locked`, pass its transaction so the read + * sees the change made there. + */ +export const getHeldFactors = async (ctx: DbContext, { userId }: GetHeldFactorsOpts) => { + const { rows } = await ctx.var.db.execute<{ passkey: boolean; totp: boolean }>(sql` + select exists (select 1 from ${passkeysTable} where ${passkeysTable.userId} = ${userId}) as passkey, + exists (select 1 from ${totpsTable} where ${totpsTable.userId} = ${userId}) as totp`); + return rows[0]; +}; diff --git a/backend/src/modules/auth/mfa/operations/factor-rules.ts b/backend/src/modules/auth/mfa/operations/factor-rules.ts new file mode 100644 index 000000000..85a36bdd0 --- /dev/null +++ b/backend/src/modules/auth/mfa/operations/factor-rules.ts @@ -0,0 +1,43 @@ +import { appConfig } from 'shared'; +import { AppError } from '#/core/error'; +import { baseDb, type DbOrTx, type Tx } from '#/db/db'; +import { getHeldFactors } from '#/modules/auth/mfa/mfa-queries'; +import { findUserForUpdate } from '#/modules/user/user-queries'; + +/** + * MFA keeps both a passkey and an authenticator app, so a lost one can be replaced while the other still signs in. + * Enabling needs both methods switched on and enrolled; while MFA is on, the last of either cannot be removed. The + * interface enforces the same, this makes it hold for every caller. + */ +export const mfaFactorRules = { + /** + * Runs a change to the MFA switch or the factors in a transaction that first locks the user's row. Every such change + * takes the lock, so they run one at a time and each check reads what the one before it committed; the checks below + * run inside `change`. + */ + async locked(userId: string, change: (tx: Tx) => Promise): Promise { + return baseDb.transaction(async (tx) => { + await findUserForUpdate({ var: { db: tx } }, { id: userId }); + return change(tx); + }); + }, + + /** Refuses turning MFA on unless both methods are enabled for the app and enrolled by the user. */ + async assertCanEnable(tx: DbOrTx, userId: string) { + const missing = (['passkey', 'totp'] as const).find((method) => !appConfig.enabledAuthStrategies.includes(method)); + if (missing) throw new AppError(400, 'forbidden_strategy', 'warn', { meta: { strategy: missing } }); + + const { passkey, totp } = await getHeldFactors({ var: { db: tx } }, { userId }); + if (!passkey || !totp) throw new AppError(400, 'mfa_factors_required', 'warn'); + }, + + /** Run after deleting a factor, in the same `locked` transaction: refuses when MFA is on and a method is now gone. */ + async assertKeepsFactors(tx: DbOrTx, userId: string) { + const txCtx = { var: { db: tx } }; + const user = await findUserForUpdate(txCtx, { id: userId }); + if (!user?.mfaRequired) return; + + const { passkey, totp } = await getHeldFactors(txCtx, { userId }); + if (!passkey || !totp) throw new AppError(400, 'mfa_factor_in_use', 'warn'); + }, +}; diff --git a/backend/src/modules/auth/general/helpers/mfa.ts b/backend/src/modules/auth/mfa/operations/mfa-challenge.ts similarity index 50% rename from backend/src/modules/auth/general/helpers/mfa.ts rename to backend/src/modules/auth/mfa/operations/mfa-challenge.ts index fdb31b47a..d2fa452af 100644 --- a/backend/src/modules/auth/general/helpers/mfa.ts +++ b/backend/src/modules/auth/mfa/operations/mfa-challenge.ts @@ -1,17 +1,14 @@ import type { AuthenticationResponseJSON } from '@simplewebauthn/server'; -import { eq } from 'drizzle-orm'; import type { Context } from 'hono'; -import { appConfig } from 'shared'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { type DbOrTx, baseDb as db, type Tx } from '#/db/db'; -import { findRemainingMfaMethods } from '#/modules/auth/auth-queries'; +import { baseDb } from '#/db/db'; import { setUserSession } from '#/modules/auth/general/helpers/session'; -import { verifyPasskeyAssertion } from '#/modules/auth/passkeys/helpers/passkey'; +import { verifyPasskeyAssertion } from '#/modules/auth/passkeys/operations/passkey-challenges'; import { issueCookieToken, readBoundToken, spendCookieToken } from '#/modules/auth/tokens/token-lifecycle'; -import { verifyTotp } from '#/modules/auth/totps/helpers/totps'; -import { userSelect } from '#/modules/user/helpers/select'; -import { type UserModel, usersTable } from '#/modules/user/user-db'; +import { verifyTotp } from '#/modules/auth/totps/operations/verify-totp'; +import type { UserModel } from '#/modules/user/user-db'; +import { findUserById } from '#/modules/user/user-queries'; /** Starts an MFA challenge: issues a `confirm-mfa` token in its cookie and returns the `/auth/mfa` path, or null when MFA is off. */ export const initiateMfa = async (ctx: Context, user: UserModel) => { @@ -28,7 +25,7 @@ export const validateConfirmMfaToken = async (ctx: Context): Promise) => { }; /** A second factor offered for an MFA challenge: a code from the authenticator app, or a passkey response. */ -export type MfaProof = - | { strategy: 'totp'; code: string } - | { strategy: 'passkey'; assertion: AuthenticationResponseJSON }; +export type MfaProof = { strategy: 'totp'; code: string } | { strategy: 'passkey'; assertion: AuthenticationResponseJSON }; /** * The only way out of an MFA challenge: reads the challenge this browser holds, verifies the offered factor for the @@ -66,43 +61,3 @@ export const completeMfaChallenge = async (ctx: Context, proof: MfaProof) = await spendConfirmMfaToken(ctx); await setUserSession(ctx, user, proof.strategy, 'mfa'); }; - -/** - * MFA keeps both a passkey and an authenticator app, so a lost one can be replaced while the other still signs in. - * Enabling needs both methods switched on and enrolled; while MFA is on, the last of either cannot be removed. The - * interface enforces the same, this makes it hold for every caller. - */ -export const mfaFactorRules = { - /** - * Runs a change to the MFA switch or the factors in a transaction that first locks the user's row. Every such change - * takes the lock, so they run one at a time and each check reads what the one before it committed; the checks below - * run inside `change`. - */ - async locked(userId: string, change: (tx: Tx) => Promise): Promise { - return db.transaction(async (tx) => { - await tx.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.id, userId)).for('update'); - return change(tx); - }); - }, - - /** Refuses turning MFA on unless both methods are enabled for the app and enrolled by the user. */ - async assertCanEnable(tx: DbOrTx, userId: string) { - const missing = (['passkey', 'totp'] as const).find((method) => !appConfig.enabledAuthStrategies.includes(method)); - if (missing) throw new AppError(400, 'forbidden_strategy', 'warn', { meta: { strategy: missing } }); - - const { passkeys, totps } = await findRemainingMfaMethods({ var: { db: tx } }, { userId }); - if (!passkeys.length || !totps.length) throw new AppError(400, 'mfa_factors_required', 'warn'); - }, - - /** Run after deleting a factor, in the same `locked` transaction: refuses when MFA is on and a method is now gone. */ - async assertKeepsFactors(tx: DbOrTx, userId: string) { - const [user] = await tx - .select({ mfaRequired: usersTable.mfaRequired }) - .from(usersTable) - .where(eq(usersTable.id, userId)); - if (!user?.mfaRequired) return; - - const { passkeys, totps } = await findRemainingMfaMethods({ var: { db: tx } }, { userId }); - if (!passkeys.length || !totps.length) throw new AppError(400, 'mfa_factor_in_use', 'warn'); - }, -}; diff --git a/backend/src/modules/auth/oauth/helpers/callback.ts b/backend/src/modules/auth/oauth/helpers/callback.ts index 80180f821..d7cec5e8b 100644 --- a/backend/src/modules/auth/oauth/helpers/callback.ts +++ b/backend/src/modules/auth/oauth/helpers/callback.ts @@ -22,11 +22,7 @@ import { isValidRedirectPath } from '#/utils/is-redirect-url'; import { getIsoDate } from '#/utils/iso-date'; type OAuthFlowResult = - | { - type: 'verified'; - user: UserWithCounters; - identity: IdentityModel; - } + | { type: 'verified'; user: UserWithCounters; identity: IdentityModel } | { /** A provider account a proven user connected, awaiting the click on its verification mail. */ type: 'unverified'; @@ -58,13 +54,7 @@ export const handleOAuthCallback = async ( const [identity] = await db .select() .from(identitiesTable) - .where( - and( - eq(identitiesTable.kind, 'oauth'), - eq(identitiesTable.issuer, provider), - eq(identitiesTable.subject, providerUser.id), - ), - ); + .where(and(eq(identitiesTable.kind, 'oauth'), eq(identitiesTable.issuer, provider), eq(identitiesTable.subject, providerUser.id))); const baseCallbackProps = { providerUser, provider, identity }; @@ -89,11 +79,7 @@ export const handleOAuthCallback = async ( }; /** Basic OAuth authentication and signup: existing verified account, unverified account, or a sign-up that waits on its verification mail. */ -const authCallbackFlow = async ({ - providerUser, - provider, - identity = null, -}: BaseCallbackProps): Promise => { +const authCallbackFlow = async ({ providerUser, provider, identity = null }: BaseCallbackProps): Promise => { if (identity?.verified) { const user = await findUserById({ var: { db } }, { id: identity.userId }); await touchIdentity(identity, providerUser); @@ -125,11 +111,7 @@ const authCallbackFlow = async ({ /** No account until the provider's address is proven: the sign-up waits on its verification mail. */ const pendingSignUp = (providerUser: TransformedUser, provider: EnabledOAuthProvider): OAuthFlowResult => { const { name, slug, firstName } = providerUser; - return { - type: 'pending', - signUp: { issuer: provider, subject: providerUser.id, name, slug, firstName }, - email: providerUser.email, - }; + return { type: 'pending', signUp: { issuer: provider, subject: providerUser.id, name, slug, firstName }, email: providerUser.email }; }; /** @@ -169,12 +151,7 @@ const connectCallbackFlow = async ({ const holder = await findUserByEmail({ var: { db } }, { email: providerUser.email }); if (holder && holder.id !== connectUserId) throw new AppError(409, 'oauth_conflict', 'warn'); - const newIdentity = await createIdentity(db, { - userId: connectUserId, - issuer: provider, - subject: providerUser.id, - email: providerUser.email, - }); + const newIdentity = await createIdentity(db, { userId: connectUserId, issuer: provider, subject: providerUser.id, email: providerUser.email }); return { type: 'unverified', identity: newIdentity }; }; @@ -208,11 +185,7 @@ const inviteCallbackFlow = async ({ const { email } = invitationToken; const created = await db.transaction(async (tx) => { const user = await handleCreateUser({ var: { db: tx } }, { newUser: providerUser, via: provider }); - const newIdentity = await createIdentity( - tx, - { userId: user.id, issuer: provider, subject: providerUser.id, email }, - { verified: true }, - ); + const newIdentity = await createIdentity(tx, { userId: user.id, issuer: provider, subject: providerUser.id, email }, { verified: true }); return { userId: user.id, identity: newIdentity }; }); @@ -307,15 +280,8 @@ const completeSignUp = async ({ if (spent?.id !== verifyToken.id) throw new AppError(401, 'oauth-verification_expired', 'warn'); const { name, slug, firstName } = signUp; - const user = await handleCreateUser( - { var: { db: tx } }, - { newUser: { email, name, slug, firstName }, via: provider }, - ); - const newIdentity = await createIdentity( - tx, - { userId: user.id, issuer: provider, subject: signUp.subject, email }, - { verified: true }, - ); + const user = await handleCreateUser({ var: { db: tx } }, { newUser: { email, name, slug, firstName }, via: provider }); + const newIdentity = await createIdentity(tx, { userId: user.id, issuer: provider, subject: signUp.subject, email }, { verified: true }); return { userId: user.id, identity: newIdentity }; }); // The spend is committed: the cookie that named the verification goes with it. @@ -328,11 +294,7 @@ const completeSignUp = async ({ type NewIdentity = Pick & { email: UserModel['email'] }; /** Links a provider account; unverified unless an inbox proof in the same flow already stands for it. */ -const createIdentity = async ( - dbOrTx: DbOrTx, - values: NewIdentity, - { verified = false }: { verified?: boolean } = {}, -): Promise => { +const createIdentity = async (dbOrTx: DbOrTx, values: NewIdentity, { verified = false }: { verified?: boolean } = {}): Promise => { const now = getIsoDate(); const [identity] = await dbOrTx .insert(identitiesTable) @@ -353,19 +315,14 @@ const refreshIdentityEmail = async (identity: IdentityModel, providerUser: Trans /** A sign-in through the identity: record the use and refresh the address snapshot to what the provider asserts now. */ const touchIdentity = async (identity: IdentityModel, providerUser: TransformedUser) => { - await db - .update(identitiesTable) - .set({ lastUsedAt: getIsoDate(), email: providerUser.email }) - .where(eq(identitiesTable.id, identity.id)); + await db.update(identitiesTable).set({ lastUsedAt: getIsoDate(), email: providerUser.email }).where(eq(identitiesTable.id, identity.id)); }; /** * Post-callback handling: verified accounts may start an MFA challenge and/or set the session, then redirect to the post-login path. * Unverified identities and pending sign-ups get a verification email and land on the email-verification page. */ -const processCallbackResult = async ( - info: OAuthFlowResult & { ctx: Context; provider: EnabledOAuthProvider; redirectAfter?: string }, -) => { +const processCallbackResult = async (info: OAuthFlowResult & { ctx: Context; provider: EnabledOAuthProvider; redirectAfter?: string }) => { const { ctx, provider, redirectAfter } = info; // Stored on the verification token; null means "use the default path" at the final hop. const redirectAfterPath = isValidRedirectPath(redirectAfter); @@ -378,11 +335,7 @@ const processCallbackResult = async ( if (info.type === 'pending') { await sendOAuthVerificationEmail({ signUp: info.signUp, email: info.email, redirectPath: redirectAfterPath }); } else { - await sendOAuthVerificationEmail({ - userId: info.identity.userId, - identityId: info.identity.id, - redirectPath: redirectAfterPath, - }); + await sendOAuthVerificationEmail({ userId: info.identity.userId, identityId: info.identity.id, redirectPath: redirectAfterPath }); } const reason = info.type === 'pending' ? 'signup' : 'connect'; diff --git a/backend/src/modules/auth/oauth/helpers/initiation.ts b/backend/src/modules/auth/oauth/helpers/initiation.ts index 425c28e3e..4501f830c 100644 --- a/backend/src/modules/auth/oauth/helpers/initiation.ts +++ b/backend/src/modules/auth/oauth/helpers/initiation.ts @@ -41,14 +41,14 @@ export const readOAuthCookie = async (ctx: Context, state: string): Promise */ export const handleOAuthInitiation = async ( ctx: Context, - provider: string, + provider: OAuthCookiePayload['provider'], url: URL, state: string, codeVerifier?: string, nonce?: string, ) => { const { type, redirectAfter } = ctx.req.valid('query'); - const cookieContent: OAuthCookiePayload = { codeVerifier, nonce, type, redirectAfter }; + const cookieContent: OAuthCookiePayload = { provider, codeVerifier, nonce, type, redirectAfter }; if (type === 'connect') { // A connect starts from the account page, which explains its refusals. diff --git a/backend/src/modules/auth/oauth/helpers/providers.ts b/backend/src/modules/auth/oauth/helpers/providers.ts index 4a47a5c91..3286d73ea 100644 --- a/backend/src/modules/auth/oauth/helpers/providers.ts +++ b/backend/src/modules/auth/oauth/helpers/providers.ts @@ -28,10 +28,7 @@ type ProviderSetup = { * Resolves the effective Entra ID issuer for multi-tenant sign-in: 'common', 'organizations' and 'consumers' are not real issuers, since * the id_token `iss` claim embeds the user's tenant id. Claim and signature validation run against the resolved issuer. */ -const resolveEntraIssuer = async ( - response: Response, - as: oauth.AuthorizationServer, -): Promise => { +const resolveEntraIssuer = async (response: Response, as: oauth.AuthorizationServer): Promise => { const body = (await response .clone() .json() @@ -48,23 +45,12 @@ const resolveEntraIssuer = async ( }; /** Creates a per-provider OAuth client: authorization-URL building and code exchange on `oauth4webapi`. */ -const createProviderClient = ({ - as, - clientId, - clientSecret, - redirectUri, - oidc, - resolveIssuerFromIdToken, -}: ProviderSetup) => { +const createProviderClient = ({ as, clientId, clientSecret, redirectUri, oidc, resolveIssuerFromIdToken }: ProviderSetup) => { const client: oauth.Client = { client_id: clientId }; return { /** Builds the provider authorization URL with state, scopes, and an optional PKCE challenge + OIDC nonce. */ - async createAuthorizationURL( - state: string, - scopes: string[], - { codeVerifier, nonce }: OAuthFlowContext = {}, - ): Promise { + async createAuthorizationURL(state: string, scopes: string[], { codeVerifier, nonce }: OAuthFlowContext = {}): Promise { const url = new URL(as.authorization_endpoint as string); url.searchParams.set('client_id', clientId); url.searchParams.set('redirect_uri', redirectUri); @@ -83,23 +69,12 @@ const createProviderClient = ({ * Exchanges the authorization code for tokens, enforcing PKCE when a `codeVerifier` is given. OIDC providers also get * id_token claim validation, `nonce` binding, and a signature check against the provider JWKS. */ - async validateAuthorizationCode( - code: string, - state: string, - { codeVerifier, nonce }: OAuthFlowContext = {}, - ): Promise<{ accessToken: string }> { + async validateAuthorizationCode(code: string, state: string, { codeVerifier, nonce }: OAuthFlowContext = {}): Promise<{ accessToken: string }> { try { // Created lazily: ClientSecretPost rejects empty secrets, which can be absent at module load (CI openapi generation, deployments without this provider) const clientAuth = oauth.ClientSecretPost(clientSecret); const callbackParams = oauth.validateAuthResponse(as, client, new URLSearchParams({ code, state }), state); - const response = await oauth.authorizationCodeGrantRequest( - as, - client, - clientAuth, - callbackParams, - redirectUri, - codeVerifier ?? oauth.nopkce, - ); + const response = await oauth.authorizationCodeGrantRequest(as, client, clientAuth, callbackParams, redirectUri, codeVerifier ?? oauth.nopkce); const effectiveAs = resolveIssuerFromIdToken ? await resolveEntraIssuer(response, as) : as; const tokens = await oauth.processAuthorizationCodeResponse(effectiveAs, client, response, { diff --git a/backend/src/modules/auth/oauth/helpers/send-oauth-verification-email.ts b/backend/src/modules/auth/oauth/helpers/send-oauth-verification-email.ts index ced16cf25..d7a17c879 100644 --- a/backend/src/modules/auth/oauth/helpers/send-oauth-verification-email.ts +++ b/backend/src/modules/auth/oauth/helpers/send-oauth-verification-email.ts @@ -3,8 +3,11 @@ import { appConfig } from 'shared'; import { AppError } from '#/core/error'; import { baseDb as db } from '#/db/db'; import { mailer } from '#/lib/mailer'; +import { strategyLabels } from '#/modules/auth/general/helpers/notify-sign-in'; import { identitiesTable } from '#/modules/auth/identities-db'; +import type { AuthStrategy } from '#/modules/auth/sessions-db'; import { issueToken, type NewToken } from '#/modules/auth/tokens/token-lifecycle'; +import { tokenLinkUrl } from '#/modules/auth/tokens/token-policies'; import type { PendingSignUp } from '#/modules/auth/tokens/tokens-queries'; import { type EmailModel, emailsTable } from '#/modules/user/emails-db'; import { userSelect } from '#/modules/user/helpers/select'; @@ -22,6 +25,9 @@ type Props = { redirectPath?: string | null } & ( } ); +/** The provider's name as people read it: an identity stores the strategy slug as its issuer. */ +const readableProviderName = (issuer: string) => strategyLabels[issuer as AuthStrategy] ?? issuer; + /** What the mail says and where it goes, with the token that stands for the verification. */ const verificationFor = async (props: Props) => { // Kept on the token row (not the emailed URL) so the deep link doesn't leak into email bodies @@ -30,7 +36,7 @@ const verificationFor = async (props: Props) => { if ('signUp' in props) { const { signUp, email } = props; const token: NewToken = { type: 'oauth-verification', email, pendingSignUp: signUp, redirectPath }; - return { token, name: signUp.name, lng: appConfig.defaultLanguage, providerName: signUp.issuer }; + return { token, name: signUp.name, lng: appConfig.defaultLanguage, providerName: readableProviderName(signUp.issuer), isNewUser: true }; } const [user] = await db.select(userSelect).from(usersTable).where(eq(usersTable.id, props.userId)).limit(1); @@ -42,10 +48,7 @@ const verificationFor = async (props: Props) => { // The address under verification is the provider's, which may differ from the account's own. const email = identity.email ?? user.email; - const [emailInUse]: (EmailModel | undefined)[] = await db - .select() - .from(emailsTable) - .where(eq(emailsTable.email, email)); + const [emailInUse]: (EmailModel | undefined)[] = await db.select().from(emailsTable).where(eq(emailsTable.email, email)); if (emailInUse && identity.verified) { throw new AppError(409, 'email_exists', 'warn', { entityType: 'user' }); @@ -59,7 +62,7 @@ const verificationFor = async (props: Props) => { identityId: identity.id, redirectPath, }; - return { token, name: user.name, lng: user.language, providerName: identity.issuer }; + return { token, name: user.name, lng: user.language, providerName: readableProviderName(identity.issuer), isNewUser: false }; }; /** @@ -68,26 +71,19 @@ const verificationFor = async (props: Props) => { * replaces the earlier ones for the same identity or signing-up provider account. */ export const sendOAuthVerificationEmail = async (props: Props) => { - const { token, name, lng, providerName } = await verificationFor(props); + const { token, name, lng, providerName, isNewUser } = await verificationFor(props); const { token: tokenRecord, rawToken } = await issueToken({ var: { db } }, token); - const verificationURL = new URL(`${appConfig.backendAuthUrl}/invoke-token/${tokenRecord.type}/${rawToken}`); + const verificationLink = tokenLinkUrl('oauth-verification', rawToken); - const staticProps = { - verificationLink: verificationURL.toString(), - name, - providerEmail: tokenRecord.email, - providerName, - }; + const staticProps = { verificationLink, name, providerEmail: tokenRecord.email, providerName, isNewUser }; const recipients = [{ email: tokenRecord.email, lng }]; - mailer - .prepareEmails(oauthVerificationEmail, staticProps, recipients) - .catch((err) => log.error('Failed to send OAuth verification email', { err })); + mailer.prepareEmails(oauthVerificationEmail, staticProps, recipients).catch((err) => log.error('Failed to send OAuth verification email', { err })); if (appConfig.mode === 'development') { - console.info(`[verification-link] ${tokenRecord.email} ${verificationURL.toString()}`); + console.info(`[verification-link] ${tokenRecord.email} ${verificationLink}`); } log.info('Verification email sent', { userId: tokenRecord.userId, signUp: !!tokenRecord.pendingSignUp }); diff --git a/backend/src/modules/auth/oauth/helpers/transform-user-data.test.ts b/backend/src/modules/auth/oauth/helpers/transform-user-data.test.ts index 79bb8f40d..a429d8a9a 100644 --- a/backend/src/modules/auth/oauth/helpers/transform-user-data.test.ts +++ b/backend/src/modules/auth/oauth/helpers/transform-user-data.test.ts @@ -1,32 +1,14 @@ import { describe, expect, it } from 'vitest'; -import type { - GithubUserEmailProps, - GithubUserProps, - GoogleUserProps, - MicrosoftUserProps, -} from '#/modules/auth/oauth/helpers/providers'; +import type { GithubUserEmailProps, GithubUserProps, GoogleUserProps, MicrosoftUserProps } from '#/modules/auth/oauth/helpers/providers'; import { transformGithubUserData, transformSocialUserData } from '#/modules/auth/oauth/helpers/transform-user-data'; -const githubUser = { - id: 123, - login: 'octocat', - name: 'Octo Cat', - avatar_url: 'https://example.com/a.png', -} as GithubUserProps; +const githubUser = { id: 123, login: 'octocat', name: 'Octo Cat', avatar_url: 'https://example.com/a.png' } as GithubUserProps; -const ghEmail = (email: string, primary: boolean, verified: boolean): GithubUserEmailProps => ({ - email, - primary, - verified, - visibility: null, -}); +const ghEmail = (email: string, primary: boolean, verified: boolean): GithubUserEmailProps => ({ email, primary, verified, visibility: null }); describe('transformGithubUserData', () => { it('selects the primary email and normalizes it', () => { - const result = transformGithubUserData(githubUser, [ - ghEmail('Secondary@Example.com', false, true), - ghEmail('Primary@Example.com', true, true), - ]); + const result = transformGithubUserData(githubUser, [ghEmail('Secondary@Example.com', false, true), ghEmail('Primary@Example.com', true, true)]); expect(result.email).toBe('primary@example.com'); expect(result.emailVerified).toBe(true); @@ -40,9 +22,7 @@ describe('transformGithubUserData', () => { }); it('throws when there is no primary email', () => { - expect(() => transformGithubUserData(githubUser, [ghEmail('only@example.com', false, true)])).toThrow( - 'no_email_found', - ); + expect(() => transformGithubUserData(githubUser, [ghEmail('only@example.com', false, true)])).toThrow('no_email_found'); }); }); @@ -93,14 +73,7 @@ describe('transformSocialUserData', () => { }); it('throws when no email is present', () => { - const microsoft = { - sub: 'm2', - name: 'M User', - email: undefined, - picture: 'p', - givenname: 'M', - familyname: 'User', - } as MicrosoftUserProps; + const microsoft = { sub: 'm2', name: 'M User', email: undefined, picture: 'p', givenname: 'M', familyname: 'User' } as MicrosoftUserProps; expect(() => transformSocialUserData(microsoft)).toThrow('no_email_found'); }); diff --git a/backend/src/modules/auth/oauth/helpers/transform-user-data.ts b/backend/src/modules/auth/oauth/helpers/transform-user-data.ts index 7d70b80fd..4c5971e3f 100644 --- a/backend/src/modules/auth/oauth/helpers/transform-user-data.ts +++ b/backend/src/modules/auth/oauth/helpers/transform-user-data.ts @@ -1,10 +1,5 @@ import slugify from 'slugify'; -import type { - GithubUserEmailProps, - GithubUserProps, - GoogleUserProps, - MicrosoftUserProps, -} from '#/modules/auth/oauth/helpers/providers'; +import type { GithubUserEmailProps, GithubUserProps, GoogleUserProps, MicrosoftUserProps } from '#/modules/auth/oauth/helpers/providers'; import { slugFromEmail } from '#/utils/slug-from-email'; export type TransformedUser = { diff --git a/backend/src/modules/auth/oauth/oauth-handlers.ts b/backend/src/modules/auth/oauth/oauth-handlers.ts index 83a4c0ad9..29672b920 100644 --- a/backend/src/modules/auth/oauth/oauth-handlers.ts +++ b/backend/src/modules/auth/oauth/oauth-handlers.ts @@ -1,6 +1,9 @@ import { OpenAPIHono } from '@hono/zod-openapi'; +import type { Context } from 'hono'; import { generateRandomCodeVerifier, generateRandomNonce, generateRandomState } from 'oauth4webapi'; import type { EnabledOAuthProvider } from 'shared'; +import type { BaseOAuthProviders } from 'shared/config-builder/types'; +import type z from 'zod'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; import { handleOAuthCallback } from '#/modules/auth/oauth/helpers/callback'; @@ -15,162 +18,124 @@ import { microsoftAuth, OAuthCodeExchangeError, } from '#/modules/auth/oauth/helpers/providers'; -import { transformGithubUserData, transformSocialUserData } from '#/modules/auth/oauth/helpers/transform-user-data'; +import { type TransformedUser, transformGithubUserData, transformSocialUserData } from '#/modules/auth/oauth/helpers/transform-user-data'; import { authOAuthRoutes } from '#/modules/auth/oauth/oauth-routes'; +import type { oauthCallbackQuerySchema, oauthQuerySchema } from '#/modules/auth/oauth/oauth-schema'; import { issueCookieToken } from '#/modules/auth/tokens/token-lifecycle'; import { defaultHook } from '#/utils/default-hook'; -// `openid` is required for Google and Microsoft so the token endpoint returns an id_token, which carries the nonce validated on callback. -const githubScopes = ['user:email']; -const googleScopes = ['openid', 'profile', 'email']; -const microsoftScopes = ['openid', 'profile', 'email']; - -const app = new OpenAPIHono({ defaultHook }); - -app.openapi(authOAuthRoutes.startOAuthConnect, async (ctx) => { - const { user, session } = ctx.var; - - // The provider's callback is a navigation from another site: this Lax cookie's token is what names the account. It - // serves only while the session that asked lives, so a sign-out (here or elsewhere) ends a connect left half-way. - await issueCookieToken(ctx, { - type: 'oauth-connect', - userId: user.id, - email: user.email, - createdBy: user.id, - sessionId: session.id, - }); - - return ctx.body(null, 204); -}); - -app.openapi(authOAuthRoutes.github, async (ctx) => { - // Generate a `state` to prevent CSRF, and build URL with scope. - const state = generateRandomState(); - const url = await githubAuth.createAuthorizationURL(state, githubScopes); +interface OAuthProviderEntry { + client: typeof githubAuth; + scopes: string[]; + /** Round trips carry a PKCE verifier and an OIDC nonce; the callback refuses a state stored without a verifier. */ + pkce: boolean; + /** The provider's profile, read with the access token the code exchange returned. */ + fetchUser: (headers: Record) => Promise; +} + +/** An OIDC provider's profile from its userinfo endpoint. */ +const readUserinfo = (url: string) => async (headers: Record) => { + const response = await fetch(url, { headers }); + return transformSocialUserData((await response.json()) as GoogleUserProps | MicrosoftUserProps); +}; - return await handleOAuthInitiation(ctx, 'github', url, state); -}); - -app.openapi(authOAuthRoutes.google, async (ctx) => { - const state = generateRandomState(); - const codeVerifier = generateRandomCodeVerifier(); - const nonce = generateRandomNonce(); - const url = await googleAuth.createAuthorizationURL(state, googleScopes, { codeVerifier, nonce }); - - return await handleOAuthInitiation(ctx, 'google', url, state, codeVerifier, nonce); -}); - -app.openapi(authOAuthRoutes.microsoft, async (ctx) => { +// `openid` is required for Google and Microsoft so the token endpoint returns an id_token, which carries the nonce validated on callback. +const oauthProviders = { + github: { + client: githubAuth, + scopes: ['user:email'], + pkce: false, + fetchUser: async (headers) => { + const [userResponse, emailsResponse] = await Promise.all([ + fetch('https://api.github.com/user', { headers }), + fetch('https://api.github.com/user/emails', { headers }), + ]); + const user = (await userResponse.json()) as GithubUserProps; + const emails = (await emailsResponse.json()) as GithubUserEmailProps[]; + return transformGithubUserData(user, emails); + }, + }, + google: { + client: googleAuth, + scopes: ['openid', 'profile', 'email'], + pkce: true, + fetchUser: readUserinfo('https://openidconnect.googleapis.com/v1/userinfo'), + }, + microsoft: { + client: microsoftAuth, + scopes: ['openid', 'profile', 'email'], + pkce: true, + fetchUser: readUserinfo('https://graph.microsoft.com/oidc/userinfo'), + }, +} satisfies Record; + +/** Sends the browser to the provider with a fresh `state`, plus a PKCE verifier and a nonce for a `pkce` provider. */ +const startOAuth = async (ctx: Context } }>, provider: BaseOAuthProviders) => { + const { client, scopes, pkce } = oauthProviders[provider]; const state = generateRandomState(); - const codeVerifier = generateRandomCodeVerifier(); - const nonce = generateRandomNonce(); - const url = await microsoftAuth.createAuthorizationURL(state, microsoftScopes, { codeVerifier, nonce }); + const flow = pkce ? { codeVerifier: generateRandomCodeVerifier(), nonce: generateRandomNonce() } : undefined; + const url = await client.createAuthorizationURL(state, scopes, flow); - return await handleOAuthInitiation(ctx, 'microsoft', url, state, codeVerifier, nonce); -}); + return await handleOAuthInitiation(ctx, provider, url, state, flow?.codeVerifier, flow?.nonce); +}; -app.openapi(authOAuthRoutes.githubCallback, async (ctx) => { +/** + * Resumes the round trip `state` names: exchanges the code (with the stored verifier and nonce for a `pkce` provider), + * reads the provider's profile and hands it to the flow the state cookie holds. + */ +const finishOAuth = async (ctx: Context } }>, provider: BaseOAuthProviders) => { const { code, state, error } = ctx.req.valid('query'); - - const strategy = 'github' as EnabledOAuthProvider; + const { client, pkce, fetchUser } = oauthProviders[provider]; + const strategy = provider as EnabledOAuthProvider; // Read before the provider's answer is judged: a connect's refusals from here on go back to the account page. const cookiePayload = await readOAuthCookie(ctx, state); if (error || !code) throw new AppError(400, 'oauth_failed', 'error', { meta: { strategy } }); - // Verify cookie by `state` (CSRF protection) - if (!cookiePayload) throw new AppError(401, 'invalid_state', 'error', { meta: { strategy } }); - - try { - const { accessToken } = await githubAuth.validateAuthorizationCode(code, state); - - const headers = { Authorization: `Bearer ${accessToken}` }; - const [githubUserResponse, githubUserEmailsResponse] = await Promise.all([ - fetch('https://api.github.com/user', { headers }), - fetch('https://api.github.com/user/emails', { headers }), - ]); - - const githubUser = (await githubUserResponse.json()) as GithubUserProps; - const githubUserEmails = (await githubUserEmailsResponse.json()) as GithubUserEmailProps[]; - const providerUser = transformGithubUserData(githubUser, githubUserEmails); - - return await handleOAuthCallback(ctx, cookiePayload, providerUser, strategy); - } catch (error) { - if (error instanceof AppError) throw error; - - const type = error instanceof OAuthCodeExchangeError ? 'invalid_credentials' : 'oauth_failed'; - throw new AppError(401, type, 'error', { - meta: { strategy }, - ...(error instanceof Error ? { originalError: error } : {}), - }); + // The cookie `state` names is the CSRF check. It must come from this provider's start, and a PKCE provider also + // needs the verifier that start stored. + if (!cookiePayload || cookiePayload.provider !== provider || (pkce && !cookiePayload.codeVerifier)) { + throw new AppError(401, 'invalid_state', 'error', { meta: { strategy } }); } -}); - -app.openapi(authOAuthRoutes.googleCallback, async (ctx) => { - const { state, code } = ctx.req.valid('query'); - const strategy = 'google' as EnabledOAuthProvider; - - // Verify cookie by `state` (CSRF protection) & PKCE validation - const cookiePayload = await readOAuthCookie(ctx, state); - - if (!code || !cookiePayload?.codeVerifier) throw new AppError(401, 'invalid_state', 'error', { meta: { strategy } }); try { - // id_token claims, `nonce` binding, and signature are validated inside the provider client. - const { accessToken } = await googleAuth.validateAuthorizationCode(code, state, { - codeVerifier: cookiePayload.codeVerifier, - nonce: cookiePayload.nonce, - }); + // For an OIDC provider, id_token claims, `nonce` binding, and signature are validated inside the provider client. + const { accessToken } = await client.validateAuthorizationCode( + code, + state, + pkce ? { codeVerifier: cookiePayload.codeVerifier, nonce: cookiePayload.nonce } : undefined, + ); - const headers = { Authorization: `Bearer ${accessToken}` }; - const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', { headers }); - const googleUser = (await response.json()) as GoogleUserProps; - const providerUser = transformSocialUserData(googleUser); + const providerUser = await fetchUser({ Authorization: `Bearer ${accessToken}` }); return await handleOAuthCallback(ctx, cookiePayload, providerUser, strategy); } catch (error) { if (error instanceof AppError) throw error; const type = error instanceof OAuthCodeExchangeError ? 'invalid_credentials' : 'oauth_failed'; - throw new AppError(401, type, 'error', { - meta: { strategy }, - ...(error instanceof Error ? { originalError: error } : {}), - }); + throw new AppError(401, type, 'error', { meta: { strategy }, ...(error instanceof Error ? { originalError: error } : {}) }); } -}); +}; -app.openapi(authOAuthRoutes.microsoftCallback, async (ctx) => { - const { state, code } = ctx.req.valid('query'); - const strategy = 'microsoft' as EnabledOAuthProvider; - - // Verify cookie by `state` (CSRF protection) & PKCE validation - const cookiePayload = await readOAuthCookie(ctx, state); +const app = new OpenAPIHono({ defaultHook }); - if (!code || !cookiePayload?.codeVerifier) throw new AppError(401, 'invalid_state', 'error', { meta: { strategy } }); +app.openapi(authOAuthRoutes.startOAuthConnect, async (ctx) => { + const { user, session } = ctx.var; - try { - // id_token claims, `nonce` binding, and signature are validated inside the provider client. - const { accessToken } = await microsoftAuth.validateAuthorizationCode(code, state, { - codeVerifier: cookiePayload.codeVerifier, - nonce: cookiePayload.nonce, - }); + // The provider's callback is a navigation from another site: this Lax cookie's token is what names the account. It + // serves only while the session that asked lives, so a sign-out (here or elsewhere) ends a connect left half-way. + await issueCookieToken(ctx, { type: 'oauth-connect', userId: user.id, email: user.email, createdBy: user.id, sessionId: session.id }); - const headers = { Authorization: `Bearer ${accessToken}` }; - const response = await fetch('https://graph.microsoft.com/oidc/userinfo', { headers }); - const microsoftUser = (await response.json()) as MicrosoftUserProps; - const providerUser = transformSocialUserData(microsoftUser); + return ctx.body(null, 204); +}); - return await handleOAuthCallback(ctx, cookiePayload, providerUser, strategy); - } catch (error) { - if (error instanceof AppError) throw error; +app.openapi(authOAuthRoutes.github, (ctx) => startOAuth(ctx, 'github')); +app.openapi(authOAuthRoutes.google, (ctx) => startOAuth(ctx, 'google')); +app.openapi(authOAuthRoutes.microsoft, (ctx) => startOAuth(ctx, 'microsoft')); - const type = error instanceof OAuthCodeExchangeError ? 'invalid_credentials' : 'oauth_failed'; - throw new AppError(401, type, 'error', { - meta: { strategy }, - ...(error instanceof Error ? { originalError: error } : {}), - }); - } -}); +app.openapi(authOAuthRoutes.githubCallback, (ctx) => finishOAuth(ctx, 'github')); +app.openapi(authOAuthRoutes.googleCallback, (ctx) => finishOAuth(ctx, 'google')); +app.openapi(authOAuthRoutes.microsoftCallback, (ctx) => finishOAuth(ctx, 'microsoft')); export const authOAuthHandlers = app; diff --git a/backend/src/modules/auth/oauth/oauth-routes.ts b/backend/src/modules/auth/oauth/oauth-routes.ts index fbe3431b8..31b97156c 100644 --- a/backend/src/modules/auth/oauth/oauth-routes.ts +++ b/backend/src/modules/auth/oauth/oauth-routes.ts @@ -1,151 +1,88 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, xRoute } from '#/core/x-routes'; import { publicGuard, stepUpGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter, tokenLimiter } from '#/middlewares/rate-limiter/limiters'; import { oauthCallbackQuerySchema, oauthQuerySchema } from '#/modules/auth/oauth/oauth-schema'; -import { cookieSchema, errorResponseRefs, locationSchema } from '#/schemas'; +import { cookieSchema, locationSchema } from '#/schemas'; -const authOAuthRoutes = { - startOAuthConnect: createXRoute({ - operationId: 'startOAuthConnect', - 'x-strategy': 'oauth', +const authOAuthRoutes = createXRoutes(['auth', 'cella'], { + startOAuthConnect: xRoute({ method: 'post', path: '/oauth-connect', + xEnabledBy: { strategy: 'oauth' }, xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['auth', 'cella'], summary: 'Start connecting a provider', description: "Pins this browser's next provider sign-in with `type=connect` to the current user, for ten minutes and once: the provider's callback connects the provider account to the user that started it. Call it right before sending the browser to the provider.", - responses: { - 204: { - description: 'Connect pinned', - headers: z.object({ 'Set-Cookie': cookieSchema }), - }, - ...errorResponseRefs, - }, + responses: { 204: { description: 'Connect pinned', headers: z.object({ 'Set-Cookie': cookieSchema }) } }, }), - github: createXRoute({ - operationId: 'github', - 'x-strategy': { oauth: 'github' }, + github: xRoute({ method: 'get', path: '/github', + xEnabledBy: { strategy: 'oauth', provider: 'github' }, xGuard: [publicGuard], - tags: ['auth', 'cella'], summary: 'Authenticate with GitHub', description: 'Starts OAuth authentication with GitHub. Can be used for account connection, email verification, invitation process, defaults to authentication.', request: { query: oauthQuerySchema }, - responses: { - 302: { - description: 'Redirect to GitHub', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + responses: { 302: { description: 'Redirect to GitHub', headers: locationSchema } }, }), - githubCallback: createXRoute({ - operationId: 'githubCallback', - 'x-strategy': { oauth: 'github' }, + githubCallback: xRoute({ method: 'get', path: '/github/callback', + xEnabledBy: { strategy: 'oauth', provider: 'github' }, xGuard: [publicGuard], xRateLimiter: [tokenLimiter('github')], - tags: ['auth', 'cella'], summary: 'Callback for GitHub', description: 'Handles GitHub OAuth callback, retrieves user identity, and establishes a session or links account.', - request: { - query: oauthCallbackQuerySchema.extend({ - error: z.string().optional(), - error_description: z.string().optional(), - error_uri: z.string().optional(), - }), - }, - responses: { - 302: { - description: 'Redirect to frontend', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + request: { query: oauthCallbackQuerySchema }, + responses: { 302: { description: 'Redirect to frontend', headers: locationSchema } }, }), - google: createXRoute({ - operationId: 'google', - 'x-strategy': { oauth: 'google' }, + google: xRoute({ method: 'get', path: '/google', + xEnabledBy: { strategy: 'oauth', provider: 'google' }, xGuard: [publicGuard], - tags: ['auth', 'cella'], summary: 'Authenticate with Google', description: 'Starts OAuth authentication with Google. Can be used for account connection, email verification, invitation process, defaults to authentication.', request: { query: oauthQuerySchema }, - responses: { - 302: { - description: 'Redirect to Google', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + responses: { 302: { description: 'Redirect to Google', headers: locationSchema } }, }), - googleCallback: createXRoute({ - operationId: 'googleCallback', - 'x-strategy': { oauth: 'google' }, + googleCallback: xRoute({ method: 'get', path: '/google/callback', + xEnabledBy: { strategy: 'oauth', provider: 'google' }, xGuard: [publicGuard], xRateLimiter: [tokenLimiter('google')], - tags: ['auth', 'cella'], summary: 'Callback for Google', description: 'Handles Google OAuth callback, retrieves user identity, and establishes a session or links account.', request: { query: oauthCallbackQuerySchema }, - responses: { - 302: { - description: 'Redirect to frontend', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + responses: { 302: { description: 'Redirect to frontend', headers: locationSchema } }, }), - microsoft: createXRoute({ - operationId: 'microsoft', - 'x-strategy': { oauth: 'microsoft' }, + microsoft: xRoute({ method: 'get', path: '/microsoft', + xEnabledBy: { strategy: 'oauth', provider: 'microsoft' }, xGuard: [publicGuard], - tags: ['auth', 'cella'], summary: 'Authenticate with Microsoft', description: 'Starts OAuth authentication with Microsoft. Can be used for account connection, email verification, invitation process, defaults to authentication.', request: { query: oauthQuerySchema }, - responses: { - 302: { - description: 'Redirect to Microsoft', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + responses: { 302: { description: 'Redirect to Microsoft', headers: locationSchema } }, }), - microsoftCallback: createXRoute({ - operationId: 'microsoftCallback', - 'x-strategy': { oauth: 'microsoft' }, + microsoftCallback: xRoute({ method: 'get', path: '/microsoft/callback', + xEnabledBy: { strategy: 'oauth', provider: 'microsoft' }, xGuard: [publicGuard], xRateLimiter: [tokenLimiter('microsoft')], - tags: ['auth', 'cella'], summary: 'Callback for Microsoft', - description: - 'Handles Microsoft OAuth callback, retrieves user identity, and establishes a session or links account.', + description: 'Handles Microsoft OAuth callback, retrieves user identity, and establishes a session or links account.', request: { query: oauthCallbackQuerySchema }, - responses: { - 302: { - description: 'Redirect to frontend', - headers: locationSchema, - }, - ...errorResponseRefs, - }, + responses: { 302: { description: 'Redirect to frontend', headers: locationSchema } }, }), -}; +}); export { authOAuthRoutes }; diff --git a/backend/src/modules/auth/oauth/oauth-schema.ts b/backend/src/modules/auth/oauth/oauth-schema.ts index 914d51eeb..d3206cb0b 100644 --- a/backend/src/modules/auth/oauth/oauth-schema.ts +++ b/backend/src/modules/auth/oauth/oauth-schema.ts @@ -1,14 +1,14 @@ import { z } from '@hono/zod-openapi'; +import { supportedOAuthProviders } from '#/modules/auth/identities-db'; const oauthFlowTypes = ['auth', 'connect', 'invite', 'verify'] as const; export type OAuthFlowType = (typeof oauthFlowTypes)[number]; -export const oauthQuerySchema = z.object({ - type: z.enum(oauthFlowTypes).default('auth'), - redirectAfter: z.string().optional(), -}); +export const oauthQuerySchema = z.object({ type: z.enum(oauthFlowTypes).default('auth'), redirectAfter: z.string().optional() }); +/** The state cookie of one round trip; `provider` is the provider whose start minted the state. */ export const oauthCookiePayloadSchema = z.object({ + provider: z.enum(supportedOAuthProviders), type: z.enum(oauthFlowTypes).default('auth'), redirectAfter: z.string().optional(), codeVerifier: z.string().optional(), @@ -17,7 +17,11 @@ export const oauthCookiePayloadSchema = z.object({ export type OAuthCookiePayload = z.infer; +/** A provider denial returns `error` and `state` without a `code` (RFC 6749 §4.1.2.1). */ export const oauthCallbackQuerySchema = z.object({ - code: z.string(), + code: z.string().optional(), state: z.string(), + error: z.string().optional(), + error_description: z.string().optional(), + error_uri: z.string().optional(), }); diff --git a/backend/src/modules/auth/passkeys/helpers/passkey.ts b/backend/src/modules/auth/passkeys/operations/passkey-challenges.ts similarity index 71% rename from backend/src/modules/auth/passkeys/helpers/passkey.ts rename to backend/src/modules/auth/passkeys/operations/passkey-challenges.ts index 191cd0c9c..f2e8cf6e8 100644 --- a/backend/src/modules/auth/passkeys/helpers/passkey.ts +++ b/backend/src/modules/auth/passkeys/operations/passkey-challenges.ts @@ -5,23 +5,30 @@ import { verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; -import { and, eq, lt, or } from 'drizzle-orm'; import type { Context } from 'hono'; import { appConfig } from 'shared'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { baseDb as db } from '#/db/db'; +import { baseDb } from '#/db/db'; import { deleteAuthCookie, getAuthCookie, setAuthCookie } from '#/modules/auth/general/helpers/cookie'; -import { type PasskeyChallengePurpose, passkeyChallengesTable } from '#/modules/auth/passkeys/passkey-challenges-db'; -import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; +import type { PasskeyChallengePurpose } from '#/modules/auth/passkeys/passkey-challenges-db'; +import { + deletePasskeyChallenge, + deleteStalePasskeyChallenges, + findPasskeyByCredentialId, + insertPasskeyChallenge, + updatePasskeyCounter, +} from '#/modules/auth/passkeys/passkeys-queries'; import { hashToken } from '#/utils/hash-token'; import { isExpiredDate } from '#/utils/is-expired-date'; -import { getIsoDate } from '#/utils/iso-date'; import { createDate, TimeSpan } from '#/utils/time-span'; const relyingPartyId = appConfig.mode === 'development' ? 'localhost' : appConfig.domain; const expectedOrigin = appConfig.frontendUrl; +/** Factor checks read and write on the base pool, whatever the route's context holds. */ +const dbCtx = { var: { db: baseDb } }; + /** How long a challenge can be answered; its cookie and its row expire together. */ const challengeLifetime = new TimeSpan(5, 'm'); @@ -41,18 +48,12 @@ interface IssuePasskeyChallengeOpts { * @returns The challenge, 32 random bytes as base64url: the value the WebAuthn options take as they are. */ export const issuePasskeyChallenge = async (ctx: Context, { purpose, userId }: IssuePasskeyChallengeOpts) => { - const expired = lt(passkeyChallengesTable.expiresAt, getIsoDate()); const previous = await getAuthCookie(ctx, 'passkey-challenge'); - await db - .delete(passkeyChallengesTable) - .where(previous ? or(eq(passkeyChallengesTable.challengeHash, hashToken(previous)), expired) : expired); + await deleteStalePasskeyChallenges(dbCtx, { previousHash: previous ? hashToken(previous) : undefined }); const challenge = Buffer.from(getRandomValues(new Uint8Array(32))).toString('base64url'); - await db.insert(passkeyChallengesTable).values({ - challengeHash: hashToken(challenge), - purpose, - userId: userId ?? null, - expiresAt: createDate(challengeLifetime), + await insertPasskeyChallenge(dbCtx, { + values: { challengeHash: hashToken(challenge), purpose, userId: userId ?? null, expiresAt: createDate(challengeLifetime) }, }); await setAuthCookie(ctx, 'passkey-challenge', challenge, challengeLifetime); @@ -71,14 +72,7 @@ const consumeChallenge = async (ctx: Context, purpose: PasskeyChallengePurp deleteAuthCookie(ctx, 'passkey-challenge'); if (!challenge) throw verificationFailed(); - const [issued] = await db - .delete(passkeyChallengesTable) - .where(eq(passkeyChallengesTable.challengeHash, hashToken(challenge))) - .returning({ - purpose: passkeyChallengesTable.purpose, - userId: passkeyChallengesTable.userId, - expiresAt: passkeyChallengesTable.expiresAt, - }); + const issued = await deletePasskeyChallenge(dbCtx, { challengeHash: hashToken(challenge) }); if (!issued || issued.purpose !== purpose || isExpiredDate(issued.expiresAt)) throw verificationFailed(); if (issued.userId && issued.userId !== userId) throw verificationFailed(); @@ -111,11 +105,7 @@ export const verifyPasskeyRegistration = async (ctx: Context, attestation: if (!verified || !registrationInfo) throw registrationFailed(); const { credential } = registrationInfo; - return { - credentialId: credential.id, - publicKey: Buffer.from(credential.publicKey).toString('base64url'), - counter: credential.counter, - }; + return { credentialId: credential.id, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter }; }; interface VerifyPasskeyAssertionOpts { @@ -127,24 +117,15 @@ interface VerifyPasskeyAssertionOpts { /** * Verifies a passkey (WebAuthn) authentication response against the challenge of `purpose` this browser holds, which is - * spent first: signature, relying party, origin, user verification and signature counter. The new counter is stored - * only while the stored one is still lower (or both are 0, for an authenticator without a counter), so of two copies - * of one authenticator answering at once, one fails. + * spent first: signature, relying party, origin, user verification and signature counter, which only moves forward. * @returns The id of the account the passkey belongs to. * @throws AppError 401 `passkey_verification_failed`, or 404 `passkey_not_found` for a credential that is not * registered (to the account `userId` names). */ -export const verifyPasskeyAssertion = async ( - ctx: Context, - { assertion, purpose, userId }: VerifyPasskeyAssertionOpts, -): Promise => { +export const verifyPasskeyAssertion = async (ctx: Context, { assertion, purpose, userId }: VerifyPasskeyAssertionOpts): Promise => { const challenge = await consumeChallenge(ctx, purpose, userId); - const [passkey] = await db - .select() - .from(passkeysTable) - .where(and(eq(passkeysTable.credentialId, assertion.id), userId ? eq(passkeysTable.userId, userId) : undefined)) - .limit(1); + const passkey = await findPasskeyByCredentialId(dbCtx, { credentialId: assertion.id, userId }); if (!passkey) throw new AppError(404, 'passkey_not_found', 'warn'); // The library throws for most mismatches (challenge, origin, relying party, flags, counter) and answers false for a @@ -154,28 +135,14 @@ export const verifyPasskeyAssertion = async ( expectedChallenge: challenge, expectedOrigin, expectedRPID: relyingPartyId, - credential: { - id: passkey.credentialId, - publicKey: new Uint8Array(Buffer.from(passkey.publicKey, 'base64url')), - counter: passkey.counter, - }, + credential: { id: passkey.credentialId, publicKey: new Uint8Array(Buffer.from(passkey.publicKey, 'base64url')), counter: passkey.counter }, requireUserVerification: true, }).catch((error: unknown) => { throw verificationFailed(error); }); if (!verified) throw verificationFailed(); - const { newCounter } = authenticationInfo; - const [stored] = await db - .update(passkeysTable) - .set({ counter: newCounter }) - .where( - and( - eq(passkeysTable.id, passkey.id), - newCounter > 0 ? lt(passkeysTable.counter, newCounter) : eq(passkeysTable.counter, 0), - ), - ) - .returning({ id: passkeysTable.id }); + const stored = await updatePasskeyCounter(dbCtx, { id: passkey.id, counter: authenticationInfo.newCounter }); if (!stored) throw verificationFailed(); return passkey.userId; diff --git a/backend/src/modules/auth/passkeys/passkeys-handlers.ts b/backend/src/modules/auth/passkeys/passkeys-handlers.ts index 875e3e951..ea375fc4b 100644 --- a/backend/src/modules/auth/passkeys/passkeys-handlers.ts +++ b/backend/src/modules/auth/passkeys/passkeys-handlers.ts @@ -1,19 +1,14 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server'; -import { and, eq } from 'drizzle-orm'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { findCredentialIdsByUser, insertPasskey } from '#/modules/auth/auth-queries'; import { deviceInfo } from '#/modules/auth/general/helpers/device-info'; -import { completeMfaChallenge, mfaFactorRules, validateConfirmMfaToken } from '#/modules/auth/general/helpers/mfa'; import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; import { setUserSession } from '#/modules/auth/general/helpers/session'; -import { - issuePasskeyChallenge, - verifyPasskeyAssertion, - verifyPasskeyRegistration, -} from '#/modules/auth/passkeys/helpers/passkey'; -import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; +import { mfaFactorRules } from '#/modules/auth/mfa/operations/factor-rules'; +import { completeMfaChallenge, validateConfirmMfaToken } from '#/modules/auth/mfa/operations/mfa-challenge'; +import { issuePasskeyChallenge, verifyPasskeyAssertion, verifyPasskeyRegistration } from '#/modules/auth/passkeys/operations/passkey-challenges'; +import { deletePasskey, findCredentialIdsByUser, insertPasskey } from '#/modules/auth/passkeys/passkeys-queries'; import { authPasskeysRoutes } from '#/modules/auth/passkeys/passkeys-routes'; import { spendCookieToken } from '#/modules/auth/tokens/token-lifecycle'; import { findUserById } from '#/modules/user/user-queries'; @@ -26,10 +21,7 @@ app.openapi(authPasskeysRoutes.createPasskey, async (ctx) => { const { attestation, nameOnDevice } = ctx.req.valid('json'); - const { credentialId, publicKey, counter } = await verifyPasskeyRegistration( - ctx, - attestation as RegistrationResponseJSON, - ); + const { credentialId, publicKey, counter } = await verifyPasskeyRegistration(ctx, attestation as RegistrationResponseJSON); const device = deviceInfo(ctx); const passkeyValue = { @@ -60,7 +52,7 @@ app.openapi(authPasskeysRoutes.deletePasskey, async (ctx) => { // The delete rolls back when MFA is on and this was the last passkey: it stays until MFA is turned off. await mfaFactorRules.locked(user.id, async (tx) => { - await tx.delete(passkeysTable).where(and(eq(passkeysTable.userId, user.id), eq(passkeysTable.id, id))); + await deletePasskey({ var: { db: tx } }, { userId: user.id, id }); await mfaFactorRules.assertKeepsFactors(tx, user.id); }); diff --git a/backend/src/modules/auth/passkeys/passkeys-queries.ts b/backend/src/modules/auth/passkeys/passkeys-queries.ts new file mode 100644 index 000000000..4da041ee3 --- /dev/null +++ b/backend/src/modules/auth/passkeys/passkeys-queries.ts @@ -0,0 +1,107 @@ +import { and, eq, getColumns, lt, or } from 'drizzle-orm'; +import type { DbContext } from '#/core/context'; +import { passkeyChallengesTable } from '#/modules/auth/passkeys/passkey-challenges-db'; +import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; +import { getIsoDate } from '#/utils/iso-date'; + +interface FindCredentialIdsByUserOpts { + userId: string; +} + +export const findCredentialIdsByUser = async (ctx: DbContext, { userId }: FindCredentialIdsByUserOpts) => { + return ctx.var.db.select({ credentialId: passkeysTable.credentialId }).from(passkeysTable).where(eq(passkeysTable.userId, userId)); +}; + +interface FindPasskeyByCredentialIdOpts { + credentialId: string; + /** The account the passkey must belong to; any account when omitted. */ + userId?: string; +} + +export const findPasskeyByCredentialId = async (ctx: DbContext, { credentialId, userId }: FindPasskeyByCredentialIdOpts) => { + const [passkey] = await ctx.var.db + .select() + .from(passkeysTable) + .where(and(eq(passkeysTable.credentialId, credentialId), userId ? eq(passkeysTable.userId, userId) : undefined)) + .limit(1); + return passkey; +}; + +interface InsertPasskeyOpts { + values: typeof passkeysTable.$inferInsert; +} + +/** + * Insert a passkey and return the created row (excluding credentialId and publicKey), or undefined when its credential + * id is registered already, to this account or another. + */ +export const insertPasskey = async (ctx: DbContext, { values }: InsertPasskeyOpts) => { + const { credentialId: _, publicKey: __, ...passkeySelect } = getColumns(passkeysTable); + const [newPasskey] = await ctx.var.db + .insert(passkeysTable) + .values(values) + .onConflictDoNothing({ target: passkeysTable.credentialId }) + .returning(passkeySelect); + return newPasskey; +}; + +interface UpdatePasskeyCounterOpts { + id: string; + counter: number; +} + +/** + * Stores the new signature counter only while the stored one is still lower (or both are 0, for an authenticator + * without a counter); undefined otherwise, so of two copies of one authenticator answering at once, one fails. + */ +export const updatePasskeyCounter = async (ctx: DbContext, { id, counter }: UpdatePasskeyCounterOpts) => { + const counterAdvances = counter > 0 ? lt(passkeysTable.counter, counter) : eq(passkeysTable.counter, 0); + const [stored] = await ctx.var.db + .update(passkeysTable) + .set({ counter }) + .where(and(eq(passkeysTable.id, id), counterAdvances)) + .returning({ id: passkeysTable.id }); + return stored; +}; + +interface DeletePasskeyOpts { + userId: string; + id: string; +} + +export const deletePasskey = async (ctx: DbContext, { userId, id }: DeletePasskeyOpts) => { + await ctx.var.db.delete(passkeysTable).where(and(eq(passkeysTable.userId, userId), eq(passkeysTable.id, id))); +}; + +interface InsertPasskeyChallengeOpts { + values: typeof passkeyChallengesTable.$inferInsert; +} + +export const insertPasskeyChallenge = async (ctx: DbContext, { values }: InsertPasskeyChallengeOpts) => { + await ctx.var.db.insert(passkeyChallengesTable).values(values); +}; + +interface DeleteStalePasskeyChallengesOpts { + /** The challenge this browser held before, if any. */ + previousHash?: string; +} + +/** Drops every expired challenge, and the one this browser held before. */ +export const deleteStalePasskeyChallenges = async (ctx: DbContext, { previousHash }: DeleteStalePasskeyChallengesOpts) => { + const expired = lt(passkeyChallengesTable.expiresAt, getIsoDate()); + await ctx.var.db.delete(passkeyChallengesTable).where(previousHash ? or(eq(passkeyChallengesTable.challengeHash, previousHash), expired) : expired); +}; + +interface DeletePasskeyChallengeOpts { + challengeHash: string; +} + +/** Takes a challenge out of play; returns what it was issued for, or undefined when it does not exist. */ +export const deletePasskeyChallenge = async (ctx: DbContext, { challengeHash }: DeletePasskeyChallengeOpts) => { + const [issued] = await ctx.var.db.delete(passkeyChallengesTable).where(eq(passkeyChallengesTable.challengeHash, challengeHash)).returning({ + purpose: passkeyChallengesTable.purpose, + userId: passkeyChallengesTable.userId, + expiresAt: passkeyChallengesTable.expiresAt, + }); + return issued; +}; diff --git a/backend/src/modules/auth/passkeys/passkeys-routes.ts b/backend/src/modules/auth/passkeys/passkeys-routes.ts index 1625e74be..31d60c97b 100644 --- a/backend/src/modules/auth/passkeys/passkeys-routes.ts +++ b/backend/src/modules/auth/passkeys/passkeys-routes.ts @@ -1,5 +1,5 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { publicGuard, stepUpGuard, userGuard } from '#/middlewares/guard'; import { passkeyChallengeLimiter, singlePointsLimiter, tokenLimiter } from '#/middlewares/rate-limiter/limiters'; import { mockPasskeyChallengeResponse, mockPasskeyResponse } from '#/modules/auth/auth-mocks'; @@ -10,100 +10,53 @@ import { passkeySchema, passkeyVerificationBodySchema, } from '#/modules/auth/passkeys/passkeys-schema'; -import { cookieSchema, errorResponseRefs, validIdSchema } from '#/schemas'; +import { cookieSchema, validIdSchema } from '#/schemas'; -const authPasskeysRoutes = { - generatePasskeyChallenge: createXRoute({ - operationId: 'generatePasskeyChallenge', - 'x-strategy': 'passkey', +const authPasskeysRoutes = createXRoutes(['auth', 'cella'], { + generatePasskeyChallenge: xRoute({ method: 'post', path: '/passkey/generate-challenge', + xEnabledBy: { strategy: 'passkey' }, xGuard: [publicGuard], xRateLimiter: [passkeyChallengeLimiter], - tags: ['auth', 'cella'], summary: 'Generate passkey challenge', description: 'Initiates the passkey registration or authentication flow by generating a device bound challenge.', - request: { - body: { - required: true, - content: { 'application/json': { schema: passkeyChallengeBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Challenge generated', - content: { 'application/json': { schema: passkeyChallengeSchema, example: mockPasskeyChallengeResponse() } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(passkeyChallengeBodySchema) }, + responses: { 200: json('Challenge generated', passkeyChallengeSchema, mockPasskeyChallengeResponse()) }, }), - createPasskey: createXRoute({ - operationId: 'createPasskey', - 'x-strategy': 'passkey', + createPasskey: xRoute({ method: 'post', path: '/passkey', + xEnabledBy: { strategy: 'passkey' }, xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['auth', 'cella'], summary: 'Create passkey', description: 'Register a passkey for passwordless authentication by verifying a signed challenge and linking it to the current user. Multiple passkeys can be created for different devices/browsers.', - request: { - body: { - required: true, - content: { 'application/json': { schema: passkeyCreateBodySchema } }, - }, - }, - responses: { - 201: { - description: 'Passkey created', - content: { 'application/json': { schema: passkeySchema, example: mockPasskeyResponse() } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(passkeyCreateBodySchema) }, + responses: { 201: json('Passkey created', passkeySchema, mockPasskeyResponse()) }, }), - deletePasskey: createXRoute({ - operationId: 'deletePasskey', - 'x-strategy': null, + deletePasskey: xRoute({ method: 'delete', path: '/passkey/{id}', xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['auth', 'cella'], summary: 'Delete passkey', description: 'Delete a passkey by id from the current user.', request: { params: z.object({ id: validIdSchema }) }, - responses: { - 204: { - description: 'Passkey deleted', - }, - ...errorResponseRefs, - }, + responses: { 204: { description: 'Passkey deleted' } }, }), - signInWithPasskey: createXRoute({ - operationId: 'signInWithPasskey', - 'x-strategy': 'passkey', + signInWithPasskey: xRoute({ method: 'post', path: '/passkey-verification', + xEnabledBy: { strategy: 'passkey' }, xGuard: [publicGuard], xRateLimiter: [tokenLimiter('passkey')], - tags: ['auth', 'cella'], summary: 'Verify passkey', description: 'Validates the signed challenge and completes passkey based authentication.', - request: { - body: { - required: true, - content: { 'application/json': { schema: passkeyVerificationBodySchema } }, - }, - }, - responses: { - 204: { - description: 'Passkey verified', - headers: z.object({ 'Set-Cookie': cookieSchema }), - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(passkeyVerificationBodySchema) }, + responses: { 204: { description: 'Passkey verified', headers: z.object({ 'Set-Cookie': cookieSchema }) } }, }), -}; +}); export { authPasskeysRoutes }; diff --git a/backend/src/modules/auth/passkeys/passkeys-schema.ts b/backend/src/modules/auth/passkeys/passkeys-schema.ts index 64f4658f3..e6a54d9d9 100644 --- a/backend/src/modules/auth/passkeys/passkeys-schema.ts +++ b/backend/src/modules/auth/passkeys/passkeys-schema.ts @@ -6,11 +6,7 @@ import { maxLength } from '#/schemas'; const passkeyTypeSchema = z.enum(['authentication', 'mfa']); const challengeTypeSchema = z.enum([...passkeyTypeSchema.options, 'registration']); -export const passkeySchema = createSelectSchema(passkeysTable).omit({ - credentialId: true, - publicKey: true, - counter: true, -}); +export const passkeySchema = createSelectSchema(passkeysTable).omit({ credentialId: true, publicKey: true, counter: true }); /** WebAuthn registration response (`RegistrationResponseJSON`); binary fields are base64url strings. */ export const webAuthnAttestationSchema = z.object({ @@ -34,28 +30,17 @@ export const webAuthnAttestationSchema = z.object({ export const webAuthnAssertionSchema = z.object({ id: z.string(), rawId: z.string(), - response: z.object({ - clientDataJSON: z.string(), - authenticatorData: z.string(), - signature: z.string(), - userHandle: z.string().optional(), - }), + response: z.object({ clientDataJSON: z.string(), authenticatorData: z.string(), signature: z.string(), userHandle: z.string().optional() }), authenticatorAttachment: z.enum(['cross-platform', 'platform']).optional(), clientExtensionResults: z.unknown().optional(), type: z.literal('public-key'), }); -export const passkeyCreateBodySchema = z.object({ - attestation: webAuthnAttestationSchema, - nameOnDevice: z.string().max(maxLength.field), -}); +export const passkeyCreateBodySchema = z.object({ attestation: webAuthnAttestationSchema, nameOnDevice: z.string().max(maxLength.field) }); export const passkeyChallengeBodySchema = z.object({ type: challengeTypeSchema }); /** `credentialIds` lists the account's passkeys for an MFA challenge only; it is empty for any other challenge. */ export const passkeyChallengeSchema = z.object({ challenge: z.string(), credentialIds: z.array(z.string()) }); -export const passkeyVerificationBodySchema = z.object({ - assertion: webAuthnAssertionSchema, - type: passkeyTypeSchema, -}); +export const passkeyVerificationBodySchema = z.object({ assertion: webAuthnAssertionSchema, type: passkeyTypeSchema }); diff --git a/backend/src/modules/auth/sessions-db.ts b/backend/src/modules/auth/sessions-db.ts index ee2c36ade..c7767d9f0 100644 --- a/backend/src/modules/auth/sessions-db.ts +++ b/backend/src/modules/auth/sessions-db.ts @@ -19,14 +19,7 @@ export type AuthStrategy = (typeof authStrategiesEnum)[number]; * housekeeping during a sign-in: `session_cap` beyond `maxSessionsPerUser`. `replaced` by a newer session in the same * browser: a sign-in, or the mfa session that enabling MFA mints. `impersonation_stopped` when the admin stops. */ -export const sessionRevocationReasons = [ - 'sign_out', - 'other_session', - 'mfa_enabled', - 'session_cap', - 'replaced', - 'impersonation_stopped', -] as const; +export const sessionRevocationReasons = ['sign_out', 'other_session', 'mfa_enabled', 'session_cap', 'replaced', 'impersonation_stopped'] as const; export type SessionRevocationReason = (typeof sessionRevocationReasons)[number]; /** Why sessions end: a revocation, or `user_deleted`, whose delete takes the session rows along. */ @@ -84,9 +77,7 @@ export const sessionsTable = snakeCase.table( index('sessions_ip_subnet_hash_idx').on(table.ipSubnetHash), index('sessions_user_id_device_id_hash_idx').on(table.userId, table.deviceIdHash), // Impersonations are found by their admin's session when it ends or is deleted; other rows hold null. - index('sessions_impersonator_session_id_idx') - .on(table.impersonatorSessionId) - .where(sql`${table.impersonatorSessionId} is not null`), + index('sessions_impersonator_session_id_idx').on(table.impersonatorSessionId).where(sql`${table.impersonatorSessionId} is not null`), ], ); diff --git a/backend/src/modules/auth/step-up/helpers/step-up-link.ts b/backend/src/modules/auth/step-up/helpers/step-up-link.ts index 9a0b8842a..b56d5b004 100644 --- a/backend/src/modules/auth/step-up/helpers/step-up-link.ts +++ b/backend/src/modules/auth/step-up/helpers/step-up-link.ts @@ -22,8 +22,7 @@ export const openStepUpLink = async (ctx: Context, rawToken: string) => { const redeemed = await invokeToken(ctx, { type: 'step-up', rawToken }); forgetLinkRequest(ctx, 'step-up'); - const stamped = - !!redeemed.userId && !!redeemed.sessionId && (await stampStepUp(redeemed.sessionId, redeemed.userId, 'email')); + const stamped = !!redeemed.userId && !!redeemed.sessionId && (await stampStepUp(redeemed.sessionId, redeemed.userId, 'email')); if (!stamped) throw new AppError(401, 'step-up_expired', 'warn'); log.info('Session stepped up', { via: 'email', sessionId: redeemed.sessionId }); diff --git a/backend/src/modules/auth/step-up/helpers/step-up.ts b/backend/src/modules/auth/step-up/helpers/step-up.ts index 6f66aa884..3cb16d710 100644 --- a/backend/src/modules/auth/step-up/helpers/step-up.ts +++ b/backend/src/modules/auth/step-up/helpers/step-up.ts @@ -2,7 +2,6 @@ import { and, eq, gt, isNull, ne, sql } from 'drizzle-orm'; import { appConfig } from 'shared'; import { AppError } from '#/core/error'; import { baseDb } from '#/db/db'; -import { refuseImpersonation } from '#/middlewares/guard/no-impersonation-guard'; import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; import { type SessionFacts, type StepUpProof, sessionsTable } from '#/modules/auth/sessions-db'; import { totpsTable } from '#/modules/auth/totps/totps-db'; @@ -56,22 +55,28 @@ export const readStepUp = async (session: SessionFacts): Promise => if (!row) return refused; const held = { passkey: row.hasPasskey, totp: row.hasTotp }; - const factors = (['passkey', 'totp'] as const).filter( - (factor) => held[factor] && appConfig.enabledAuthStrategies.includes(factor), - ); + const factors = (['passkey', 'totp'] as const).filter((factor) => held[factor] && appConfig.enabledAuthStrategies.includes(factor)); if (factors.length === 0) { return { steppedUp: !!row.stampedVia || row.signedInRecently, methods: ['email', 'sign_in'], factor: null }; } // Only a factor the user holds counts: an emailed link stands in for a factor only while the user has none. const stampedWith = factors.find((candidate) => candidate === row.stampedVia); - const signedInWith = row.signedInRecently - ? factors.find((candidate) => candidate === session.authStrategy) - : undefined; + const signedInWith = row.signedInRecently ? factors.find((candidate) => candidate === session.authStrategy) : undefined; const factor = stampedWith ?? signedInWith ?? null; return { steppedUp: !!factor, methods: factors, factor }; }; +/** + * Refuses an impersonation: the admin acts as the user, never on the account itself, its sessions or how it is + * protected. The one spelling of this answer: `requireStepUp`, `sysAdminGuard` and the handlers of stepping up and + * revoking sessions. + * @throws AppError 403 `impersonation_forbidden`. + */ +export const refuseImpersonation = (session: SessionFacts): void => { + if (session.type === 'impersonation') throw new AppError(403, 'impersonation_forbidden', 'warn'); +}; + /** * Refuses an account-security action on a session that does not stand stepped up, an impersonation first of all. * @returns The step-up state, with the factor that proves the session. diff --git a/backend/src/modules/auth/step-up/step-up-handlers.ts b/backend/src/modules/auth/step-up/step-up-handlers.ts index b9c35e5d3..940178ca4 100644 --- a/backend/src/modules/auth/step-up/step-up-handlers.ts +++ b/backend/src/modules/auth/step-up/step-up-handlers.ts @@ -5,12 +5,13 @@ import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; import { baseDb } from '#/db/db'; import { mailer } from '#/lib/mailer'; -import { findCredentialIdsByUser } from '#/modules/auth/auth-queries'; -import { issuePasskeyChallenge, verifyPasskeyAssertion } from '#/modules/auth/passkeys/helpers/passkey'; -import { readStepUp, stampStepUp } from '#/modules/auth/step-up/helpers/step-up'; +import { issuePasskeyChallenge, verifyPasskeyAssertion } from '#/modules/auth/passkeys/operations/passkey-challenges'; +import { findCredentialIdsByUser } from '#/modules/auth/passkeys/passkeys-queries'; +import { readStepUp, refuseImpersonation, stampStepUp } from '#/modules/auth/step-up/helpers/step-up'; import { authStepUpRoutes } from '#/modules/auth/step-up/step-up-routes'; import { issueToken, rememberLinkRequest } from '#/modules/auth/tokens/token-lifecycle'; -import { verifyTotp } from '#/modules/auth/totps/helpers/totps'; +import { tokenLinkUrl } from '#/modules/auth/tokens/token-policies'; +import { verifyTotp } from '#/modules/auth/totps/operations/verify-totp'; import { defaultHook } from '#/utils/default-hook'; import { isValidRedirectPath } from '#/utils/is-redirect-url'; import { log } from '#/utils/logger'; @@ -24,7 +25,8 @@ app.openapi(authStepUpRoutes.getStepUp, async (ctx) => { }); app.openapi(authStepUpRoutes.getStepUpPasskeyChallenge, async (ctx) => { - const { user } = ctx.var; + const { user, session } = ctx.var; + refuseImpersonation(session); // Issued for this account and for a step-up only: a sign-in or MFA challenge never answers as a step-up proof. const challenge = await issuePasskeyChallenge(ctx, { purpose: 'step-up', userId: user.id }); @@ -35,6 +37,7 @@ app.openapi(authStepUpRoutes.getStepUpPasskeyChallenge, async (ctx) => { app.openapi(authStepUpRoutes.stepUp, async (ctx) => { const { user, session } = ctx.var; + refuseImpersonation(session); const { passkeyData, totpCode } = ctx.req.valid('json'); const via = passkeyData ? 'passkey' : totpCode ? 'totp' : null; @@ -57,6 +60,7 @@ app.openapi(authStepUpRoutes.stepUp, async (ctx) => { app.openapi(authStepUpRoutes.sendStepUpLink, async (ctx) => { const { user, session } = ctx.var; + refuseImpersonation(session); const { redirect } = ctx.req.valid('json'); // An emailed link stands in for a second factor only while the user holds none. @@ -79,10 +83,8 @@ app.openapi(authStepUpRoutes.sendStepUpLink, async (ctx) => { // Opening the link stamps this session only in this browser. await rememberLinkRequest(ctx, 'step-up', token.id); - const stepUpUrl = `${appConfig.backendAuthUrl}/invoke-token/${token.type}/${rawToken}`; - mailer.prepareEmails(stepUpEmail, { stepUpUrl, name: user.name }, [ - { email: user.email, lng: user.language ?? appConfig.defaultLanguage }, - ]); + const stepUpUrl = tokenLinkUrl('step-up', rawToken); + mailer.prepareEmails(stepUpEmail, { stepUpUrl, name: user.name }, [{ email: user.email, lng: user.language ?? appConfig.defaultLanguage }]); if (appConfig.mode === 'development') console.info(`[step-up] ${user.email} ${stepUpUrl}`); log.info('Step-up link sent', { tokenId: token.id }); diff --git a/backend/src/modules/auth/step-up/step-up-routes.ts b/backend/src/modules/auth/step-up/step-up-routes.ts index a9e038a36..21467dac8 100644 --- a/backend/src/modules/auth/step-up/step-up-routes.ts +++ b/backend/src/modules/auth/step-up/step-up-routes.ts @@ -1,83 +1,52 @@ -import { createXRoute } from '#/core/x-routes'; -import { noImpersonationGuard, userGuard } from '#/middlewares/guard'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { userGuard } from '#/middlewares/guard'; import { passkeyChallengeLimiter, spamLimiter, stepUpLimiter } from '#/middlewares/rate-limiter/limiters'; import { passkeyChallengeSchema } from '#/modules/auth/passkeys/passkeys-schema'; import { stepUpBodySchema, stepUpLinkBodySchema, stepUpStateSchema } from '#/modules/auth/step-up/step-up-schema'; -import { errorResponseRefs } from '#/schemas'; -const authStepUpRoutes = { - getStepUp: createXRoute({ - operationId: 'getStepUp', +const authStepUpRoutes = createXRoutes(['auth', 'cella'], { + getStepUp: xRoute({ method: 'get', path: '/step-up', xGuard: [userGuard], - tags: ['auth', 'cella'], summary: 'Get step-up state', description: 'Whether this session stands stepped up for account-security actions, and what the user can offer to step up: a passkey or TOTP they hold, else an emailed confirmation link or a new sign-in.', - responses: { - 200: { - description: 'Step-up state', - content: { 'application/json': { schema: stepUpStateSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Step-up state', stepUpStateSchema) }, }), - getStepUpPasskeyChallenge: createXRoute({ - operationId: 'getStepUpPasskeyChallenge', - 'x-strategy': 'passkey', + getStepUpPasskeyChallenge: xRoute({ method: 'post', path: '/step-up/passkey-challenge', - xGuard: [userGuard, noImpersonationGuard], + xEnabledBy: { strategy: 'passkey' }, + xGuard: [userGuard], xRateLimiter: [passkeyChallengeLimiter], - tags: ['auth', 'cella'], summary: 'Get a step-up passkey challenge', description: "Issues a passkey challenge for a step-up of this session, bound to the current user, with the user's passkeys to offer. Only a step-up answers it.", - responses: { - 200: { - description: 'Challenge issued', - content: { 'application/json': { schema: passkeyChallengeSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Challenge issued', passkeyChallengeSchema) }, }), - stepUp: createXRoute({ - operationId: 'stepUp', + stepUp: xRoute({ method: 'post', path: '/step-up', - xGuard: [userGuard, noImpersonationGuard], + xGuard: [userGuard], xRateLimiter: [stepUpLimiter], - tags: ['auth', 'cella'], summary: 'Step up with a second factor', description: 'Proves the user is present on this session with a passkey assertion (to a step-up passkey challenge) or a TOTP code of a factor they hold. Account-security actions then pass for ten minutes.', - request: { - body: { required: true, content: { 'application/json': { schema: stepUpBodySchema } } }, - }, - responses: { - 204: { description: 'Session stepped up' }, - ...errorResponseRefs, - }, + request: { body: jsonBody(stepUpBodySchema) }, + responses: { 204: { description: 'Session stepped up' } }, }), - sendStepUpLink: createXRoute({ - operationId: 'sendStepUpLink', + sendStepUpLink: xRoute({ method: 'post', path: '/step-up/link', - xGuard: [userGuard, noImpersonationGuard], + xGuard: [userGuard], xRateLimiter: [spamLimiter], - tags: ['auth', 'cella'], summary: 'Email a step-up link', description: 'For a user without a passkey or TOTP: emails a confirmation link that steps up this session when opened in this browser within ten minutes. The link signs nobody in.', - request: { - body: { content: { 'application/json': { schema: stepUpLinkBodySchema } } }, - }, - responses: { - 204: { description: 'Link sent' }, - ...errorResponseRefs, - }, + request: { body: { content: { 'application/json': { schema: stepUpLinkBodySchema } } } }, + responses: { 204: { description: 'Link sent' } }, }), -}; +}); export { authStepUpRoutes }; diff --git a/backend/src/modules/auth/step-up/step-up-schema.ts b/backend/src/modules/auth/step-up/step-up-schema.ts index f9923eac1..fd5bb75ae 100644 --- a/backend/src/modules/auth/step-up/step-up-schema.ts +++ b/backend/src/modules/auth/step-up/step-up-schema.ts @@ -4,19 +4,12 @@ import { stepUpMethods } from '#/modules/auth/step-up/helpers/step-up'; import { totpCreateBodySchema } from '#/modules/auth/totps/totps-schema'; export const stepUpStateSchema = z.object({ - steppedUp: z - .boolean() - .openapi({ description: 'The session proved its user presence recently enough for account-security actions.' }), - methods: z - .array(z.enum(stepUpMethods)) - .openapi({ description: 'What the user can offer to step up; empty while impersonating.' }), + steppedUp: z.boolean().openapi({ description: 'The session proved its user presence recently enough for account-security actions.' }), + methods: z.array(z.enum(stepUpMethods)).openapi({ description: 'What the user can offer to step up; empty while impersonating.' }), }); /** One second-factor proof: a passkey assertion to a step-up passkey challenge, or a current TOTP code. */ -export const stepUpBodySchema = z.object({ - passkeyData: webAuthnAssertionSchema.optional(), - totpCode: totpCreateBodySchema.shape.code.optional(), -}); +export const stepUpBodySchema = z.object({ passkeyData: webAuthnAssertionSchema.optional(), totpCode: totpCreateBodySchema.shape.code.optional() }); export const stepUpLinkBodySchema = z.object({ /** The app path to return to after the link is opened. */ diff --git a/backend/src/modules/auth/tokens/token-lifecycle.test.ts b/backend/src/modules/auth/tokens/token-lifecycle.test.ts index c9006a8a6..c560fc520 100644 --- a/backend/src/modules/auth/tokens/token-lifecycle.test.ts +++ b/backend/src/modules/auth/tokens/token-lifecycle.test.ts @@ -7,13 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; import { baseDb as db } from '#/db/db'; -import { - invokeToken, - issueToken, - issueTokens, - readBoundToken, - spendCookieToken, -} from '#/modules/auth/tokens/token-lifecycle'; +import { invokeToken, issueToken, issueTokens, readBoundToken, spendCookieToken } from '#/modules/auth/tokens/token-lifecycle'; import { isTokenType, type LinkTokenType, tokenPolicies } from '#/modules/auth/tokens/token-policies'; import { tokensTable } from '#/modules/auth/tokens-db'; import { hashToken } from '#/utils/hash-token'; @@ -162,7 +156,7 @@ const racingOnRow = async (tokenId: string, redeem: () => Promise): Promis const redeeming = redeem(); await vi.waitFor(async () => { const { rows } = await db.execute<{ waiting: number }>( - sql`select count(*)::int as waiting from pg_stat_activity where wait_event_type = 'Lock' and query ilike 'update "tokens"%'`, + sql`select count(*)::int as waiting from pg_stat_activity where datname = current_database() and wait_event_type = 'Lock' and query ilike 'update "tokens"%'`, ); expect(rows[0].waiting).toBe(2); }); @@ -252,10 +246,7 @@ describe('spendCookieToken', () => { const { token, rawToken } = await issueToken(ctx, { type: 'confirm-mfa', email: user.email, userId: user.id }); const cookie = authCookie('confirm-mfa', rawToken); - const responses = await Promise.all([ - request('/spend/confirm-mfa', [cookie], 'POST'), - request('/spend/confirm-mfa', [cookie], 'POST'), - ]); + const responses = await Promise.all([request('/spend/confirm-mfa', [cookie], 'POST'), request('/spend/confirm-mfa', [cookie], 'POST')]); const spent = await Promise.all(responses.map(async (response) => (await response.json()).spent)); expect(spent.filter(Boolean)).toEqual([expect.objectContaining({ id: token.id, userId: user.id })]); diff --git a/backend/src/modules/auth/tokens/token-lifecycle.ts b/backend/src/modules/auth/tokens/token-lifecycle.ts index 7667659b1..7169d52f1 100644 --- a/backend/src/modules/auth/tokens/token-lifecycle.ts +++ b/backend/src/modules/auth/tokens/token-lifecycle.ts @@ -9,12 +9,7 @@ import { baseDb, type DbOrTx, type Tx } from '#/db/db'; import { deleteAuthCookie, getAuthCookie, setAuthCookie } from '#/modules/auth/general/helpers/cookie'; import { findSession } from '#/modules/auth/general/helpers/session'; import { sessionsTable } from '#/modules/auth/sessions-db'; -import { - type CookieTokenType, - type LinkTokenType, - type TokenReplacement, - tokenPolicies, -} from '#/modules/auth/tokens/token-policies'; +import { type CookieTokenType, type LinkTokenType, type TokenReplacement, tokenPolicies } from '#/modules/auth/tokens/token-policies'; import { type TokenRecord, tokenColumns } from '#/modules/auth/tokens/tokens-queries'; import { type InsertTokenModel, tokensTable } from '#/modules/auth/tokens-db'; import { findUserByEmail } from '#/modules/user/user-queries'; @@ -25,19 +20,12 @@ import { createDate } from '#/utils/time-span'; /** What a new token records besides its secret and expiry, which issuing sets. */ export type NewToken = Pick & - Partial< - Pick< - InsertTokenModel, - 'userId' | 'createdBy' | 'identityId' | 'inactiveMembershipId' | 'redirectPath' | 'pendingSignUp' | 'sessionId' - > - >; + Partial>; /** The subject each replacement rule names; undefined replaces nothing. See `tokenReplacements`. */ const replacementSubjects = { 'address-or-account': (token) => - token.userId - ? or(eq(tokensTable.email, token.email), eq(tokensTable.userId, token.userId)) - : eq(tokensTable.email, token.email), + token.userId ? or(eq(tokensTable.email, token.email), eq(tokensTable.userId, token.userId)) : eq(tokensTable.email, token.email), identity: ({ identityId, pendingSignUp }) => { if (identityId) return eq(tokensTable.identityId, identityId); if (!pendingSignUp) return undefined; @@ -68,10 +56,7 @@ const replacedBy = (token: NewToken): SQL | undefined => { * @returns Per token, in the given order: the stored row and the raw value. The raw value exists only here; it goes * into the link or cookie that carries the token. */ -export const issueTokens = async ( - ctx: DbContext, - tokens: NewToken[], -): Promise<{ token: TokenRecord; rawToken: string }[]> => { +export const issueTokens = async (ctx: DbContext, tokens: NewToken[]): Promise<{ token: TokenRecord; rawToken: string }[]> => { if (!tokens.length) return []; const { db } = ctx.var; @@ -124,8 +109,7 @@ export const issueCookieToken = async (ctx: Context, token: NewToken & { ty }; /** An expired token's refusal names it by id, so an error page can offer a new link; never by its raw value. */ -const expired = (token: TokenRecord) => - new AppError(401, `${token.type}_expired`, 'warn', { meta: { tokenId: token.id } }); +const expired = (token: TokenRecord) => new AppError(401, `${token.type}_expired`, 'warn', { meta: { tokenId: token.id } }); /** * Refuses a link that belongs to another account than the one this browser is signed in to. A link issued without an @@ -188,9 +172,8 @@ export const findLinkToken = async ({ type, rawToken }: LinkTokenOpts): Promise< /** Deletes the unopened link a raw value names, so neither its URL nor a confirmation page can redeem it any more. */ export const withdrawLinkToken = async ({ type, rawToken }: LinkTokenOpts) => { - await baseDb - .delete(tokensTable) - .where(and(eq(tokensTable.secret, hashToken(rawToken)), eq(tokensTable.type, type), isNull(tokensTable.invokedAt))); + const secret = hashToken(rawToken); + await baseDb.delete(tokensTable).where(and(eq(tokensTable.secret, secret), eq(tokensTable.type, type), isNull(tokensTable.invokedAt))); }; /** @@ -203,10 +186,7 @@ export const withdrawLinkToken = async ({ type, rawToken }: LinkTokenOpts) => { * @throws AppError 401 `_not_found`, 401 `_expired` (expired, or redeemed by another browser), 409 * `user_mismatch` while signed in to another account, or what `claimOwner` throws. */ -export const invokeToken = async ( - ctx: Context, - { type, rawToken, claimOwner }: InvokeTokenOpts, -): Promise => { +export const invokeToken = async (ctx: Context, { type, rawToken, claimOwner }: InvokeTokenOpts): Promise => { const { singleUseWindow } = tokenPolicies[type]; const token = await findLinkToken({ type, rawToken }); @@ -245,11 +225,7 @@ export const invokeToken = async ( const won = await redeem(tx); if (!won) return won; const userId = await claimOwner(tx, won); - const [owned] = await tx - .update(tokensTable) - .set({ userId }) - .where(eq(tokensTable.id, won.id)) - .returning(tokenColumns); + const [owned] = await tx.update(tokensTable).set({ userId }).where(eq(tokensTable.id, won.id)).returning(tokenColumns); return owned; }); @@ -280,8 +256,7 @@ export const requestedHere = async (ctx: Context, type: RequestedLinkType, (await getAuthCookie(ctx, `${type}-requested`)) === tokenId; /** Drops the marker once the link is used: it has nothing left to say. */ -export const forgetLinkRequest = (ctx: Context, type: RequestedLinkType) => - deleteAuthCookie(ctx, `${type}-requested`); +export const forgetLinkRequest = (ctx: Context, type: RequestedLinkType) => deleteAuthCookie(ctx, `${type}-requested`); /** * Names the row a browser's cookie of `type` binds it to, by the hash of the cookie's value: after a link's redemption @@ -351,9 +326,7 @@ const isLiveSession = async (db: DbOrTx, sessionId: string) => { const [live] = await db .select({ id: sessionsTable.id }) .from(sessionsTable) - .where( - and(eq(sessionsTable.id, sessionId), isNull(sessionsTable.revokedAt), gt(sessionsTable.expiresAt, getIsoDate())), - ) + .where(and(eq(sessionsTable.id, sessionId), isNull(sessionsTable.revokedAt), gt(sessionsTable.expiresAt, getIsoDate()))) .limit(1); return !!live; }; diff --git a/backend/src/modules/auth/tokens/token-policies.test.ts b/backend/src/modules/auth/tokens/token-policies.test.ts index dcd984be2..3dcafa83d 100644 --- a/backend/src/modules/auth/tokens/token-policies.test.ts +++ b/backend/src/modules/auth/tokens/token-policies.test.ts @@ -19,9 +19,7 @@ describe('token policies', () => { }); it('keeps an opened invitation usable through a magic-link sign-in to another account', () => { - expect(tokenPolicies.invitation.singleUseWindow.milliseconds()).toBeGreaterThan( - tokenPolicies.magic.ttl.milliseconds(), - ); + expect(tokenPolicies.invitation.singleUseWindow.milliseconds()).toBeGreaterThan(tokenPolicies.magic.ttl.milliseconds()); }); it("sets each token type's cookie with its policy's SameSite", async () => { diff --git a/backend/src/modules/auth/tokens/token-policies.ts b/backend/src/modules/auth/tokens/token-policies.ts index df2b5a220..b1a6505b2 100644 --- a/backend/src/modules/auth/tokens/token-policies.ts +++ b/backend/src/modules/auth/tokens/token-policies.ts @@ -12,14 +12,7 @@ type SameSite = 'lax' | 'strict'; * - `session`: those bound to its session (a step-up link: one per session at a time). * - `none`: nothing; each one stands on its own (every sign-in holds its own second-factor challenge). */ -export const tokenReplacements = [ - 'address-or-account', - 'identity', - 'invitation', - 'account', - 'session', - 'none', -] as const; +export const tokenReplacements = ['address-or-account', 'identity', 'invitation', 'account', 'session', 'none'] as const; export type TokenReplacement = (typeof tokenReplacements)[number]; /** @@ -106,6 +99,12 @@ type TokenTypeCarriedBy = { /** Token types carried by an emailed link. */ export type LinkTokenType = TokenTypeCarriedBy<'link'>; +/** + * The emailed URL that opens a link token. Keep the `/invoke-token/` path: `middlewares/app.ts` answers it with + * `Referrer-Policy: no-referrer`, so the secret in it never leaks through a Referer. + */ +export const tokenLinkUrl = (type: LinkTokenType, rawToken: string) => `${appConfig.backendAuthUrl}/invoke-token/${type}/${rawToken}`; + /** Token types carried only by a cookie. */ export type CookieTokenType = TokenTypeCarriedBy<'cookie'>; diff --git a/backend/src/modules/auth/tokens/tokens-queries.ts b/backend/src/modules/auth/tokens/tokens-queries.ts index 1997b142e..248c474d4 100644 --- a/backend/src/modules/auth/tokens/tokens-queries.ts +++ b/backend/src/modules/auth/tokens/tokens-queries.ts @@ -30,8 +30,7 @@ export const findInvitationToken = async ( { forUpdate = false }: FindInvitationTokenOpts = {}, ): Promise => { const { db } = ctx.var; - const byKey = - 'id' in key ? eq(tokensTable.id, key.id) : eq(tokensTable.inactiveMembershipId, key.inactiveMembershipId); + const byKey = 'id' in key ? eq(tokensTable.id, key.id) : eq(tokensTable.inactiveMembershipId, key.inactiveMembershipId); const query = db .select(tokenColumns) .from(tokensTable) @@ -52,9 +51,7 @@ export const hasLiveInvitationToken = async (ctx: DbContext, { email }: HasLiveI const [liveToken] = await db .select({ id: tokensTable.id }) .from(tokensTable) - .where( - and(eq(tokensTable.email, email), eq(tokensTable.type, 'invitation'), gt(tokensTable.expiresAt, getIsoDate())), - ) + .where(and(eq(tokensTable.email, email), eq(tokensTable.type, 'invitation'), gt(tokensTable.expiresAt, getIsoDate()))) .limit(1); return !!liveToken; }; diff --git a/backend/src/modules/auth/totps/helpers/totp-budget.test.ts b/backend/src/modules/auth/totps/helpers/totp-budget.test.ts index 73fb4fc76..68fc920ea 100644 --- a/backend/src/modules/auth/totps/helpers/totp-budget.test.ts +++ b/backend/src/modules/auth/totps/helpers/totp-budget.test.ts @@ -12,7 +12,7 @@ vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => ); vi.mock('#/modules/auth/general/helpers/send-account-security-email', () => ({ sendAccountSecurityEmail: vi.fn() })); -const { verifyTotp } = await import('#/modules/auth/totps/helpers/totps'); +const { verifyTotp } = await import('#/modules/auth/totps/operations/verify-totp'); const { sendAccountSecurityEmail } = await import('#/modules/auth/general/helpers/send-account-security-email'); const { appErrorHandler } = await import('#/lib/error'); @@ -25,8 +25,7 @@ function accountChecks() { await verifyTotp(ctx, { user, code: await ctx.req.text(), pendingSecret: testTotpSecret }); return ctx.body(null, 204); }); - const check = async (code: string) => - (await app.request('http://localhost/check', { method: 'POST', body: code })).status; + const check = async (code: string) => (await app.request('http://localhost/check', { method: 'POST', body: code })).status; return { user, check }; } diff --git a/backend/src/modules/auth/totps/helpers/totp-budget.ts b/backend/src/modules/auth/totps/helpers/totp-budget.ts index e9448b11c..dd5d7134f 100644 --- a/backend/src/modules/auth/totps/helpers/totp-budget.ts +++ b/backend/src/modules/auth/totps/helpers/totp-budget.ts @@ -39,9 +39,7 @@ export const takeTotpAttempt = (ctx: Context, userId: string) => reserveTie */ export const settleTotpAttempt = async (attempt: Reservation, user: TotpUser, verified: boolean) => { for (const { limits } of await settleTiers(attempt, verified ? 'success' : 'fail')) { - sendAccountSecurityEmail(user, 'totp-lockout', { - attempts: limits.points, - duration: Math.round(limits.blockDuration / 60), - }); + const duration = Math.round(limits.blockDuration / 60); + sendAccountSecurityEmail(user, 'totp-lockout', { attempts: limits.points, duration }); } }; diff --git a/backend/src/modules/auth/totps/helpers/totp-core.ts b/backend/src/modules/auth/totps/helpers/totp-core.ts index 1f2a46c13..a05e4d7b3 100644 --- a/backend/src/modules/auth/totps/helpers/totp-core.ts +++ b/backend/src/modules/auth/totps/helpers/totp-core.ts @@ -51,13 +51,7 @@ export const matchTOTPStep = ( }; /** Builds an `otpauth://` provisioning URI for authenticator apps (QR code or deep link). */ -export const createTOTPKeyURI = ( - issuer: string, - accountName: string, - key: Uint8Array, - periodInSeconds: number, - digits: number, -): string => { +export const createTOTPKeyURI = (issuer: string, accountName: string, key: Uint8Array, periodInSeconds: number, digits: number): string => { const params = new URLSearchParams({ secret: encodeBase32UpperCaseNoPadding(key), issuer, diff --git a/backend/src/modules/auth/totps/helpers/totps.ts b/backend/src/modules/auth/totps/operations/verify-totp.ts similarity index 72% rename from backend/src/modules/auth/totps/helpers/totps.ts rename to backend/src/modules/auth/totps/operations/verify-totp.ts index 29f141a46..da4126d9b 100644 --- a/backend/src/modules/auth/totps/helpers/totps.ts +++ b/backend/src/modules/auth/totps/operations/verify-totp.ts @@ -1,42 +1,27 @@ import { decodeBase32 } from '@oslojs/encoding'; -import { and, eq, isNull, lt, or } from 'drizzle-orm'; import type { Context } from 'hono'; import { appConfig } from 'shared'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { baseDb as db } from '#/db/db'; +import { baseDb } from '#/db/db'; import { settleTotpAttempt, type TotpUser, takeTotpAttempt } from '#/modules/auth/totps/helpers/totp-budget'; import { matchTOTPStep } from '#/modules/auth/totps/helpers/totp-core'; import { decryptTotpSecret } from '#/modules/auth/totps/helpers/totp-secret-encryption'; -import { totpsTable } from '#/modules/auth/totps/totps-db'; +import { findTotp, updateTotpLastUsedStep } from '#/modules/auth/totps/totps-queries'; const { intervalInSeconds, digits, gracePeriodInSeconds } = appConfig.totp; +/** Factor checks read and write on the base pool, whatever the route's context holds. */ +const dbCtx = { var: { db: baseDb } }; + /** The account's stored Base32 secret. */ const findStoredSecret = async (userId: string) => { - const [totp] = await db - .select({ secret: totpsTable.secret }) - .from(totpsTable) - .where(eq(totpsTable.userId, userId)) - .limit(1); + const totp = await findTotp(dbCtx, { userId }); if (!totp) throw new AppError(404, 'not_found', 'warn'); return decryptTotpSecret(totp.secret); }; -/** - * Moves the account's last used step forward to `step`: false when that step or a later one was used already. Of two - * checks of one code, exactly one moves it. - */ -const spendStep = async (userId: string, step: number) => { - const [spent] = await db - .update(totpsTable) - .set({ lastUsedStep: step }) - .where(and(eq(totpsTable.userId, userId), or(isNull(totpsTable.lastUsedStep), lt(totpsTable.lastUsedStep, step)))) - .returning({ id: totpsTable.id }); - return !!spent; -}; - interface VerifyTotpOpts { user: TotpUser; code: string; @@ -57,7 +42,7 @@ export const verifyTotp = async (ctx: Context, { user, code, pendingSecret const attempt = await takeTotpAttempt(ctx, user.id); const step = matchTOTPStep(decodeBase32(secret), intervalInSeconds, digits, code, gracePeriodInSeconds); - const spent = step !== null && (pendingSecret !== undefined || (await spendStep(user.id, step))); + const spent = step !== null && (pendingSecret !== undefined || !!(await updateTotpLastUsedStep(dbCtx, { userId: user.id, step }))); await settleTotpAttempt(attempt, user, spent); if (step === null) throw new AppError(401, 'invalid_token', 'warn'); diff --git a/backend/src/modules/auth/totps/totps-handlers.ts b/backend/src/modules/auth/totps/totps-handlers.ts index c480023f8..5f63de6d3 100644 --- a/backend/src/modules/auth/totps/totps-handlers.ts +++ b/backend/src/modules/auth/totps/totps-handlers.ts @@ -1,17 +1,15 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { encodeBase32UpperCase } from '@oslojs/encoding'; -import { eq } from 'drizzle-orm'; import { appConfig } from 'shared'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { baseDb } from '#/db/db'; -import { findExistingTotp, insertTotp } from '#/modules/auth/auth-queries'; import { deleteAuthCookie, getAuthCookie, setAuthCookie } from '#/modules/auth/general/helpers/cookie'; -import { completeMfaChallenge, mfaFactorRules } from '#/modules/auth/general/helpers/mfa'; import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { mfaFactorRules } from '#/modules/auth/mfa/operations/factor-rules'; +import { completeMfaChallenge } from '#/modules/auth/mfa/operations/mfa-challenge'; import { createTOTPKeyURI } from '#/modules/auth/totps/helpers/totp-core'; -import { verifyTotp } from '#/modules/auth/totps/helpers/totps'; -import { totpsTable } from '#/modules/auth/totps/totps-db'; +import { verifyTotp } from '#/modules/auth/totps/operations/verify-totp'; +import { deleteTotp, findTotp, insertTotp } from '#/modules/auth/totps/totps-queries'; import { authTotpsRoutes } from '#/modules/auth/totps/totps-routes'; import { defaultHook } from '#/utils/default-hook'; import { TimeSpan } from '#/utils/time-span'; @@ -21,8 +19,7 @@ const app = new OpenAPIHono({ defaultHook }); app.openapi(authTotpsRoutes.generateTotpKey, async (ctx) => { const user = ctx.var.user; - const existingTotp = await findExistingTotp({ var: { ...ctx.var, db: baseDb } }, { userId: user.id }); - if (existingTotp) throw new AppError(409, 'resource_already_exists', 'warn'); + if (await findTotp(ctx, { userId: user.id })) throw new AppError(409, 'resource_already_exists', 'warn'); // Generate a 20-byte random secret and encode it as Base32 const secretBytes = crypto.getRandomValues(new Uint8Array(20)); @@ -31,13 +28,7 @@ app.openapi(authTotpsRoutes.generateTotpKey, async (ctx) => { await setAuthCookie(ctx, 'totp-challenge', manualKey, new TimeSpan(5, 'm')); - const totpUri = createTOTPKeyURI( - appConfig.slug, - user.email, - secretBytes, - appConfig.totp.intervalInSeconds, - appConfig.totp.digits, - ); + const totpUri = createTOTPKeyURI(appConfig.slug, user.email, secretBytes, appConfig.totp.intervalInSeconds, appConfig.totp.digits); return ctx.json({ totpUri, manualKey }, 200); }); @@ -47,8 +38,7 @@ app.openapi(authTotpsRoutes.createTotp, async (ctx) => { const { code } = ctx.req.valid('json'); - const existingTotp = await findExistingTotp(ctx, { userId: user.id }); - if (existingTotp) throw new AppError(409, 'resource_already_exists', 'warn'); + if (await findTotp(ctx, { userId: user.id })) throw new AppError(409, 'resource_already_exists', 'warn'); const pendingSecret = await getAuthCookie(ctx, 'totp-challenge'); if (!pendingSecret) throw new AppError(400, 'invalid_credentials', 'warn'); @@ -70,7 +60,7 @@ app.openapi(authTotpsRoutes.deleteTotp, async (ctx) => { // The delete rolls back when MFA is on: it keeps the authenticator app until MFA is turned off. await mfaFactorRules.locked(user.id, async (tx) => { - await tx.delete(totpsTable).where(eq(totpsTable.userId, user.id)); + await deleteTotp({ var: { db: tx } }, { userId: user.id }); await mfaFactorRules.assertKeepsFactors(tx, user.id); }); diff --git a/backend/src/modules/auth/totps/totps-queries.ts b/backend/src/modules/auth/totps/totps-queries.ts new file mode 100644 index 000000000..b4f90df1a --- /dev/null +++ b/backend/src/modules/auth/totps/totps-queries.ts @@ -0,0 +1,51 @@ +import { and, eq, isNull, lt, or } from 'drizzle-orm'; +import type { DbContext } from '#/core/context'; +import { encryptTotpSecret } from '#/modules/auth/totps/helpers/totp-secret-encryption'; +import { totpsTable } from '#/modules/auth/totps/totps-db'; + +interface FindTotpOpts { + userId: string; +} + +/** The account's authenticator app, with its secret still encrypted; undefined without one. */ +export const findTotp = async (ctx: DbContext, { userId }: FindTotpOpts) => { + const [totp] = await ctx.var.db.select({ secret: totpsTable.secret }).from(totpsTable).where(eq(totpsTable.userId, userId)).limit(1); + return totp; +}; + +interface InsertTotpOpts { + userId: string; + secret: string; + /** The time step of the code that confirmed the setup. */ + lastUsedStep: number; +} + +export const insertTotp = async (ctx: DbContext, { userId, secret, lastUsedStep }: InsertTotpOpts) => { + return ctx.var.db.insert(totpsTable).values({ userId, secret: encryptTotpSecret(secret), lastUsedStep }); +}; + +interface UpdateTotpLastUsedStepOpts { + userId: string; + step: number; +} + +/** + * Moves the account's last used step forward to `step`; undefined when that step or a later one was used already. Of + * two checks of one code, exactly one moves it. + */ +export const updateTotpLastUsedStep = async (ctx: DbContext, { userId, step }: UpdateTotpLastUsedStepOpts) => { + const [spent] = await ctx.var.db + .update(totpsTable) + .set({ lastUsedStep: step }) + .where(and(eq(totpsTable.userId, userId), or(isNull(totpsTable.lastUsedStep), lt(totpsTable.lastUsedStep, step)))) + .returning({ id: totpsTable.id }); + return spent; +}; + +interface DeleteTotpOpts { + userId: string; +} + +export const deleteTotp = async (ctx: DbContext, { userId }: DeleteTotpOpts) => { + await ctx.var.db.delete(totpsTable).where(eq(totpsTable.userId, userId)); +}; diff --git a/backend/src/modules/auth/totps/totps-routes.ts b/backend/src/modules/auth/totps/totps-routes.ts index 5d0fe744c..b74a445bb 100644 --- a/backend/src/modules/auth/totps/totps-routes.ts +++ b/backend/src/modules/auth/totps/totps-routes.ts @@ -1,99 +1,55 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { publicGuard, stepUpGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter, totpVerificationLimiter } from '#/middlewares/rate-limiter/limiters'; import { mockTotpKeyResponse } from '#/modules/auth/auth-mocks'; import { totpCreateBodySchema } from '#/modules/auth/totps/totps-schema'; -import { cookieSchema, errorResponseRefs } from '#/schemas'; +import { cookieSchema } from '#/schemas'; -const authTotpsRoutes = { - generateTotpKey: createXRoute({ - operationId: 'generateTotpKey', - 'x-strategy': 'totp', +const authTotpsRoutes = createXRoutes(['auth', 'cella'], { + generateTotpKey: xRoute({ method: 'post', path: '/totp/generate-key', + xEnabledBy: { strategy: 'totp' }, xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['auth', 'cella'], summary: 'Generate TOTP key', description: 'Generates a new TOTP key for current user and returns a provisioning URI and Base32 manual key.', - responses: { - 200: { - description: 'Challenge created', - content: { - 'application/json': { - schema: z.object({ totpUri: z.string(), manualKey: z.string() }), - example: mockTotpKeyResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Challenge created', z.object({ totpUri: z.string(), manualKey: z.string() }), mockTotpKeyResponse()) }, }), - createTotp: createXRoute({ - operationId: 'createTotp', - 'x-strategy': 'totp', + createTotp: xRoute({ method: 'post', path: '/totp', + xEnabledBy: { strategy: 'totp' }, xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['auth', 'cella'], summary: 'Set TOTP', description: 'Confirms TOTP setup by verifying a code from the authenticator app for the first time. On success, TOTP is registered for current user.', - request: { - body: { - required: true, - content: { 'application/json': { schema: totpCreateBodySchema.pick({ code: true }) } }, - }, - }, + request: { body: jsonBody(totpCreateBodySchema.pick({ code: true })) }, - responses: { - 201: { - description: 'TOTP created', - }, - ...errorResponseRefs, - }, + responses: { 201: { description: 'TOTP created' } }, }), - deleteTotp: createXRoute({ - operationId: 'deleteTotp', - 'x-strategy': null, + deleteTotp: xRoute({ method: 'delete', path: '/totp', xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['auth', 'cella'], summary: 'Delete TOTP', description: 'Delete TOTP credential for current user.', - responses: { - 204: { description: 'TOTP deleted' }, - ...errorResponseRefs, - }, + responses: { 204: { description: 'TOTP deleted' } }, }), - signInWithTotp: createXRoute({ - operationId: 'signInWithTotp', - 'x-strategy': 'totp', + signInWithTotp: xRoute({ method: 'post', path: '/totp-verification', + xEnabledBy: { strategy: 'totp' }, xGuard: [publicGuard], xRateLimiter: [totpVerificationLimiter], - tags: ['auth', 'cella'], summary: 'Verify TOTP', description: 'Validates the TOTP code and completes TOTP based authentication.', - request: { - body: { - required: true, - content: { 'application/json': { schema: totpCreateBodySchema } }, - }, - }, - responses: { - 204: { - description: 'TOTP verified', - headers: z.object({ 'Set-Cookie': cookieSchema }), - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(totpCreateBodySchema) }, + responses: { 204: { description: 'TOTP verified', headers: z.object({ 'Set-Cookie': cookieSchema }) } }, }), -}; +}); export { authTotpsRoutes }; diff --git a/backend/src/modules/auth/totps/totps-schema.ts b/backend/src/modules/auth/totps/totps-schema.ts index d3db2845f..4bb31a0b5 100644 --- a/backend/src/modules/auth/totps/totps-schema.ts +++ b/backend/src/modules/auth/totps/totps-schema.ts @@ -2,7 +2,5 @@ import { z } from '@hono/zod-openapi'; import { appConfig } from 'shared'; export const totpCreateBodySchema = z.object({ - code: z - .string() - .regex(new RegExp(`^\\d{${appConfig.totp.digits}}$`), `Code must be exactly ${appConfig.totp.digits} digits`), + code: z.string().regex(new RegExp(`^\\d{${appConfig.totp.digits}}$`), `Code must be exactly ${appConfig.totp.digits} digits`), }); diff --git a/backend/src/modules/domains/domains-queries.ts b/backend/src/modules/domains/domains-queries.ts index 841bd5be0..4ca8d2b52 100644 --- a/backend/src/modules/domains/domains-queries.ts +++ b/backend/src/modules/domains/domains-queries.ts @@ -10,11 +10,7 @@ export const findDomainsByTenant = async (ctx: UserContext) => { export const findTenantExists = async (ctx: UserContext) => { const { db, tenantId } = ctx.var; - const [tenant] = await db - .select({ id: tenantsTable.id }) - .from(tenantsTable) - .where(eq(tenantsTable.id, tenantId)) - .limit(1); + const [tenant] = await db.select({ id: tenantsTable.id }).from(tenantsTable).where(eq(tenantsTable.id, tenantId)).limit(1); return tenant; }; @@ -67,8 +63,7 @@ export const deleteDomain = async (ctx: UserContext, { id }: DeleteDomainOpts) = interface UpdateDomainOpts { id: string; - values: Pick & - Partial>; + values: Pick & Partial>; } export const updateDomain = async (ctx: UserContext, { id, values }: UpdateDomainOpts) => { diff --git a/backend/src/modules/domains/domains-routes.ts b/backend/src/modules/domains/domains-routes.ts index f5b94c8f8..1875fbd45 100644 --- a/backend/src/modules/domains/domains-routes.ts +++ b/backend/src/modules/domains/domains-routes.ts @@ -1,135 +1,61 @@ import { appConfig } from 'shared'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { sysAdminGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; -import { errorResponseRefs, tenantOnlyParamSchema } from '#/schemas'; -import { - createDomainBodySchema, - domainParamSchema, - domainSchema, - domainWithTokenSchema, - verifyDomainResponseSchema, -} from './domains-schema'; +import { tenantOnlyParamSchema } from '#/schemas'; +import { createDomainBodySchema, domainParamSchema, domainSchema, domainWithTokenSchema, verifyDomainResponseSchema } from './domains-schema'; -export const domainRoutes = { - getDomains: createXRoute({ - operationId: 'getDomains', +export const domainRoutes = createXRoutes(['tenants', 'cella'], { + getDomains: xRoute({ method: 'get', path: '/', xGuard: [userGuard, sysAdminGuard, tenantGuard], - tags: ['tenants', 'cella'], summary: 'List domains for a tenant', - description: - 'Returns all domains belonging to a tenant, including verification tokens. System admin access required.', + description: 'Returns all domains belonging to a tenant, including verification tokens. System admin access required.', request: { params: tenantOnlyParamSchema }, - responses: { - 200: { - description: 'List of domains', - content: { - 'application/json': { - schema: domainWithTokenSchema.array(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('List of domains', domainWithTokenSchema.array()) }, }), - createDomain: createXRoute({ - operationId: 'createDomain', + createDomain: xRoute({ method: 'post', path: '/', xGuard: [userGuard, sysAdminGuard, tenantGuard], xRateLimiter: [singlePointsLimiter], - tags: ['tenants', 'cella'], summary: 'Add a domain to a tenant', description: 'Adds a new domain to a tenant. The domain starts unverified. System admin access required.', - request: { - params: tenantOnlyParamSchema, - body: { - required: true, - content: { 'application/json': { schema: createDomainBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Created domain', - content: { - 'application/json': { - schema: domainSchema, - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOnlyParamSchema, body: jsonBody(createDomainBodySchema) }, + responses: { 200: json('Created domain', domainSchema) }, }), - deleteDomain: createXRoute({ - operationId: 'deleteDomain', + deleteDomain: xRoute({ method: 'delete', path: '/{id}', xGuard: [userGuard, sysAdminGuard, tenantGuard], xRateLimiter: [singlePointsLimiter], - tags: ['tenants', 'cella'], summary: 'Remove a domain', description: 'Removes a domain from a tenant. System admin access required.', request: { params: domainParamSchema }, - responses: { - 200: { - description: 'Domain removed', - content: { - 'application/json': { - schema: domainSchema, - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Domain removed', domainSchema) }, }), - getDomain: createXRoute({ - operationId: 'getDomain', + getDomain: xRoute({ method: 'get', path: '/{id}', xGuard: [userGuard, sysAdminGuard, tenantGuard], - tags: ['tenants', 'cella'], summary: 'Get domain with verification token', - description: - 'Returns a single domain including its verification token for DNS TXT setup. System admin access required.', + description: 'Returns a single domain including its verification token for DNS TXT setup. System admin access required.', request: { params: domainParamSchema }, - responses: { - 200: { - description: 'Domain with verification token', - content: { - 'application/json': { - schema: domainWithTokenSchema, - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Domain with verification token', domainWithTokenSchema) }, }), - verifyDomain: createXRoute({ - operationId: 'verifyDomain', + verifyDomain: xRoute({ method: 'post', path: '/{id}/verify', xGuard: [userGuard, sysAdminGuard, tenantGuard], xRateLimiter: [singlePointsLimiter], - tags: ['tenants', 'cella'], summary: 'Verify domain ownership via DNS', description: `Looks up DNS TXT records for the domain to verify ownership. Checks for a _${appConfig.slug}-verification. TXT record matching the verification token.`, request: { params: domainParamSchema }, - responses: { - 200: { - description: 'Verification result', - content: { - 'application/json': { - schema: verifyDomainResponseSchema, - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Verification result', verifyDomainResponseSchema) }, }), -}; +}); diff --git a/backend/src/modules/domains/domains-schema.ts b/backend/src/modules/domains/domains-schema.ts index 9fcdf5691..a28667697 100644 --- a/backend/src/modules/domains/domains-schema.ts +++ b/backend/src/modules/domains/domains-schema.ts @@ -3,28 +3,17 @@ import { createInsertSchema, createSelectSchema } from '#/db/utils/drizzle-schem import { domainsTable } from '#/modules/domains/domains-db'; import { entityIdParamSchema, tenantOnlyParamSchema, validDomainSchema } from '#/schemas'; -export const domainSchema = z.object({ - ...createSelectSchema(domainsTable).omit({ verificationToken: true }).shape, -}); +export const domainSchema = z.object({ ...createSelectSchema(domainsTable).omit({ verificationToken: true }).shape }); /** Includes verificationToken: the DNS TXT record value an admin must configure. */ -export const domainWithTokenSchema = z.object({ - ...createSelectSchema(domainsTable).shape, -}); +export const domainWithTokenSchema = z.object({ ...createSelectSchema(domainsTable).shape }); export const verifyDomainResponseSchema = z.object({ success: z.boolean(), domain: domainWithTokenSchema, - diagnostics: z - .object({ - recordsFound: z.array(z.string()), - expectedToken: z.string(), - }) - .optional(), + diagnostics: z.object({ recordsFound: z.array(z.string()), expectedToken: z.string() }).optional(), }); -export const createDomainBodySchema = createInsertSchema(domainsTable, { - domain: validDomainSchema, -}).pick({ domain: true }); +export const createDomainBodySchema = createInsertSchema(domainsTable, { domain: validDomainSchema }).pick({ domain: true }); export const domainParamSchema = tenantOnlyParamSchema.merge(entityIdParamSchema); diff --git a/backend/src/modules/domains/operations/verify-domain.ts b/backend/src/modules/domains/operations/verify-domain.ts index c8a62a4ef..4f6735244 100644 --- a/backend/src/modules/domains/operations/verify-domain.ts +++ b/backend/src/modules/domains/operations/verify-domain.ts @@ -39,11 +39,7 @@ export async function verifyDomainOp(ctx: UserContext, id: string) { const values = { lastCheckedAt: now, ...(verified ? { verified: true, verifiedAt: now } : {}) }; const updated = await updateDomain(ctx, { id, values }); - log.info(`Domain verification ${verified ? 'succeeded' : 'failed'}`, { - tenantId, - domain: domain.domain, - verified, - }); + log.info(`Domain verification ${verified ? 'succeeded' : 'failed'}`, { tenantId, domain: domain.domain, verified }); const diagnostics = !verified ? { recordsFound, expectedToken: domain.verificationToken } : undefined; return { success: verified, domain: updated, ...(diagnostics && { diagnostics }) }; diff --git a/backend/src/modules/entities/entities-handlers.ts b/backend/src/modules/entities/entities-handlers.ts index aa971e01b..4b8957cfd 100644 --- a/backend/src/modules/entities/entities-handlers.ts +++ b/backend/src/modules/entities/entities-handlers.ts @@ -62,10 +62,7 @@ app.openapi(entityRoutes.appStream, async (ctx) => { // membership in a new org reaches the user here and the frontend reconnects to re-register. streamSubscriberManager.register(subscriber, [...orgChannels.slice(1), `user:${user.id}`]); ensureAppStreamSessionSweep(); - log.debug('App stream subscriber registered', { - subscriberId: subscriber.id, - orgCount: organizationIds.size, - }); + log.debug('App stream subscriber registered', { subscriberId: subscriber.id, orgCount: organizationIds.size }); stream.onAbort(() => { streamSubscriberManager.unregister(subscriber.id); diff --git a/backend/src/modules/entities/entities-listeners.ts b/backend/src/modules/entities/entities-listeners.ts index 88542a4c7..b56ce9183 100644 --- a/backend/src/modules/entities/entities-listeners.ts +++ b/backend/src/modules/entities/entities-listeners.ts @@ -1,11 +1,7 @@ import { appConfig } from 'shared'; import { activityBus, getEventData } from '#/lib/activity-bus'; import { authEvents } from '#/modules/auth/auth-events'; -import { - type AppStreamSubscriber, - dispatchMoveOuts, - dispatchToAppStream, -} from '#/modules/entities/helpers/dispatch-to-stream'; +import { type AppStreamSubscriber, dispatchMoveOuts, dispatchToAppStream } from '#/modules/entities/helpers/dispatch-to-stream'; import { closeAppStreams, streamErrorForEnding } from '#/modules/entities/helpers/session-streams'; import { toMembershipBase } from '#/modules/memberships/helpers/select'; import { log } from '#/utils/logger'; @@ -48,8 +44,7 @@ for (const action of ['created', 'updated', 'deleted'] as const) { const subscribers = streamSubscriberManager.getByChannel(`user:${membership.userId}`); for (const subscriber of subscribers) { const remaining = subscriber.memberships.filter((existing) => existing.id !== membership.id); - subscriber.memberships = - action === 'deleted' ? remaining : [...remaining, toMembershipBase(membership as Record)]; + subscriber.memberships = action === 'deleted' ? remaining : [...remaining, toMembershipBase(membership as Record)]; } } diff --git a/backend/src/modules/entities/entities-mocks.ts b/backend/src/modules/entities/entities-mocks.ts index e7c65789f..7a231184a 100644 --- a/backend/src/modules/entities/entities-mocks.ts +++ b/backend/src/modules/entities/entities-mocks.ts @@ -3,9 +3,7 @@ import { mockNanoid, withFakerSeed } from '#/mocks'; export const mockStreamResponse = (key = 'stream:default') => withFakerSeed(key, () => ({ changes: { - 'org-example-id': { - signals: { membership: 1 }, - }, + 'org-example-id': { signals: { membership: 1 } }, }, cursor: mockNanoid(), })); diff --git a/backend/src/modules/entities/entities-queries.ts b/backend/src/modules/entities/entities-queries.ts index 45260a8c9..c7e840bdd 100644 --- a/backend/src/modules/entities/entities-queries.ts +++ b/backend/src/modules/entities/entities-queries.ts @@ -51,9 +51,7 @@ export const getChannelCountsSelect = (entityType: ChannelEntityType) => { const entityJsonPairs = children.map((entity) => `'${entity}', ${jsonbIntRaw(col, `e:c:${entity}`)}`).join(', '); // Home-only twin of `entities` from the `e:c:h:` keys: rows homed directly at the channel, no descendant rollup. - const entitySelfJsonPairs = children - .map((entity) => `'${entity}', ${jsonbIntRaw(col, `e:c:h:${entity}`)}`) - .join(', '); + const entitySelfJsonPairs = children.map((entity) => `'${entity}', ${jsonbIntRaw(col, `e:c:h:${entity}`)}`).join(', '); // Product descendants only: { attachment: { created: epochMs | null, updated: epochMs | null }, ... } const activityJsonPairs = productChildren @@ -97,8 +95,7 @@ export const findProductViewCount = async (ctx: DbContext, { productId }: FindPr }; /** Pair with {@link productViewCountJoin}. */ -export const productViewCountSelect = () => - sql`coalesce(${productCountersTable.viewCount}, 0)`.as('view_count'); +export const productViewCountSelect = () => sql`coalesce(${productCountersTable.viewCount}, 0)`.as('view_count'); export const productViewCountJoin = (productIdColumn: AnyColumn) => eq(productCountersTable.productId, productIdColumn); @@ -112,19 +109,15 @@ export const getChannelCounts = async (ctx: DbContext, { entityType, entityId }: const { db } = ctx.var; const { countsSelect } = getChannelCountsSelect(entityType); - const [counts] = await db - .select(countsSelect) - .from(channelCountersTable) - .where(eq(channelCountersTable.channelKey, entityId)); + const [counts] = await db.select(countsSelect).from(channelCountersTable).where(eq(channelCountersTable.channelKey, entityId)); // No row yet: activity stamps stay null until a first post. if (!counts) { const descendants = hierarchy.getOrderedDescendants(entityType); const zeroMembership = Object.fromEntries([...roles.all.map((r) => [r, 0]), ['pending', 0], ['total', 0]]); const zeroEntities = Object.fromEntries(descendants.map((e) => [e, 0])); - const nullActivity = Object.fromEntries( - descendants.filter((e) => isProduct(e)).map((e) => [e, { created: null, updated: null }]), - ); + const productDescendants = descendants.filter((e) => isProduct(e)); + const nullActivity = Object.fromEntries(productDescendants.map((e) => [e, { created: null, updated: null }])); return { membership: zeroMembership as z.infer, entities: zeroEntities as Record, @@ -145,19 +138,13 @@ interface GetOrganizationEntityCountOpts { * Reads `e:c:{entityType}` from the org's counter row. Draft-lifecycle tables fall back to a * direct COUNT including drafts, since that counter tracks published rows only. */ -export const getOrganizationEntityCount = async ( - ctx: DbContext, - { organizationId, entityType }: GetOrganizationEntityCountOpts, -) => { +export const getOrganizationEntityCount = async (ctx: DbContext, { organizationId, entityType }: GetOrganizationEntityCountOpts) => { const { db } = ctx.var; const table = isProduct(entityType) ? getEntityTable(entityType) : null; if (table && hasPublishedAt(table)) { const deletedFilter = hasDeletedAt(table) ? sql.raw(' AND deleted_at IS NULL') : sql.raw(''); - const [row] = await db - .select({ count: count() }) - .from(table) - .where(sql`organization_id = ${organizationId}${deletedFilter}`); + const [row] = await db.select({ count: count() }).from(table).where(sql`organization_id = ${organizationId}${deletedFilter}`); return row?.count ?? 0; } @@ -176,10 +163,7 @@ interface FindLatestUserActivityIdOpts { entityTypes: SharedEntityType[]; } -export const findLatestUserActivityId = async ( - ctx: DbContext, - { organizationIds, entityTypes }: FindLatestUserActivityIdOpts, -) => { +export const findLatestUserActivityId = async (ctx: DbContext, { organizationIds, entityTypes }: FindLatestUserActivityIdOpts) => { const { db } = ctx.var; const result = await db .select({ id: activitiesTable.id }) @@ -252,8 +236,7 @@ export async function resolveEntities( } /** Drafts are outside the sync engine, so their seq bumps must not yield ids to sync back. */ -const publishedSqlFilter = (table: ResolvableTable) => - hasPublishedAt(table) ? sql.raw(' AND published_at IS NOT NULL') : sql.raw(''); +const publishedSqlFilter = (table: ResolvableTable) => (hasPublishedAt(table) ? sql.raw(' AND published_at IS NOT NULL') : sql.raw('')); interface FindChangedEntityIdsOpts { entityType: EntityType; @@ -261,10 +244,7 @@ interface FindChangedEntityIdsOpts { afterSeq: number; } -export const findChangedEntityIds = async ( - ctx: DbContext, - { entityType, organizationId, afterSeq }: FindChangedEntityIdsOpts, -) => { +export const findChangedEntityIds = async (ctx: DbContext, { entityType, organizationId, afterSeq }: FindChangedEntityIdsOpts) => { const { db } = ctx.var; const table = getEntityTable(entityType); @@ -277,10 +257,7 @@ export const findChangedEntityIds = async ( }; /** Split into live updates and soft-delete tombstones. */ -export const findChangedEntityDeltaIds = async ( - ctx: DbContext, - { entityType, organizationId, afterSeq }: FindChangedEntityIdsOpts, -) => { +export const findChangedEntityDeltaIds = async (ctx: DbContext, { entityType, organizationId, afterSeq }: FindChangedEntityIdsOpts) => { const { db } = ctx.var; const table = getEntityTable(entityType); const deletedAtSelect = hasDeletedAt(table) ? sql.raw('deleted_at') : sql.raw('NULL'); diff --git a/backend/src/modules/entities/entities-routes.ts b/backend/src/modules/entities/entities-routes.ts index b50120b14..22e968ecd 100644 --- a/backend/src/modules/entities/entities-routes.ts +++ b/backend/src/modules/entities/entities-routes.ts @@ -1,87 +1,47 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { tenantGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter, streamConnectLimiter } from '#/middlewares/rate-limiter/limiters'; import { mockStreamResponse } from '#/modules/entities/entities-mocks'; import { checkSlugBodySchema } from '#/modules/entities/entities-schema'; -import { appCatchupResponseSchema, errorResponseRefs, streamCatchupBodySchema, tenantOnlyParamSchema } from '#/schemas'; +import { appCatchupResponseSchema, streamCatchupBodySchema, tenantOnlyParamSchema } from '#/schemas'; -const entityRoutes = { - checkSlug: createXRoute({ - operationId: 'checkSlug', +const entityRoutes = createXRoutes(['entities', 'cella'], { + checkSlug: xRoute({ method: 'post', path: '/{tenantId}/check-slug', xGuard: [userGuard, tenantGuard], xRateLimiter: [singlePointsLimiter], - tags: ['entities', 'cella'], summary: 'Check slug availability', description: `Checks whether a given slug is available within a tenant for the specified entity type. Primarily used to prevent slug collisions before creating or updating an entity.`, - request: { - params: tenantOnlyParamSchema, - body: { - required: true, - content: { 'application/json': { schema: checkSlugBodySchema } }, - }, - }, - responses: { - 204: { - description: 'Slug is available', - }, - ...errorResponseRefs, - }, + request: { params: tenantOnlyParamSchema, body: jsonBody(checkSlugBodySchema) }, + responses: { 204: { description: 'Slug is available' } }, }), - appStream: createXRoute({ + appStream: xRoute({ operationId: 'getAppStream', method: 'get', path: '/app/stream', xGuard: [userGuard], xRateLimiter: [streamConnectLimiter], - tags: ['entities', 'cella'], summary: 'App event SSE stream', - description: - 'SSE stream for membership and entity notifications affecting the current user. Sends lightweight notifications.', - responses: { - 200: { - description: 'SSE stream started', - content: { - 'text/event-stream': { schema: z.any() }, - }, - }, - ...errorResponseRefs, - }, + description: 'SSE stream for membership and entity notifications affecting the current user. Sends lightweight notifications.', + responses: { 200: { description: 'SSE stream started', content: { 'text/event-stream': { schema: z.any() } } } }, }), - appCatchup: createXRoute({ + appCatchup: xRoute({ operationId: 'postAppCatchup', method: 'post', path: '/app/stream', xGuard: [userGuard], xRateLimiter: [singlePointsLimiter], - tags: ['entities', 'cella'], summary: 'App event catchup', description: 'Fetch missed entity and membership changes since last sync. Send cursor and declared views (prefix sets + org-sequence cursors) in the body.', - request: { - body: { - required: true, - content: { 'application/json': { schema: streamCatchupBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Catchup summary', - content: { - 'application/json': { - schema: appCatchupResponseSchema, - example: mockStreamResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(streamCatchupBodySchema) }, + responses: { 200: json('Catchup summary', appCatchupResponseSchema, mockStreamResponse()) }, }), -}; +}); export { entityRoutes }; diff --git a/backend/src/modules/entities/entities-schema.ts b/backend/src/modules/entities/entities-schema.ts index e7d4f2e57..bde8af0d3 100644 --- a/backend/src/modules/entities/entities-schema.ts +++ b/backend/src/modules/entities/entities-schema.ts @@ -2,10 +2,7 @@ import { z } from '@hono/zod-openapi'; import { appConfig } from 'shared'; import { validSlugSchema } from '#/schemas'; -export const checkSlugBodySchema = z.object({ - slug: validSlugSchema, - entityType: z.enum(appConfig.channelEntityTypes), -}); +export const checkSlugBodySchema = z.object({ slug: validSlugSchema, entityType: z.enum(appConfig.channelEntityTypes) }); /** View count from product counters, present on product reads that join or fetch them. */ export const productViewCountSchema = z.number().int().min(0).optional(); diff --git a/backend/src/modules/entities/helpers/build-zero-counts.ts b/backend/src/modules/entities/helpers/build-zero-counts.ts index 65881b594..85656638d 100644 --- a/backend/src/modules/entities/helpers/build-zero-counts.ts +++ b/backend/src/modules/entities/helpers/build-zero-counts.ts @@ -10,11 +10,7 @@ export const buildZeroCounts = (entityType: ChannelEntityType, creatorRole = 'ad descendants.filter((descendant) => isProduct(descendant)), () => ({ created: null, updated: null }) as { created: number | null; updated: number | null }, ); - const membership = { - ...recordFromKeys(roles.all, (role) => (role === creatorRole ? 1 : 0)), - pending: 0, - total: 1, - }; + const membership = { ...recordFromKeys(roles.all, (role) => (role === creatorRole ? 1 : 0)), pending: 0, total: 1 }; return { membership, entities, entitiesSelf, activity }; }; diff --git a/backend/src/modules/entities/helpers/check-slug.ts b/backend/src/modules/entities/helpers/check-slug.ts index 33094264d..ebab6dee2 100644 --- a/backend/src/modules/entities/helpers/check-slug.ts +++ b/backend/src/modules/entities/helpers/check-slug.ts @@ -11,11 +11,7 @@ export const checkSlugAvailable = async (ctx: DbContext, slug: string, entityTyp /** Returns a Map of slug to availability; true means free. */ export const checkSlugsAvailable = async (ctx: DbContext, slugs: string[], entityType: EntityTypeWithSlug) => { - const results = await Promise.all( - slugs.map(async (slug) => ({ - slug, - available: await checkSlugAvailable(ctx, slug, entityType), - })), - ); + const checks = slugs.map(async (slug) => ({ slug, available: await checkSlugAvailable(ctx, slug, entityType) })); + const results = await Promise.all(checks); return new Map(results.map((r) => [r.slug, r.available])); }; diff --git a/backend/src/modules/entities/helpers/dispatch-eligibility.test.ts b/backend/src/modules/entities/helpers/dispatch-eligibility.test.ts index be7eb0ebf..409fdcc70 100644 --- a/backend/src/modules/entities/helpers/dispatch-eligibility.test.ts +++ b/backend/src/modules/entities/helpers/dispatch-eligibility.test.ts @@ -1,11 +1,6 @@ import { appConfig, type EntityRole } from 'shared'; import { describe, expect, it } from 'vitest'; -import { - canReceiveProductEvent, - rowReadDecisions, - rowScopedEvent, - type SubscriberAccess, -} from '#/modules/entities/helpers/dispatch-to-stream'; +import { canReceiveProductEvent, rowReadDecisions, rowScopedEvent, type SubscriberAccess } from '#/modules/entities/helpers/dispatch-to-stream'; import type { AppStreamProductEvent } from '#/modules/entities/stream/types'; import type { MembershipBaseModel } from '#/modules/memberships/helpers/select'; import { memberRole } from '../../../../tests/fixtures'; @@ -97,11 +92,7 @@ describe('dispatch batch eligibility: deterministic splits', () => { // Org members hold read:'own': the row must be authored by the reader to be readable. const event = attachmentEvent(ORGS[0], { rowData: attachmentRow('att-1', ORGS[0], { createdBy: 'user-1' }) }); - const clean: SubscriberAccess = { - userId: 'user-1', - isSystemAdmin: false, - memberships: [membership(ORGS[0], memberRole, 'user-1')], - }; + const clean: SubscriberAccess = { userId: 'user-1', isSystemAdmin: false, memberships: [membership(ORGS[0], memberRole, 'user-1')] }; // A granting membership plus a malformed one: the engine fail-closes just this access. const broken: SubscriberAccess = { userId: 'user-2', @@ -154,10 +145,7 @@ describe('dispatch batch eligibility: randomized parity sweep', () => { ); const event = attachmentEvent(eventOrg, { rowData: rows[0], - ...(rowCount > 1 && { - batchUntilSeq: rowCount, - batchRows: rows.map((rowData, i) => ({ seq: i + 1, rowData })), - }), + ...(rowCount > 1 && { batchUntilSeq: rowCount, batchRows: rows.map((rowData, i) => ({ seq: i + 1, rowData })) }), }); const subscribers: SubscriberAccess[] = Array.from({ length: 60 }, () => { @@ -166,17 +154,13 @@ describe('dispatch batch eligibility: randomized parity sweep', () => { return { userId, isSystemAdmin: random() < 0.05, - memberships: Array.from({ length: membershipCount }, () => - membership(pick(ORGS), pick(roles), userId, random() < 0.05), - ), + memberships: Array.from({ length: membershipCount }, () => membership(pick(ORGS), pick(roles), userId, random() < 0.05)), }; }); const batch = batchDecisions(subscribers, event); for (const [index, subscriber] of subscribers.entries()) { - expect(batch[index], `seed=0x${SEED.toString(16)} iteration=${iteration} subscriber=${index}`).toBe( - singleDecision(subscriber, event), - ); + expect(batch[index], `seed=0x${SEED.toString(16)} iteration=${iteration} subscriber=${index}`).toBe(singleDecision(subscriber, event)); } } }); diff --git a/backend/src/modules/entities/helpers/dispatch-to-stream.ts b/backend/src/modules/entities/helpers/dispatch-to-stream.ts index 715cdcf2d..d328c4711 100644 --- a/backend/src/modules/entities/helpers/dispatch-to-stream.ts +++ b/backend/src/modules/entities/helpers/dispatch-to-stream.ts @@ -39,10 +39,7 @@ const rowReadSubject = (event: AppStreamProductEvent): SubjectForPermission | nu row, }); } catch { - log.error('Malformed stream event: missing ancestor scope', { - entityType: event.entityType, - subjectId: event.subjectId, - }); + log.error('Malformed stream event: missing ancestor scope', { entityType: event.entityType, subjectId: event.subjectId }); return null; } }; @@ -59,15 +56,10 @@ export function rowReadDecisions(subscribers: readonly SubscriberAccess[], event memberships: subscriber.memberships, scopes: null, })); - const results = checkAccessFanout(accesses, 'read', subject, { - onInvalidMembership: 'deny', - }); + const results = checkAccessFanout(accesses, 'read', subject, { onInvalidMembership: 'deny' }); return results.map((result) => result.allowed); } catch { - log.error('Stream read decision failed; denying all', { - entityType: subject.entityType, - subjectId: subject.id, - }); + log.error('Stream read decision failed; denying all', { entityType: subject.entityType, subjectId: subject.id }); return subscribers.map(() => false); } } @@ -78,10 +70,7 @@ export function canReceiveProductEvent(subscriber: SubscriberAccess, event: AppS } /** subjectId and every context id column come from the row, so re-parenting evaluates correctly. */ -export const rowScopedEvent = ( - event: AppStreamProductEvent, - rowData: Record, -): AppStreamProductEvent => { +export const rowScopedEvent = (event: AppStreamProductEvent, rowData: Record): AppStreamProductEvent => { const overrides: Record = { rowData }; if (typeof rowData.id === 'string') overrides.subjectId = rowData.id; for (const channelType of appConfig.channelEntityTypes) { @@ -130,9 +119,7 @@ export const dispatchToAppStream = createStreamDispatcher; movedFrom: Record }> => { +const movedRows = (event: AppStreamProductEvent): Array<{ rowData: Record; movedFrom: Record }> => { if (event.batchRows?.length) { return event.batchRows .filter((row): row is ActivityBatchRow & { movedFrom: Record } => !!row.movedFrom) diff --git a/backend/src/modules/entities/helpers/generate-slug.ts b/backend/src/modules/entities/helpers/generate-slug.ts deleted file mode 100644 index 1bc870cfd..000000000 --- a/backend/src/modules/entities/helpers/generate-slug.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ChannelEntityType } from 'shared'; -import { nanoid } from 'shared/utils/nanoid'; -import type { DbContext } from '#/core/context'; -import { checkSlugAvailable } from '#/modules/entities/helpers/check-slug'; - -type EntityTypeWithSlug = ChannelEntityType | 'user'; - -export const generateUniqueSlug = async ( - ctx: DbContext, - baseSlug: string, - entityType: EntityTypeWithSlug, -): Promise => { - if (await checkSlugAvailable(ctx, baseSlug, entityType)) return baseSlug; - - const withSuffix = `${baseSlug}-${nanoid(6)}`; - if (await checkSlugAvailable(ctx, withSuffix, entityType)) return withSuffix; - - // Final fallback uses enough entropy that collisions are not expected. - return `${withSuffix}-${nanoid(10)}`; -}; diff --git a/backend/src/modules/entities/helpers/recalculate-counters.test.ts b/backend/src/modules/entities/helpers/recalculate-counters.test.ts index 2a26cdfdf..bf610c5bc 100644 --- a/backend/src/modules/entities/helpers/recalculate-counters.test.ts +++ b/backend/src/modules/entities/helpers/recalculate-counters.test.ts @@ -31,8 +31,6 @@ describe('deepestAncestorExpr', () => { .product('item', { parent: 'project', nullableAncestors: ['project', 'courseSection'] }) .build(); - expect(deepestAncestorExpr('item', 't', h)).toBe( - 'COALESCE(t.project_id, t.course_section_id, t.course_id, t.organization_id)', - ); + expect(deepestAncestorExpr('item', 't', h)).toBe('COALESCE(t.project_id, t.course_section_id, t.course_id, t.organization_id)'); }); }); diff --git a/backend/src/modules/entities/helpers/recalculate-counters.ts b/backend/src/modules/entities/helpers/recalculate-counters.ts index 423324197..161c874c1 100644 --- a/backend/src/modules/entities/helpers/recalculate-counters.ts +++ b/backend/src/modules/entities/helpers/recalculate-counters.ts @@ -11,36 +11,23 @@ import { getEntityTable } from '#/tables'; const tbl = (et: EntityType) => getTableName(getEntityTable(et)); /** CDC decrements e:c: counters on soft-delete, so recalculation must exclude tombstones to agree. */ -const livePredicate = (et: EntityType, alias: string) => - 'deletedAt' in getColumns(getEntityTable(et)) ? ` AND ${alias}.deleted_at IS NULL` : ''; +const livePredicate = (et: EntityType, alias: string) => ('deletedAt' in getColumns(getEntityTable(et)) ? ` AND ${alias}.deleted_at IS NULL` : ''); /** CDC never counts drafts, so recalculation must exclude them from the table to agree. */ const publishedPredicate = (et: EntityType, alias: string) => 'publishedAt' in getColumns(getEntityTable(et)) ? ` AND ${alias}.published_at IS NOT NULL` : ''; /** Matches CDC's `resolveChannelKey`; the hierarchy parameter lets tests use synthetic trees. */ -export const deepestAncestorExpr = (et: string, alias: string, h: EntityHierarchy = hierarchy) => - h.deepestAncestorSql(et, alias); +export const deepestAncestorExpr = (et: string, alias: string, h: EntityHierarchy = hierarchy) => h.deepestAncestorSql(et, alias); /** JSONB pair with a COUNT subquery: 'key', COALESCE((SELECT COUNT(*) …), 0) */ -const countPair = (key: string, from: string, where: string) => - `'${key}', COALESCE((SELECT COUNT(*) FROM ${from} WHERE ${where}), 0)`; +const countPair = (key: string, from: string, where: string) => `'${key}', COALESCE((SELECT COUNT(*) FROM ${from} WHERE ${where}), 0)`; /** Build JSONB pairs for membership counts: m:c:{role}…, m:c:total, m:c:pending */ const membershipPairs = (alias: string, fk: string, ctxType: string, ctxRoles: readonly string[]) => [ - ...ctxRoles.map((r) => - countPair( - `m:c:${r}`, - 'memberships cm', - `cm.${fk} = ${alias}.id AND cm.channel_type = '${ctxType}' AND cm.role = '${r}'`, - ), - ), + ...ctxRoles.map((r) => countPair(`m:c:${r}`, 'memberships cm', `cm.${fk} = ${alias}.id AND cm.channel_type = '${ctxType}' AND cm.role = '${r}'`)), countPair('m:c:total', 'memberships cm', `cm.${fk} = ${alias}.id AND cm.channel_type = '${ctxType}'`), - countPair( - 'm:c:pending', - 'inactive_memberships im', - `im.${fk} = ${alias}.id AND im.channel_type = '${ctxType}' AND im.rejected_at IS NULL`, - ), + countPair('m:c:pending', 'inactive_memberships im', `im.${fk} = ${alias}.id AND im.channel_type = '${ctxType}' AND im.rejected_at IS NULL`), ]; /** Upsert a SELECT into channel_counters with JSONB || merge */ @@ -105,9 +92,7 @@ export const recalculateCounters = async (db: DbOrTx) => { } // Rebuilt from the maximum stamped sequence; tombstones stay part of the frontier, as in CDC. - const sequenceMaxes = appConfig.productEntityTypes.map( - (et) => `COALESCE((SELECT MAX(t.seq) FROM ${tbl(et)} t WHERE t.organization_id = o.id), 0)`, - ); + const sequenceMaxes = appConfig.productEntityTypes.map((et) => `COALESCE((SELECT MAX(t.seq) FROM ${tbl(et)} t WHERE t.organization_id = o.id), 0)`); if (sequenceMaxes.length > 0) { await upsertChannelCounters( db, @@ -181,10 +166,7 @@ export const recalculateCounters = async (db: DbOrTx) => { const ctxExpr = deepestAncestorExpr(entityType, 't'); if (!ctxExpr) continue; // COALESCE mirrors CDC's e:li:h: stamp source (publishedAt ?? createdAt). - const liSource = - 'publishedAt' in getColumns(getEntityTable(entityType)) - ? 'COALESCE(t.published_at, t.created_at)' - : 't.created_at'; + const liSource = 'publishedAt' in getColumns(getEntityTable(entityType)) ? 'COALESCE(t.published_at, t.created_at)' : 't.created_at'; await upsertChannelCounters( db, @@ -252,12 +234,8 @@ export const recalculateCounters = async (db: DbOrTx) => { ); } - const [{ channelRows }] = await db - .select({ channelRows: sql`count(*)`.mapWith(Number) }) - .from(channelCountersTable); - const [{ productRows }] = await db - .select({ productRows: sql`count(*)`.mapWith(Number) }) - .from(productCountersTable); + const [{ channelRows }] = await db.select({ channelRows: sql`count(*)`.mapWith(Number) }).from(channelCountersTable); + const [{ productRows }] = await db.select({ productRows: sql`count(*)`.mapWith(Number) }).from(productCountersTable); return { channelRows, productRows }; }; diff --git a/backend/src/modules/entities/helpers/session-streams.ts b/backend/src/modules/entities/helpers/session-streams.ts index 43c62189a..a9662cb6c 100644 --- a/backend/src/modules/entities/helpers/session-streams.ts +++ b/backend/src/modules/entities/helpers/session-streams.ts @@ -2,12 +2,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import { baseDb } from '#/db/db'; import { type SessionEndReason, sessionsTable } from '#/modules/auth/sessions-db'; import type { AppStreamSubscriber } from '#/modules/entities/helpers/dispatch-to-stream'; -import { - type BaseStreamSubscriber, - type StreamErrorPayload, - streamSubscriberManager, - writeError, -} from '#/modules/entities/stream'; +import { type BaseStreamSubscriber, type StreamErrorPayload, streamSubscriberManager, writeError } from '#/modules/entities/stream'; import { systemRolesTable } from '#/modules/system/system-roles-db'; import { isExpiredDate } from '#/utils/is-expired-date'; import { log } from '#/utils/logger'; @@ -18,9 +13,7 @@ const endingsWithSuccessor = new Set(['replaced', 'impersonati /** What a stream bound to an ended session hears: reconnect with the newer session, or the session is gone for good. */ export const streamErrorForEnding = (reason: SessionEndReason): StreamErrorPayload => - endingsWithSuccessor.has(reason) - ? { code: 'session_replaced', message: 'Session replaced' } - : { code: 'unauthorized', message: 'Session revoked' }; + endingsWithSuccessor.has(reason) ? { code: 'session_replaced', message: 'Session replaced' } : { code: 'unauthorized', message: 'Session revoked' }; /** How long a close waits for the client to take its error event. */ const ERROR_WRITE_TIMEOUT_MS = 1000; @@ -39,10 +32,7 @@ async function closeAppStream(subscriber: AppStreamSubscriber, payload: StreamEr } /** Closes streams side by side, so a client that stopped reading holds up none of the others. */ -export async function closeAppStreams( - closings: { subscriber: AppStreamSubscriber; payload: StreamErrorPayload }[], - failure: string, -): Promise { +export async function closeAppStreams(closings: { subscriber: AppStreamSubscriber; payload: StreamErrorPayload }[], failure: string): Promise { await Promise.allSettled( closings.map(({ subscriber, payload }) => closeAppStream(subscriber, payload).catch((error) => log.error(failure, { error, subscriberId: subscriber.id })), @@ -60,10 +50,7 @@ const SWEEP_INTERVAL_MS = 60_000; let sweepTimer: ReturnType | null = null; let sweeping = false; -type SessionState = Pick< - typeof sessionsTable.$inferSelect, - 'userId' | 'revokedAt' | 'revocationReason' | 'expiresAt' | 'impersonatorSessionId' ->; +type SessionState = Pick; /** Why a stream must close now, or null while its session still holds what the stream was opened with. */ const staleStreamError = ( @@ -84,10 +71,7 @@ const staleStreamError = ( } // The stream reads as system admin while the user holds the role and it connected from an allowed address. if (subscriber.isSystemAdmin !== (subscriber.systemAccessAllowed && systemAdmins.has(subscriber.userId))) { - return { - code: 'access_changed', - message: subscriber.isSystemAdmin ? 'System role removed' : 'System role granted', - }; + return { code: 'access_changed', message: subscriber.isSystemAdmin ? 'System role removed' : 'System role granted' }; } return null; }; @@ -116,17 +100,11 @@ export async function sweepAppStreamSessions(): Promise { if (subscribers.length === 0) return; const sessions = await readSessionStates([...new Set(subscribers.map((subscriber) => subscriber.sessionId))]); - const impersonatorIds = [ - ...new Set(sessions.flatMap((s) => (s.impersonatorSessionId ? [s.impersonatorSessionId] : []))), - ]; + const impersonatorIds = [...new Set(sessions.flatMap((s) => (s.impersonatorSessionId ? [s.impersonatorSessionId] : [])))]; const impersonators = impersonatorIds.length === 0 ? [] : await readSessionStates(impersonatorIds); - const adminIds = [ - ...new Set([ - ...subscribers.filter((s) => s.isSystemAdmin || s.systemAccessAllowed).map((s) => s.userId), - ...impersonators.map((s) => s.userId), - ]), - ]; + const subscriberUserIds = subscribers.filter((s) => s.isSystemAdmin || s.systemAccessAllowed).map((s) => s.userId); + const adminIds = [...new Set([...subscriberUserIds, ...impersonators.map((s) => s.userId)])]; const admins = adminIds.length === 0 ? [] diff --git a/backend/src/modules/entities/operations/app-catchup.test.ts b/backend/src/modules/entities/operations/app-catchup.test.ts index 6ec45b949..6104734a6 100644 --- a/backend/src/modules/entities/operations/app-catchup.test.ts +++ b/backend/src/modules/entities/operations/app-catchup.test.ts @@ -14,14 +14,7 @@ const OTHER_ORG = 'org-catchup-other'; const [productType] = appConfig.productEntityTypes; const orgAdmin: MembershipBaseModel[] = [ - { - id: 'mem-1', - userId: 'actor', - channelType: 'organization', - channelId: ORG, - organizationId: ORG, - role: 'admin', - } as unknown as MembershipBaseModel, + { id: 'mem-1', userId: 'actor', channelType: 'organization', channelId: ORG, organizationId: ORG, role: 'admin' } as unknown as MembershipBaseModel, ]; // A node below ORG whose counters row carries its verified path. @@ -42,9 +35,7 @@ beforeAll(async () => { }); afterAll(async () => { - await seedDb.execute( - sql.raw(`DELETE FROM channel_counters WHERE channel_key IN ('${ORG}', '${OTHER_ORG}', '${CHILD}')`), - ); + await seedDb.execute(sql.raw(`DELETE FROM channel_counters WHERE channel_key IN ('${ORG}', '${OTHER_ORG}', '${CHILD}')`)); }); describe('answerCatchupViews', () => { @@ -53,9 +44,7 @@ describe('answerCatchupViews', () => { { key: 'v1', organizationId: ORG, prefixes: [ORG], entityTypes: [productType], cursor: 30 }, ]); - expect(answers).toEqual([ - { key: 'v1', status: 'ok', frontiers: { [productType]: 37 }, counts: { [productType]: 12 } }, - ]); + expect(answers).toEqual([{ key: 'v1', status: 'ok', frontiers: { [productType]: 37 }, counts: { [productType]: 12 } }]); }); it('answers a view outside the caller memberships without leaking numbers', async () => { @@ -104,14 +93,10 @@ describe('answerCatchupViews', () => { { key: 'v5', organizationId: ORG, prefixes: [`${ORG}/${CHILD}`], entityTypes: [productType], cursor: 0 }, ]); - expect(answers).toEqual([ - { key: 'v5', status: 'ok', frontiers: { [productType]: 21 }, counts: { [productType]: 3 } }, - ]); + expect(answers).toEqual([{ key: 'v5', status: 'ok', frontiers: { [productType]: 21 }, counts: { [productType]: 3 } }]); }); it('returns empty for no views', async () => { - expect(await answerCatchupViews(orgAdmin, { actorId: 'actor', isSystemAdmin: false, scopes: null }, [])).toEqual( - [], - ); + expect(await answerCatchupViews(orgAdmin, { actorId: 'actor', isSystemAdmin: false, scopes: null }, [])).toEqual([]); }); }); diff --git a/backend/src/modules/entities/operations/app-catchup.ts b/backend/src/modules/entities/operations/app-catchup.ts index 7f8bf8a05..2e4236d0e 100644 --- a/backend/src/modules/entities/operations/app-catchup.ts +++ b/backend/src/modules/entities/operations/app-catchup.ts @@ -33,9 +33,7 @@ export async function answerCatchupViews( } } const counterRows = nodeKeys.size > 0 ? await findChannelCountersByKeys(dbCtx, { keys: [...nodeKeys] }) : []; - const countersByNode = new Map( - counterRows.map((r) => [r.channelKey, { ...parseCounterCounts(r.counts), path: r.path }]), - ); + const countersByNode = new Map(counterRows.map((r) => [r.channelKey, { ...parseCounterCounts(r.counts), path: r.path }])); // Authorize each pair at the view's depth, against the verified path when the row has one. const statuses = views.map((view) => { @@ -110,9 +108,7 @@ export async function appCatchupOp( const changes: AppCatchupResponse['changes'] = {}; for (const organizationId of organizationIdArray) { const { membership } = parseCounterCounts(allCounters.get(organizationId)); - changes[organizationId] = { - signals: membership !== undefined ? { membership } : undefined, - }; + changes[organizationId] = { signals: membership !== undefined ? { membership } : undefined }; } // Embedding propagation hints: frontiers vs the client's org-view cursors. diff --git a/backend/src/modules/entities/operations/check-slug.ts b/backend/src/modules/entities/operations/check-slug.ts index b361ab296..39efdd2b6 100644 --- a/backend/src/modules/entities/operations/check-slug.ts +++ b/backend/src/modules/entities/operations/check-slug.ts @@ -2,11 +2,7 @@ import type { ChannelEntityType } from 'shared'; import type { DbContext } from '#/core/context'; import { checkSlugAvailable } from '#/modules/entities/helpers/check-slug'; -export async function checkSlugOp( - ctx: DbContext, - slug: string, - entityType: ChannelEntityType, -): Promise<{ available: boolean }> { +export async function checkSlugOp(ctx: DbContext, slug: string, entityType: ChannelEntityType): Promise<{ available: boolean }> { const available = await checkSlugAvailable(ctx, slug, entityType); return { available }; } diff --git a/backend/src/modules/entities/stream/build-message.test.ts b/backend/src/modules/entities/stream/build-message.test.ts index 6458156c7..e5016e75c 100644 --- a/backend/src/modules/entities/stream/build-message.test.ts +++ b/backend/src/modules/entities/stream/build-message.test.ts @@ -33,9 +33,7 @@ describe('buildStreamNotification propagation hint', () => { it('classifies a soft-deleted label row as a removal hint', () => { const { propagation } = buildStreamNotification( - labelEvent({ - rowData: { id: 'label-1', organizationId: 'org-1', projectId: 'project-1', deletedAt: '2026-07-26T21:00:00Z' }, - }), + labelEvent({ rowData: { id: 'label-1', organizationId: 'org-1', projectId: 'project-1', deletedAt: '2026-07-26T21:00:00Z' } }), ); expect(propagation).toMatchObject({ update: [], remove: ['label-1'] }); }); diff --git a/backend/src/modules/entities/stream/dispatch-mirror.test.ts b/backend/src/modules/entities/stream/dispatch-mirror.test.ts index 9888bb136..5d9552ab5 100644 --- a/backend/src/modules/entities/stream/dispatch-mirror.test.ts +++ b/backend/src/modules/entities/stream/dispatch-mirror.test.ts @@ -25,12 +25,7 @@ const membership = (organizationId: string, role: EntityRole, userId: string): M }) as unknown as MembershipBaseModel; /** Fake SSE subscriber capturing every notification written to its stream. */ -const fakeSubscriber = ( - memberships: MembershipBaseModel[], - userId: string, - organizationIds: string[], - channelOrg: string, -) => { +const fakeSubscriber = (memberships: MembershipBaseModel[], userId: string, organizationIds: string[], channelOrg: string) => { const received: StreamNotification[] = []; const stream = { writeSSE: async ({ data }: { data: string }) => { @@ -115,9 +110,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { // Authored by the org member, so read stays granted under a row-conditional read:'own' grant. await dispatchToAppStream( - attachmentEvent(ORG_A, { - rowData: attachmentRow('attachment-1', ORG_A, { createdBy: 'member-user' }), - }) as AppStreamEvent, + attachmentEvent(ORG_A, { rowData: attachmentRow('attachment-1', ORG_A, { createdBy: 'member-user' }) }) as AppStreamEvent, ); expect(member.received).toHaveLength(1); // org member: read granted @@ -128,12 +121,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { it('pings a subscriber who can read only a non-representative batch row', async () => { // A stale channel registration after membership removal: dispatch must still evaluate each row. - const { subscriber, received } = fakeSubscriber( - [membership(ORG_A, memberRole, 'moved-user')], - 'moved-user', - [ORG_A, ORG_B], - ORG_B, - ); + const { subscriber, received } = fakeSubscriber([membership(ORG_A, memberRole, 'moved-user')], 'moved-user', [ORG_A, ORG_B], ORG_B); streamSubscriberManager.register(subscriber); // The representative first row is in unreadable org B, the second in org A: representative-row @@ -186,10 +174,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { attachmentEvent(ORG_A, { type: 'attachment.deleted', action: 'delete', - rowData: attachmentRow('attachment-unpublished', ORG_A, { - createdBy: 'member-user', - publishedAt: '2026-07-04T09:00:00.000Z', - }), + rowData: attachmentRow('attachment-unpublished', ORG_A, { createdBy: 'member-user', publishedAt: '2026-07-04T09:00:00.000Z' }), }) as AppStreamEvent, ); @@ -204,10 +189,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { await dispatchToAppStream( attachmentEvent(ORG_A, { - rowData: attachmentRow('attachment-published', ORG_A, { - createdBy: 'member-user', - publishedAt: '2026-07-04T09:00:00.000Z', - }), + rowData: attachmentRow('attachment-published', ORG_A, { createdBy: 'member-user', publishedAt: '2026-07-04T09:00:00.000Z' }), }) as AppStreamEvent, ); @@ -218,12 +200,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { // Connected as a member of ORG_A only, so the new-org invite can arrive only via the user // channel. The bystander shares the org channel but must not receive that event. const joiner = fakeSubscriber([membership(ORG_A, memberRole, 'joiner-user')], 'joiner-user', [ORG_A], ORG_A); - const bystander = fakeSubscriber( - [membership(ORG_A, memberRole, 'bystander-user')], - 'bystander-user', - [ORG_A], - ORG_A, - ); + const bystander = fakeSubscriber([membership(ORG_A, memberRole, 'bystander-user')], 'bystander-user', [ORG_A], ORG_A); streamSubscriberManager.register(joiner.subscriber, ['user:joiner-user']); streamSubscriberManager.register(bystander.subscriber, ['user:bystander-user']); @@ -237,14 +214,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { subjectId: 'mem-new-org', tenantId: 'tenant-1', organizationId: ORG_B, - rowData: { - id: 'mem-new-org', - userId: 'joiner-user', - channelType: 'organization', - channelId: ORG_B, - organizationId: ORG_B, - role: memberRole, - }, + rowData: { id: 'mem-new-org', userId: 'joiner-user', channelType: 'organization', channelId: ORG_B, organizationId: ORG_B, role: memberRole }, seq: null, batchUntilSeq: null, propagation: null, @@ -260,12 +230,7 @@ describe('dispatch mirror: org membership, live snapshots, batches', () => { }); it('does not ping anyone for a batch with no readable rows', async () => { - const { subscriber, received } = fakeSubscriber( - [membership(ORG_A, memberRole, 'moved-user')], - 'moved-user', - [ORG_A, ORG_B], - ORG_B, - ); + const { subscriber, received } = fakeSubscriber([membership(ORG_A, memberRole, 'moved-user')], 'moved-user', [ORG_A, ORG_B], ORG_B); streamSubscriberManager.register(subscriber); await dispatchToAppStream( diff --git a/backend/src/modules/entities/stream/dispatcher.ts b/backend/src/modules/entities/stream/dispatcher.ts index b23841c55..b259220b4 100644 --- a/backend/src/modules/entities/stream/dispatcher.ts +++ b/backend/src/modules/entities/stream/dispatcher.ts @@ -33,16 +33,9 @@ export function createStreamDispatcher - sendNotificationToSubscriber(subscriber, event, notification, transformNotification, preSerialized).catch( - (error) => { - log.error('Failed to dispatch stream event', { - subscriberId: subscriber.id, - activityId: event.id, - channel, - error, - }); - }, - ), + sendNotificationToSubscriber(subscriber, event, notification, transformNotification, preSerialized).catch((error) => { + log.error('Failed to dispatch stream event', { subscriberId: subscriber.id, activityId: event.id, channel, error }); + }), ), ); }; diff --git a/backend/src/modules/entities/stream/helpers.ts b/backend/src/modules/entities/stream/helpers.ts index 7a8eee637..aadca12e5 100644 --- a/backend/src/modules/entities/stream/helpers.ts +++ b/backend/src/modules/entities/stream/helpers.ts @@ -20,35 +20,21 @@ export interface StreamErrorPayload { } export async function writeChange(stream: SSEStreamingApi, id: string, data: unknown): Promise { - await stream.writeSSE({ - event: 'change', - id, - data: JSON.stringify(data), - }); + await stream.writeSSE({ event: 'change', id, data: JSON.stringify(data) }); } export async function writeChangeRaw(stream: SSEStreamingApi, id: string, serializedData: string): Promise { - await stream.writeSSE({ - event: 'change', - id, - data: serializedData, - }); + await stream.writeSSE({ event: 'change', id, data: serializedData }); } /** Catch-up complete marker. */ export async function writeOffset(stream: SSEStreamingApi, cursor: string | null): Promise { - await stream.writeSSE({ - event: 'offset', - data: cursor ?? '', - }); + await stream.writeSSE({ event: 'offset', data: cursor ?? '' }); } /** The caller must return from the streamSSE callback after this, closing the stream. */ export async function writeError(stream: SSEStreamingApi, payload: StreamErrorPayload): Promise { - await stream.writeSSE({ - event: 'error', - data: JSON.stringify(payload), - }); + await stream.writeSSE({ event: 'error', data: JSON.stringify(payload) }); } /** diff --git a/backend/src/modules/entities/stream/move-out.test.ts b/backend/src/modules/entities/stream/move-out.test.ts index 02d768c80..7210eeb71 100644 --- a/backend/src/modules/entities/stream/move-out.test.ts +++ b/backend/src/modules/entities/stream/move-out.test.ts @@ -100,10 +100,7 @@ describe('dispatchMoveOuts', () => { await dispatchMoveOuts( updateEvent({ rowData: row('att-1', { publishedAt: null }), - movedFrom: row('att-1', { - publishedAt: '2026-07-01T00:00:00Z', - createdBy: 'member-user', - }), + movedFrom: row('att-1', { publishedAt: '2026-07-01T00:00:00Z', createdBy: 'member-user' }), }), ); @@ -127,10 +124,7 @@ describe('dispatchMoveOuts', () => { // Positive control: both rows are authored by the reader, so both locations are readable // under a read:'own' policy. Without that authorship the assertion could pass vacuously. await dispatchMoveOuts( - updateEvent({ - rowData: row('att-1', { createdBy: 'member-user' }), - movedFrom: row('att-1', { createdBy: 'member-user' }), - }), + updateEvent({ rowData: row('att-1', { createdBy: 'member-user' }), movedFrom: row('att-1', { createdBy: 'member-user' }) }), ); expect(member.received).toHaveLength(0); @@ -159,17 +153,10 @@ describe('dispatchMoveOuts', () => { { seq: 8, rowData: row('att-2', { publishedAt: null }), - movedFrom: row('att-2', { - publishedAt: '2026-07-01T00:00:00Z', - createdBy: 'member-user', - }), + movedFrom: row('att-2', { publishedAt: '2026-07-01T00:00:00Z', createdBy: 'member-user' }), }, // Moved but still readable: routed by the normal update, no moveOut. - { - seq: 9, - rowData: row('att-3', { createdBy: 'member-user' }), - movedFrom: row('att-3', { createdBy: 'member-user' }), - }, + { seq: 9, rowData: row('att-3', { createdBy: 'member-user' }), movedFrom: row('att-3', { createdBy: 'member-user' }) }, // Not moved at all. { seq: 10, rowData: row('att-4') }, ], diff --git a/backend/src/modules/entities/stream/send-to-subscriber.ts b/backend/src/modules/entities/stream/send-to-subscriber.ts index e718b119f..c81e8c7c8 100644 --- a/backend/src/modules/entities/stream/send-to-subscriber.ts +++ b/backend/src/modules/entities/stream/send-to-subscriber.ts @@ -22,12 +22,7 @@ export async function sendNotificationToSubscriber { abort: () => (stream.aborted = true), close: async () => (stream.closed = true), }); - const subscriber = { - id: sessionId, - stream, - userId: USER, - sessionId, - memberships: [], - } as unknown as AppStreamSubscriber; + const subscriber = { id: sessionId, stream, userId: USER, sessionId, memberships: [] } as unknown as AppStreamSubscriber; streamSubscriberManager.register(subscriber, [`user:${USER}`]); return stream; }; diff --git a/backend/src/modules/entities/stream/types.ts b/backend/src/modules/entities/stream/types.ts index 7bbf0c821..0157f0c43 100644 --- a/backend/src/modules/entities/stream/types.ts +++ b/backend/src/modules/entities/stream/types.ts @@ -27,15 +27,10 @@ export interface DispatcherConfig = E & { - subjectId: string; - organizationId: string; -}; +export type EntityScopedEvent = E & { subjectId: string; organizationId: string }; /** Product entity event routed via the app (authenticated) stream. */ -export type AppStreamProductEvent = EntityScopedEvent< - ActivityEvent & { entityType: ProductEntityType } & Partial ->; +export type AppStreamProductEvent = EntityScopedEvent>; export type AppStreamMembershipEvent = EntityScopedEvent; diff --git a/backend/src/modules/label/helpers/primary-labels.ts b/backend/src/modules/label/helpers/primary-labels.ts index 684b5dff0..b7dfb1930 100644 --- a/backend/src/modules/label/helpers/primary-labels.ts +++ b/backend/src/modules/label/helpers/primary-labels.ts @@ -21,13 +21,7 @@ interface BuildPrimaryLabelRowsOpts { * setupConfig entries. Array order becomes displayOrder; the first row is the default * primary label for new tasks in the project. */ -export const buildPrimaryLabelRows = ({ - entries, - projectId, - organizationId, - tenantId, - createdBy, -}: BuildPrimaryLabelRowsOpts): InsertLabelModel[] => { +export const buildPrimaryLabelRows = ({ entries, projectId, organizationId, tenantId, createdBy }: BuildPrimaryLabelRowsOpts): InsertLabelModel[] => { const createdAt = getIsoDate(); return entries.map((entry, index) => ({ entityType: 'label' as const, @@ -57,18 +51,13 @@ interface PropagateSetupConfigLabelsOpts { * Live primary labels for a set of projects, ordered by displayOrder (default first). * Must run inside a tenant context (labels are FORCE-RLS). */ -export const findLivePrimaryLabels = async ( - ctx: DbContext, - { projectIds }: { projectIds: string[] }, -): Promise => { +export const findLivePrimaryLabels = async (ctx: DbContext, { projectIds }: { projectIds: string[] }): Promise => { if (projectIds.length === 0) return []; const { db } = ctx.var; return db .select() .from(labelsTable) - .where( - and(inArray(labelsTable.projectId, projectIds), eq(labelsTable.mode, 'primary'), isNull(labelsTable.deletedAt)), - ) + .where(and(inArray(labelsTable.projectId, projectIds), eq(labelsTable.mode, 'primary'), isNull(labelsTable.deletedAt))) .orderBy(asc(labelsTable.displayOrder)); }; @@ -174,14 +163,7 @@ export const propagateSetupConfigLabels = async ( for (const entry of entries) { await db .update(labelsTable) - .set({ - name: entry.name, - color: entry.color, - icon: entry.icon, - updatedAt, - updatedBy, - stx: sql`stx - 'changedFields'`, - }) + .set({ name: entry.name, color: entry.color, icon: entry.icon, updatedAt, updatedBy, stx: sql`stx - 'changedFields'` }) .where( and( eq(labelsTable.organizationId, organizationId), diff --git a/backend/src/modules/label/label-db.ts b/backend/src/modules/label/label-db.ts index 9a6b5f6e6..a06c6f0a9 100644 --- a/backend/src/modules/label/label-db.ts +++ b/backend/src/modules/label/label-db.ts @@ -38,19 +38,16 @@ export const labelsTable = snakeCase.table( index('labels_created_by_index').on(table.createdBy), index('labels_updated_by_index').on(table.updatedBy), ...channelRelationIndexes('labels', table, 'label'), - foreignKey({ - columns: [table.tenantId, table.organizationId], - foreignColumns: [organizationsTable.tenantId, organizationsTable.id], - }).onDelete('cascade'), + foreignKey({ columns: [table.tenantId, table.organizationId], foreignColumns: [organizationsTable.tenantId, organizationsTable.id] }).onDelete( + 'cascade', + ), tenantSelectPolicy('labels', table), ...writeThroughPolicies('labels'), ], ); // Get table columns and convert to snake_case -export const labelsTableColumns = Object.fromEntries( - Object.entries(getColumns(labelsTable)).map(([key, column]) => [toSnakeCase(column.name), key]), -); +export const labelsTableColumns = Object.fromEntries(Object.entries(getColumns(labelsTable)).map(([key, column]) => [toSnakeCase(column.name), key])); export type LabelModel = typeof labelsTable.$inferSelect; export type InsertLabelModel = typeof labelsTable.$inferInsert; diff --git a/backend/src/modules/label/label-mocks.ts b/backend/src/modules/label/label-mocks.ts index edde16136..0a7cfb919 100644 --- a/backend/src/modules/label/label-mocks.ts +++ b/backend/src/modules/label/label-mocks.ts @@ -1,12 +1,6 @@ import { faker } from '@faker-js/faker'; import { labelSlug } from 'shared/config/labels-config'; -import { - generateMockEntityChannelIdColumns, - mockBatchResponse, - mockPaginated, - mockProductColumns, - withFakerSeed, -} from '#/mocks'; +import { generateMockEntityChannelIdColumns, mockBatchResponse, mockPaginated, mockProductColumns, withFakerSeed } from '#/mocks'; import type { LabelModel } from '#/modules/label/label-db'; /** @@ -18,14 +12,7 @@ import type { LabelModel } from '#/modules/label/label-db'; */ export const mockLabel = (key = 'label:default', suffix?: string): LabelModel => withFakerSeed(key, () => { - const baseName = faker.helpers.arrayElement([ - 'bug', - 'feature', - 'enhancement', - 'documentation', - 'urgent', - 'low priority', - ]); + const baseName = faker.helpers.arrayElement(['bug', 'feature', 'enhancement', 'documentation', 'urgent', 'low priority']); const channelIds = generateMockEntityChannelIdColumns('label'); const name = suffix ? `${baseName}-${suffix}` : baseName; diff --git a/backend/src/modules/label/label-module.ts b/backend/src/modules/label/label-module.ts index 5f623e52d..ecb36c508 100644 --- a/backend/src/modules/label/label-module.ts +++ b/backend/src/modules/label/label-module.ts @@ -42,11 +42,7 @@ defineBackendModule({ const nextLabels = primaryLabelsOf(org); if (!nextLabels || JSON.stringify(primaryLabelsOf(before[index])) === JSON.stringify(nextLabels)) continue; await tenantContext(ctx, (txCtx) => - propagateSetupConfigLabels(txCtx, { - entries: nextLabels, - organizationId: org.id as string, - updatedBy: ctx.var.actor.id, - }), + propagateSetupConfigLabels(txCtx, { entries: nextLabels, organizationId: org.id as string, updatedBy: ctx.var.actor.id }), ); } }, diff --git a/backend/src/modules/label/label-queries.ts b/backend/src/modules/label/label-queries.ts index 51550adc9..f9c8abd71 100644 --- a/backend/src/modules/label/label-queries.ts +++ b/backend/src/modules/label/label-queries.ts @@ -10,9 +10,7 @@ import { labelsTable } from '#/modules/label/label-db'; * as `e:c:` (see cdc getCountDeltas). Derived from the same embedding * config so reader and writer cannot drift apart. */ -export const labelUsedCountKey = `e:c:${ - appConfig.productEmbeddings.find((e) => e.embeddedProduct === 'label')?.hostProduct ?? 'task' -}`; +export const labelUsedCountKey = `e:c:${appConfig.productEmbeddings.find((e) => e.embeddedProduct === 'label')?.hostProduct ?? 'task'}`; /** Find all labels in an organization (used for duplicate/color matching). */ export const findLabelsByOrg = async (ctx: ActorContext) => { @@ -61,9 +59,7 @@ export const deleteLabelsByIds = async (ctx: ActorContext, { ids, deletedAt, del return db .update(labelsTable) .set({ deletedAt, deletedBy, updatedAt: deletedAt, updatedBy: deletedBy }) - .where( - and(inArray(labelsTable.id, ids), requestScopeWhere(ctx, labelsTable, 'label'), isNull(labelsTable.deletedAt)), - ) + .where(and(inArray(labelsTable.id, ids), requestScopeWhere(ctx, labelsTable, 'label'), isNull(labelsTable.deletedAt))) .returning(); }; @@ -101,9 +97,7 @@ export const buildLabelsListQuery = (ctx: ActorContext, { filters }: BuildLabels return db .select({ ...getColumns(labelsTable), - usedCount: sql`coalesce((${channelCountersTable.counts}->>${labelUsedCountKey})::int, 0)`.as( - 'used_count', - ), + usedCount: sql`coalesce((${channelCountersTable.counts}->>${labelUsedCountKey})::int, 0)`.as('used_count'), }) .from(labelsTable) .leftJoin(channelCountersTable, sql`${channelCountersTable.channelKey} = ${labelsTable.id}::text`) diff --git a/backend/src/modules/label/label-routes.ts b/backend/src/modules/label/label-routes.ts index 6a9709437..bd024b7dc 100644 --- a/backend/src/modules/label/label-routes.ts +++ b/backend/src/modules/label/label-routes.ts @@ -61,19 +61,11 @@ const labelsRoutes = { tags: ['labels', 'app', 'product'], summary: 'Get list of labels', description: 'Returns a list of labels for a given project or workspace.', - request: { - params: tenantOrgParamSchema, - query: labelListQuerySchema, - }, + request: { params: tenantOrgParamSchema, query: labelListQuerySchema }, responses: { 200: { description: 'Label list', - content: { - 'application/json': { - schema: paginationSchema(labelSchema), - example: mockPaginatedLabelsResponse(), - }, - }, + content: { 'application/json': { schema: paginationSchema(labelSchema), example: mockPaginatedLabelsResponse() } }, }, ...errorResponseRefs, }, @@ -87,9 +79,7 @@ const labelsRoutes = { tags: ['labels', 'app', 'product'], summary: 'Get label', description: 'Retrieves a label by its ID.', - request: { - params: idInTenantOrgParamSchema, - }, + request: { params: idInTenantOrgParamSchema }, responses: { 200: { description: 'Label', @@ -111,11 +101,7 @@ const labelsRoutes = { params: idInTenantOrgParamSchema, body: { required: true, - content: { - 'application/json': { - schema: labelUpdateStxBodySchema, - }, - }, + content: { 'application/json': { schema: labelUpdateStxBodySchema } }, }, }, responses: { diff --git a/backend/src/modules/label/label-schema.ts b/backend/src/modules/label/label-schema.ts index 771c525be..676bc027e 100644 --- a/backend/src/modules/label/label-schema.ts +++ b/backend/src/modules/label/label-schema.ts @@ -6,14 +6,7 @@ import { evolutionContract } from '#/core/schema-evolution/evolution-contract'; import { createInsertSchema, createSelectSchema } from '#/db/utils/drizzle-schema'; import { labelsTable } from '#/modules/label/label-db'; import { mockLabelResponse } from '#/modules/label/label-mocks'; -import { - batchResponseSchema, - maxLength, - paginationQuerySchema, - stxBaseSchema, - validIdSchema, - validUuidSchema, -} from '#/schemas'; +import { batchResponseSchema, maxLength, paginationQuerySchema, stxBaseSchema, validIdSchema, validUuidSchema } from '#/schemas'; import { iconNameSchema } from '#/schemas/icon-name-schema'; import { labelSlugSchema } from '#/schemas/label-slug-schema'; import { pick } from '#/utils/pick'; @@ -21,27 +14,18 @@ import { pick } from '#/utils/pick'; const labelInsertSchema = createInsertSchema(labelsTable); const labelSelectSchema = createSelectSchema(labelsTable); -const labelCreateSchema = labelInsertSchema - .pick({ - name: true, - projectId: true, - }) - .extend({ - id: validUuidSchema, - color: z.string().max(maxLength.field).nullable(), - mode: z.enum(labelModes).default('secondary'), - slug: labelSlugSchema.optional(), - icon: iconNameSchema.nullable().optional(), - displayOrder: z.number().optional(), - }); +const labelCreateSchema = labelInsertSchema.pick({ name: true, projectId: true }).extend({ + id: validUuidSchema, + color: z.string().max(maxLength.field).nullable(), + mode: z.enum(labelModes).default('secondary'), + slug: labelSlugSchema.optional(), + icon: iconNameSchema.nullable().optional(), + displayOrder: z.number().optional(), +}); export const labelSchema = z .object({ - ...labelSelectSchema.omit({ - stx: true, - createdBy: true, - updatedBy: true, - }).shape, + ...labelSelectSchema.omit({ stx: true, createdBy: true, updatedBy: true }).shape, mode: z.enum(labelModes), stx: stxBaseSchema, usedCount: z.number().int().min(0).optional(), @@ -65,10 +49,7 @@ export const labelEmbeddedSchema = z.object({ /** Drizzle select object for fetching only embedded label columns */ type LabelEmbeddedKeys = keyof typeof labelEmbeddedSchema.shape; -export const labelEmbeddedSelect = pick( - getColumns(labelsTable), - Object.keys(labelEmbeddedSchema.shape) as LabelEmbeddedKeys[], -); +export const labelEmbeddedSelect = pick(getColumns(labelsTable), Object.keys(labelEmbeddedSchema.shape) as LabelEmbeddedKeys[]); /** Wire registration: lens-widened schemas + entity-bound runtime seams for label */ export const labelContract = evolutionContract.product('label', { @@ -105,9 +86,7 @@ export const labelListQuerySchema = paginationQuerySchema projectId: validIdSchema.optional(), workspaceId: validIdSchema.optional(), }) - .refine((data) => !data.projectId || !data.workspaceId, { - message: 'Only one of projectId or workspaceId can be provided', - }); + .refine((data) => !data.projectId || !data.workspaceId, { message: 'Only one of projectId or workspaceId can be provided' }); export const labelCreateManyStxBodySchema = labelContract.createItemSchema.array().min(1).max(50); export const labelCreateResponseSchema = batchResponseSchema(labelSchema); diff --git a/backend/src/modules/label/operations/create-labels.ts b/backend/src/modules/label/operations/create-labels.ts index 2e5b467f2..79c53b118 100644 --- a/backend/src/modules/label/operations/create-labels.ts +++ b/backend/src/modules/label/operations/create-labels.ts @@ -17,10 +17,7 @@ import { log } from '#/utils/logger'; type CreateLabelsInput = z.infer; -export async function createLabelsOp( - ctx: UserContext, - rawInput: CreateLabelsInput, -): Promise<{ data: LabelModel[]; rejectedIds: string[] }> { +export async function createLabelsOp(ctx: UserContext, rawInput: CreateLabelsInput): Promise<{ data: LabelModel[]; rejectedIds: string[] }> { // Lens seam: canonicalize old-shape field names before any body access const input = rawInput.map((item) => labelContract.normalizeCreateItem(item)); const { organization, tenant } = ctx.var; @@ -36,19 +33,14 @@ export async function createLabelsOp( if (existing) return { data: existing, rejectedIds: [] }; // Check restriction limits. Concurrent requests may slightly overshoot. - const currentCount = await getOrganizationEntityCount(ctx, { - organizationId: organization.id, - entityType: 'label', - }); + const currentCount = await getOrganizationEntityCount(ctx, { organizationId: organization.id, entityType: 'label' }); if (labelRestrictions !== 0 && currentCount + input.length > labelRestrictions) { throw new AppError(429, 'restrict_by_org', 'warn', { entityType: 'label' }); } // Creating primary/epic labels requires project-admin authority (project update permission) - const managedProjectIds = [ - ...new Set(input.filter((item) => item.mode !== 'secondary').map((item) => item.projectId)), - ]; + const managedProjectIds = [...new Set(input.filter((item) => item.mode !== 'secondary').map((item) => item.projectId))]; for (const managedProjectId of managedProjectIds) { await getValidChannel(ctx, managedProjectId, 'project', 'update'); } diff --git a/backend/src/modules/label/operations/get-labels.ts b/backend/src/modules/label/operations/get-labels.ts index fe0478908..759cbf2de 100644 --- a/backend/src/modules/label/operations/get-labels.ts +++ b/backend/src/modules/label/operations/get-labels.ts @@ -88,16 +88,7 @@ export async function getLabelsOp( // Seq reads are keyset-paged: seq order (id tiebreak) makes a capped page a clean prefix const orderBy = seqCursor ? [sql`seq asc`, sql`id asc`] - : getOrderColumns({ - sort, - order, - fallback: ['name', 'asc'], - columns: { - name: sql`name`, - usedCount: sql`used_count`, - }, - tieBreaker: sql`id`, - }); + : getOrderColumns({ sort, order, fallback: ['name', 'asc'], columns: { name: sql`name`, usedCount: sql`used_count` }, tieBreaker: sql`id` }); const itemsQuery = db .select() @@ -109,10 +100,7 @@ export async function getLabelsOp( const totalSource: ListTotalSource = isDelta ? { kind: 'pageLength' } : counterEligible - ? { - kind: 'counter', - getTotal: () => getOrganizationEntityCount(readCtx, { organizationId, entityType: 'label' }), - } + ? { kind: 'counter', getTotal: () => getOrganizationEntityCount(readCtx, { organizationId, entityType: 'label' }) } : { kind: 'exact', getTotal: async () => { diff --git a/backend/src/modules/label/operations/update-label.ts b/backend/src/modules/label/operations/update-label.ts index d0c89aabf..da1c7ed14 100644 --- a/backend/src/modules/label/operations/update-label.ts +++ b/backend/src/modules/label/operations/update-label.ts @@ -40,20 +40,14 @@ export async function updateLabelOp( const opsKeys = Object.keys(rawOps ?? {}); const memberLevelOnly = opsKeys.length > 0 && opsKeys.every((key) => key === 'description' || key === 'mode'); if ('description' in (rawOps ?? {}) && before.mode !== 'epic') { - throw new AppError(403, 'forbidden', 'warn', { - entityType: 'label', - meta: { reason: 'Only epic labels carry a description' }, - }); + throw new AppError(403, 'forbidden', 'warn', { entityType: 'label', meta: { reason: 'Only epic labels carry a description' } }); } // Primary rows are the org's task types; their mode never changes (the schema already // limits transitions to secondary <-> epic) const modeChange = 'mode' in (rawOps ?? {}); if (modeChange && before.mode === 'primary') { - throw new AppError(403, 'forbidden', 'warn', { - entityType: 'label', - meta: { reason: 'Primary labels cannot change mode' }, - }); + throw new AppError(403, 'forbidden', 'warn', { entityType: 'label', meta: { reason: 'Primary labels cannot change mode' } }); } // Other edits on primary/epic labels (identity, appearance) require project-admin @@ -64,9 +58,7 @@ export async function updateLabelOp( // Server-origin writes (Yjs description materialization) carry no client field timestamps, // so each changed scalar gets a fresh server HLC. - const resolved = serverOrigin - ? labelContract.resolveServerUpdateOps(before, rawOps) - : labelContract.resolveUpdateOps(before, rawOps, stx); + const resolved = serverOrigin ? labelContract.resolveServerUpdateOps(before, rawOps) : labelContract.resolveUpdateOps(before, rawOps, stx); const values: Partial = { ...(resolved.changed ? resolved.values : {}), diff --git a/backend/src/modules/mcp/mcp-handlers.ts b/backend/src/modules/mcp/mcp-handlers.ts index 02a93580b..35f89e750 100644 --- a/backend/src/modules/mcp/mcp-handlers.ts +++ b/backend/src/modules/mcp/mcp-handlers.ts @@ -1,49 +1,35 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import { accessScopes, appConfig } from 'shared'; import type { Env } from '#/core/context'; import { mcpRoutes } from '#/modules/mcp/mcp-routes'; -import { - handleMcpMessage, - InsufficientScopeError, - type JsonRpcMessage, - type JsonRpcResponse, -} from '#/modules/mcp/mcp-server'; -import { resourceMetadataUrl, resourceUri } from '#/modules/oauth-server/resources'; +import { handleMcpMessage, InsufficientScopeError, type JsonRpcMessage, type JsonRpcResponse } from '#/modules/mcp/mcp-server'; +import { getMcpTools } from '#/modules/mcp/mcp-tools'; +import { protectedResourceMetadata, resourceMetadataUrl } from '#/modules/oauth-server/resources'; import { defaultHook } from '#/utils/default-hook'; const app = new OpenAPIHono({ defaultHook }); app.openapi(mcpRoutes.getMcpProtectedResourceMetadata, async (ctx) => { const { tenantId, organizationId } = ctx.req.valid('param'); - const ref = { face: 'mcp', tenantId: tenantId.toLowerCase(), organizationId } as const; - return ctx.json( - { - resource: resourceUri(ref), - authorization_servers: [appConfig.oauthUrl], - scopes_supported: [...accessScopes.all], - bearer_methods_supported: ['header'], - resource_documentation: `${appConfig.frontendUrl}/docs`, - }, - 200, - ); + return ctx.json(protectedResourceMetadata({ face: 'mcp', tenantId: tenantId.toLowerCase(), organizationId }), 200); }); // biome-ignore lint/suspicious/noExplicitAny: JSON-RPC bodies are dynamic and notifications return 202 with no body app.openapi(mcpRoutes.handleMcp, async (ctx): Promise => { const body = ctx.req.valid('json') as JsonRpcMessage | JsonRpcMessage[]; + const tools = await getMcpTools(); try { // JSON-RPC batch: collect responses, dropping notification (null) results. if (Array.isArray(body)) { const responses: JsonRpcResponse[] = []; for (const message of body) { - const response = await handleMcpMessage(ctx, message); + const response = await handleMcpMessage(ctx, message, tools); if (response) responses.push(response); } return responses.length ? ctx.json(responses, 200) : ctx.body(null, 202); } - const response = await handleMcpMessage(ctx, body); + const response = await handleMcpMessage(ctx, body, tools); if (!response) return ctx.body(null, 202); return ctx.json(response, 200); } catch (error) { @@ -51,10 +37,7 @@ app.openapi(mcpRoutes.handleMcp, async (ctx): Promise => { // Step-up (RFC 6750 §3.1): the client re-authorizes with the named scope and retries. const { tenantId, organizationId } = ctx.req.valid('param'); const metadata = resourceMetadataUrl({ face: 'mcp', tenantId: tenantId.toLowerCase(), organizationId }); - ctx.header( - 'WWW-Authenticate', - `Bearer error="insufficient_scope", scope="${error.scope}", resource_metadata="${metadata}"`, - ); + ctx.header('WWW-Authenticate', `Bearer error="insufficient_scope", scope="${error.scope}", resource_metadata="${metadata}"`); const response: JsonRpcResponse = { jsonrpc: '2.0', id: error.id, diff --git a/backend/src/modules/mcp/mcp-routes.ts b/backend/src/modules/mcp/mcp-routes.ts index 55107742b..c8a8ce826 100644 --- a/backend/src/modules/mcp/mcp-routes.ts +++ b/backend/src/modules/mcp/mcp-routes.ts @@ -1,51 +1,35 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { orgGuard, publicGuard, tenantGuard, tokenGuard } from '#/middlewares/guard'; -import { protectedResourceSchema } from '#/modules/oauth-server/oauth-server-routes'; -import { errorResponseRefs, tenantOrgParamSchema } from '#/schemas'; +import { mcpRequestLimiter } from '#/middlewares/rate-limiter/limiters'; +import { mockProtectedResourceResponse } from '#/modules/oauth-server/oauth-server-mocks'; +import { protectedResourceSchema } from '#/modules/oauth-server/oauth-server-schema'; +import { tenantOrgParamSchema } from '#/schemas'; -const mcpRoutes = { - getMcpProtectedResourceMetadata: createXRoute({ - 'x-service': 'mcp', - operationId: 'getMcpProtectedResourceMetadata', +const mcpRoutes = createXRoutes(['mcp', 'cella'], { + getMcpProtectedResourceMetadata: xRoute({ method: 'get', path: '/.well-known/oauth-protected-resource', + xEnabledBy: { service: 'mcp' }, xGuard: [publicGuard], - tags: ['mcp', 'cella'], summary: 'Protected resource metadata', description: 'RFC 9728 metadata of this organization MCP server: its resource identifier, the authorization server that issues tokens for it, and the scopes it understands.', request: { params: tenantOrgParamSchema }, - responses: { - 200: { - description: 'Protected resource metadata', - content: { 'application/json': { schema: protectedResourceSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Protected resource metadata', protectedResourceSchema, mockProtectedResourceResponse('mcp')) }, }), - handleMcp: createXRoute({ - 'x-service': 'mcp', - operationId: 'handleMcp', + handleMcp: xRoute({ method: 'post', path: '/', + xEnabledBy: { service: 'mcp' }, xGuard: [tokenGuard, tenantGuard, orgGuard], - tags: ['mcp', 'cella'], + xRateLimiter: [mcpRequestLimiter], summary: 'MCP endpoint', description: 'Model Context Protocol (JSON-RPC 2.0 over Streamable HTTP) endpoint. Requires an access token from the authorization server; exposes the MCP tools that modules registered (initialize, tools/list, tools/call). A call outside the token scopes answers 403 with a WWW-Authenticate challenge naming the scope to step up to.', - request: { - params: tenantOrgParamSchema, - body: { required: true, content: { 'application/json': { schema: z.any() } } }, - }, - responses: { - 200: { - description: 'JSON-RPC response', - content: { 'application/json': { schema: z.any() } }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, body: jsonBody(z.any()) }, + responses: { 200: json('JSON-RPC response', z.any()) }, }), -}; +}); export { mcpRoutes }; diff --git a/backend/src/modules/mcp/mcp-server.test.ts b/backend/src/modules/mcp/mcp-server.test.ts index cd1e0c6ff..cda9ba92a 100644 --- a/backend/src/modules/mcp/mcp-server.test.ts +++ b/backend/src/modules/mcp/mcp-server.test.ts @@ -1,23 +1,32 @@ -import { z } from '@hono/zod-openapi'; +import { OpenAPIHono, z } from '@hono/zod-openapi'; import { sql } from 'drizzle-orm'; +import type { Context } from 'hono'; import { describe, expect, it } from 'vitest'; -import type { OrgContext } from '#/core/context'; +import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { getMcpTools } from '#/core/mcp-tool-registry'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, xRoute } from '#/core/x-routes'; import { baseDb } from '#/db/db'; import { publicGuard } from '#/middlewares/guard'; -import '#/modules/attachment/attachment-routes'; -import { handleMcpMessage, InsufficientScopeError } from '#/modules/mcp/mcp-server'; -import { describeMcpTools } from '#/modules/mcp/tool-source'; +import { handleMcpMessage, InsufficientScopeError, type JsonRpcMessage } from '#/modules/mcp/mcp-server'; +import { buildMcpTools, type McpToolDescriptor } from '#/modules/mcp/mcp-tools'; +import { createBaseApp } from '#/server'; +import { defaultHook } from '#/utils/default-hook'; -/** Transport-level behavior needs no database: the registry is the attachment routes', the actor carries scopes. */ +/** Transport-level behavior needs no session: the tools are the mounted app's, the actor carries scopes. */ const contextWith = (scopes: string[] | null) => - ({ var: { actor: { kind: 'service', scopes } } }) as unknown as OrgContext; + ({ + var: { actor: { kind: 'service', scopes }, tenantId: 'tenant-1', organizationId: 'org-1' }, + req: { url: 'http://localhost/tenant-1/org-1/mcp', header: () => undefined }, + env: undefined, + }) as unknown as Context; + +const { baseApp } = await import('#/routes'); +const appTools = buildMcpTools(baseApp); +const handle = (scopes: string[] | null, message: JsonRpcMessage) => handleMcpMessage(contextWith(scopes), message, appTools); describe('mcp-server', () => { it('responds to initialize with protocol version, capabilities, and server info', async () => { - const res = await handleMcpMessage(contextWith(null), { jsonrpc: '2.0', id: 1, method: 'initialize' }); + const res = await handle(null, { jsonrpc: '2.0', id: 1, method: 'initialize' }); expect(res).not.toBeNull(); const result = res?.result as Record; expect(result.protocolVersion).toBeTypeOf('string'); @@ -26,41 +35,20 @@ describe('mcp-server', () => { }); it('echoes the client requested protocol version on initialize', async () => { - const res = await handleMcpMessage(contextWith(null), { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2024-11-05' }, - }); + const res = await handle(null, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } }); const result = res?.result as Record; expect(result.protocolVersion).toBe('2024-11-05'); }); it('lists the attachment tools with scope, annotations and a strict input schema', async () => { - const res = await handleMcpMessage(contextWith(['attachment:read']), { - jsonrpc: '2.0', - id: 2, - method: 'tools/list', - }); - const { tools } = (res?.result ?? {}) as { tools: ReturnType }; + const res = await handle(['attachment:read'], { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const { tools } = (res?.result ?? {}) as { tools: McpToolDescriptor[] }; const names = tools.map((tool) => tool.name); - expect(names).toEqual( - expect.arrayContaining([ - 'getAttachments', - 'getAttachment', - 'createAttachments', - 'updateAttachment', - 'deleteAttachments', - ]), - ); + expect(names).toEqual(expect.arrayContaining(['getAttachments', 'getAttachment', 'createAttachments', 'updateAttachment', 'deleteAttachments'])); const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool])); const properties = (name: string) => Object.keys((byName[name].inputSchema as { properties: object }).properties); expect(byName.updateAttachment._meta.scope).toBe('attachment:write'); - expect(byName.updateAttachment.annotations).toMatchObject({ - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - }); + expect(byName.updateAttachment.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false, idempotentHint: true }); // Path params minus the route's own, plus the body without its sync transaction. expect(byName.updateAttachment.inputSchema).toMatchObject({ type: 'object', additionalProperties: false }); expect(properties('updateAttachment')).toEqual(expect.arrayContaining(['id', 'ops'])); @@ -72,7 +60,7 @@ describe('mcp-server', () => { it('refuses a call outside the token scopes with the scope to step up to', async () => { await expect( - handleMcpMessage(contextWith(['attachment:read']), { + handle(['attachment:read'], { jsonrpc: '2.0', id: 6, method: 'tools/call', @@ -82,7 +70,7 @@ describe('mcp-server', () => { }); it('rejects arguments the route schema refuses, before executing', async () => { - const res = await handleMcpMessage(contextWith(null), { + const res = await handle(null, { jsonrpc: '2.0', id: 7, method: 'tools/call', @@ -92,46 +80,54 @@ describe('mcp-server', () => { }); it('answers ping with an empty result', async () => { - const res = await handleMcpMessage(contextWith(null), { jsonrpc: '2.0', id: 3, method: 'ping' }); + const res = await handle(null, { jsonrpc: '2.0', id: 3, method: 'ping' }); expect(res?.result).toEqual({}); }); it('returns no response for notifications', async () => { - const res = await handleMcpMessage(contextWith(null), { jsonrpc: '2.0', method: 'notifications/initialized' }); + const res = await handle(null, { jsonrpc: '2.0', method: 'notifications/initialized' }); expect(res).toBeNull(); }); it('errors on unknown method (method not found)', async () => { - const res = await handleMcpMessage(contextWith(null), { jsonrpc: '2.0', id: 4, method: 'does/not-exist' }); + const res = await handle(null, { jsonrpc: '2.0', id: 4, method: 'does/not-exist' }); expect(res?.error?.code).toBe(-32601); }); describe('a failing tool', () => { - /** Registers a read tool whose operation runs `execute`; each name registers once per run. */ - const toolFailingWith = (operationId: string, execute: () => Promise) => - createXRoute({ - operationId, + const failRoutes = createXRoutes(['things'], { + brokenQueryTool: xRoute({ method: 'get', - path: `/${operationId}`, + path: '/broken', xGuard: [publicGuard], - 'x-tool': { - enabled: true, - description: 'A tool whose operation fails', - approvalRequired: false, - category: 'things', - entity: 'attachment', - execute, - }, - responses: { 200: { description: 'ok' } }, - }); + xTool: { description: 'Fails', approvalRequired: false, entity: 'attachment' }, + summary: 'Broken query', + responses: { 200: json('ok', z.any()) }, + }), + missingThingTool: xRoute({ + method: 'get', + path: '/missing', + xGuard: [publicGuard], + xTool: { description: 'Fails', approvalRequired: false, entity: 'attachment' }, + summary: 'Missing thing', + responses: { 200: json('ok', z.any()) }, + }), + }); + const failing = new OpenAPIHono({ defaultHook }); + failing.openapi(failRoutes.brokenQueryTool, async (ctx) => + ctx.json(await baseDb.execute(sql`select * from mcp_missing_table where token = ${'param-secret-value'}`), 200), + ); + failing.openapi(failRoutes.missingThingTool, () => { + throw new AppError(404, 'not_found', 'warn', { entityType: 'attachment' }); + }); + const app = createBaseApp(); + app.route('/:tenantId/:organizationId/things', failing); + const failingTools = buildMcpTools(app); + const call = (name: string) => - handleMcpMessage(contextWith(null), { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name } }); + handleMcpMessage(contextWith(null), { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name } }, failingTools); it('must not leak SQL or query parameters to the model via a failing query', async () => { - toolFailingWith('brokenQueryTool', async () => - baseDb.execute(sql`select * from mcp_missing_table where token = ${'param-secret-value'}`), - ); - const res = await call('brokenQueryTool'); expect(res?.result).toEqual({ content: [{ type: 'text', text: 'server_error: Internal server error' }], @@ -141,11 +137,7 @@ describe('mcp-server', () => { }); it('answers a domain failure with its type and message (positive control)', async () => { - toolFailingWith('missingThingTool', async () => { - throw new AppError(404, 'not_found', 'warn', { entityType: 'attachment' }); - }); - - const { message } = new AppError(404, 'not_found', 'warn'); + const { message } = new AppError(404, 'not_found', 'warn', { entityType: 'attachment' }); expect(message).not.toBe(''); const res = await call('missingThingTool'); @@ -154,7 +146,7 @@ describe('mcp-server', () => { }); it('errors when calling a tool that is not registered', async () => { - const res = await handleMcpMessage(contextWith(null), { + const res = await handle(null, { jsonrpc: '2.0', id: 5, method: 'tools/call', @@ -163,63 +155,3 @@ describe('mcp-server', () => { expect(res?.error?.code).toBe(-32602); }); }); - -describe('createXRoute with x-tool', () => { - it('registers the route as a tool, derives the input from the request, and rebuilds the sync transaction', async () => { - const calls: unknown[] = []; - createXRoute({ - operationId: 'renameThing', - method: 'put', - path: '/{id}', - xGuard: [publicGuard], - 'x-tool': { - enabled: true, - description: 'Rename a thing', - approvalRequired: true, - category: 'things', - entity: 'attachment', - execute: async (_ctx, { params, body }) => { - calls.push({ id: params.id, name: body.ops.name, stx: body.stx }); - return { ok: true }; - }, - }, - request: { - params: z.object({ tenantId: z.string(), organizationId: z.string(), id: z.string() }), - body: { - content: { - 'application/json': { - schema: z.object({ - ops: z.object({ name: z.string() }), - stx: z.object({ - mutationId: z.string(), - sourceId: z.string(), - fieldTimestamps: z.record(z.string(), z.string()), - }), - }), - }, - }, - }, - }, - responses: { 200: { description: 'ok' } }, - }); - const tool = getMcpTools().find((candidate) => candidate.name === 'renameThing'); - expect(tool).toMatchObject({ - scope: 'attachment:write', - annotations: { readOnlyHint: false, idempotentHint: true }, - }); - expect(Object.keys((describeMcpTools([tool!])[0].inputSchema as { properties: object }).properties)).toEqual([ - 'id', - 'ops', - ]); - - // A sync transaction the model sends never reaches the route: the server's own replaces it. - await tool!.run(contextWith(null), { - id: 'thing-1', - ops: { name: 'renamed' }, - stx: { mutationId: 'model', sourceId: 'model', fieldTimestamps: { name: '1:0001:model' } }, - }); - expect(calls[0]).toMatchObject({ id: 'thing-1', name: 'renamed', stx: { sourceId: 'server' } }); - expect((calls[0] as { stx: { mutationId: string } }).stx.mutationId).not.toBe('model'); - await expect(tool!.run(contextWith(null), { id: 'thing-1', ops: { name: 7 } })).rejects.toThrow(); - }); -}); diff --git a/backend/src/modules/mcp/mcp-server.ts b/backend/src/modules/mcp/mcp-server.ts index 0af0c8bbc..57376ad6d 100644 --- a/backend/src/modules/mcp/mcp-server.ts +++ b/backend/src/modules/mcp/mcp-server.ts @@ -1,9 +1,9 @@ import { z } from '@hono/zod-openapi'; +import type { Context } from 'hono'; import { accessScopes, appConfig } from 'shared'; -import type { OrgContext } from '#/core/context'; -import { getMcpTools } from '#/core/mcp-tool-registry'; +import type { Env } from '#/core/context'; import { toClientError } from '#/lib/error'; -import { describeMcpTools } from '#/modules/mcp/tool-source'; +import type { McpTool } from '#/modules/mcp/mcp-tools'; const PROTOCOL_VERSION = '2026-07-28'; @@ -36,11 +36,11 @@ const serverInfo = { name: `${appConfig.name} MCP`, version: appConfig.apiVersio /** * Model Context Protocol server over JSON-RPC 2.0 (Streamable HTTP, JSON responses): `initialize`, `tools/list`, - * `tools/call`, `ping`. Tools are the routes carrying `x-tool`; the token's scopes gate execution. Returns `null` - * for notifications (messages without an `id`), which must not get a reply. + * `tools/call`, `ping`. Tools are the routes carrying `xTool`; the token's scopes gate execution, and a call runs the + * route's own handler. Returns `null` for notifications (messages without an `id`), which must not get a reply. * @see https://modelcontextprotocol.io */ -export async function handleMcpMessage(ctx: OrgContext, message: JsonRpcMessage): Promise { +export async function handleMcpMessage(ctx: Context, message: JsonRpcMessage, tools: readonly McpTool[]): Promise { const isNotification = message.id === undefined || message.id === null; const id = message.id ?? null; const respond = (result: unknown): JsonRpcResponse => ({ jsonrpc: '2.0', id, result }); @@ -71,34 +71,32 @@ export async function handleMcpMessage(ctx: OrgContext, message: JsonRpcMessage) return respond({}); case 'tools/list': - return respond({ tools: describeMcpTools(getMcpTools()) }); + return respond({ tools: tools.map((tool) => tool.descriptor) }); case 'tools/call': { if (isNotification) return null; const name = typeof message.params?.name === 'string' ? message.params.name : undefined; if (!name) return fail(-32602, 'Invalid params: missing tool name'); - const tool = getMcpTools().find((candidate) => candidate.name === name); + const tool = tools.find((candidate) => candidate.name === name); if (!tool) return fail(-32602, `Unknown tool: ${name}`); // The mask (D2): a token names its scopes explicitly; a missing one is a step-up, never a silent denial. - if (!accessScopes.allows(ctx.var.actor.scopes, tool.entity, tool.action)) - throw new InsufficientScopeError(tool.scope, id); + if (!accessScopes.allows(ctx.var.actor.scopes, tool.entity, tool.action)) throw new InsufficientScopeError(tool.scope, id); try { - const output = await tool.run(ctx, message.params?.arguments); + const outcome = await tool.call(ctx, message.params?.arguments); + // Permission and domain failures are answers the model can act on, not transport errors. + if (!outcome.ok) return respond({ content: [{ type: 'text', text: `${outcome.type}: ${outcome.message}` }], isError: true }); + const { output } = outcome; const text = typeof output === 'string' ? output : JSON.stringify(output ?? null); return respond({ content: [{ type: 'text', text }], structuredContent: output ?? undefined }); } catch (error) { // The route's schemas refused the arguments: a JSON-RPC params error with the issues. if (error instanceof z.ZodError) return fail(-32602, 'Invalid params', error.issues); - // Permission and domain failures are answers the model can act on, not transport errors. The model belongs to - // a third-party client, so a server error reaches it without its internals, in every mode. - const { type, message } = toClientError( - error, - { tool: name, organizationId: ctx.var.organizationId }, - { exposeServerMessage: false }, - ); + // The call itself failed before the route answered. The model belongs to a third-party client, so a server + // error reaches it without its internals, in every mode. + const { type, message } = toClientError(error, { tool: name, organizationId: ctx.var.organizationId }, { exposeServerMessage: false }); return respond({ content: [{ type: 'text', text: `${type}: ${message}` }], isError: true }); } } diff --git a/backend/src/modules/mcp/mcp-tools.test.ts b/backend/src/modules/mcp/mcp-tools.test.ts new file mode 100644 index 000000000..bc5fa038a --- /dev/null +++ b/backend/src/modules/mcp/mcp-tools.test.ts @@ -0,0 +1,124 @@ +import { OpenAPIHono, z } from '@hono/zod-openapi'; +import type { Context } from 'hono'; +import { describe, expect, it } from 'vitest'; +import type { Env } from '#/core/context'; +import { AppError } from '#/core/error'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { publicGuard } from '#/middlewares/guard'; +import { createBaseApp } from '#/server'; +import { defaultHook } from '#/utils/default-hook'; +import { buildMcpTools, type McpTool } from './mcp-tools'; + +const tool = (entity: 'attachment') => ({ description: 'A test tool', approvalRequired: false, entity }); + +const itemSchema = z.object({ name: z.string(), stx: z.object({ mutationId: z.string(), sourceId: z.string() }) }); + +const thingRoutes = createXRoutes(['things'], { + listThings: xRoute({ + method: 'get', + path: '/{id}', + xGuard: [publicGuard], + xTool: tool('attachment'), + summary: 'List things', + request: { + params: z.object({ tenantId: z.string(), organizationId: z.string(), id: z.string() }), + query: z.object({ q: z.string().optional(), limit: z.string().regex(/^\d+$/).transform(Number).optional() }), + }, + responses: { 200: json('Things', z.any()) }, + }), + createThings: xRoute({ + method: 'post', + path: '/', + xGuard: [publicGuard], + xTool: tool('attachment'), + summary: 'Create things', + request: { params: z.object({ tenantId: z.string(), organizationId: z.string() }), body: jsonBody(z.array(itemSchema)) }, + responses: { 201: json('Created', z.any()) }, + }), + refuseThing: xRoute({ + method: 'delete', + path: '/{id}', + xGuard: [publicGuard], + xTool: tool('attachment'), + summary: 'Refuse thing', + request: { params: z.object({ tenantId: z.string(), organizationId: z.string(), id: z.string() }) }, + responses: { 200: json('Deleted', z.any()) }, + }), +}); + +const things = new OpenAPIHono({ defaultHook }); +// Each handler answers with what the route received, so a test sees the request the tool sent. +things.openapi(thingRoutes.listThings, (ctx) => + ctx.json({ params: ctx.req.valid('param'), query: ctx.req.valid('query'), ip: ctx.req.header('x-forwarded-for') }, 200), +); +things.openapi(thingRoutes.createThings, (ctx) => ctx.json(ctx.req.valid('json'), 201)); +things.openapi(thingRoutes.refuseThing, (ctx) => { + if (ctx.req.valid('param').id === 'broken') throw new Error('select secret from things'); + throw new AppError(404, 'not_found', 'warn'); +}); + +const app = createBaseApp(); +app.route('/:tenantId/:organizationId/things', things); +const tools = buildMcpTools(app); + +/** The MCP request a tool call runs inside: its organization, its client address, no bindings. */ +const ctx = { + var: { tenantId: 'tenant-1', organizationId: 'org-1' }, + req: { url: 'http://localhost/tenant-1/org-1/mcp', header: (name: string) => (name === 'x-forwarded-for' ? '203.0.113.7' : undefined) }, + env: undefined, +} as unknown as Context; + +const named = (name: string): McpTool => { + const found = tools.find((candidate) => candidate.name === name); + if (!found) throw new Error(`${name} is not a tool`); + return found; +}; + +describe('buildMcpTools', () => { + it("derives the input from params minus the route's own ids plus the query, and runs the route", async () => { + const list = named('listThings'); + expect(list.scope).toBe('attachment:read'); + expect(Object.keys((list.descriptor.inputSchema as { properties: object }).properties)).toEqual(['id', 'q', 'limit']); + + // The route parses the raw query itself, so its coercions apply; the organization comes from the MCP request. + const outcome = await list.call(ctx, { id: 'a/b', q: 'hi', limit: '5' }); + expect(outcome).toEqual({ + ok: true, + output: { params: { tenantId: 'tenant-1', organizationId: 'org-1', id: 'a/b' }, query: { q: 'hi', limit: 5 }, ip: '203.0.113.7' }, + }); + await expect(list.call(ctx, { id: 'thing-1', limit: 'five' })).rejects.toBeInstanceOf(z.ZodError); + }); + + it('nests an array body under items and rebuilds the sync transaction per item', async () => { + const create = named('createThings'); + expect(create.scope).toBe('attachment:write'); + expect(create.descriptor.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false, idempotentHint: false }); + expect(Object.keys((create.descriptor.inputSchema as { properties: object }).properties)).toEqual(['items']); + + // A sync transaction the model sends is replaced by the server's own, item by item. + const outcome = await create.call(ctx, { + items: [{ name: 'a', stx: { mutationId: 'model', sourceId: 'model' } }, { name: 'b' }], + }); + if (!outcome.ok) throw new Error(outcome.message); + const body = outcome.output as { name: string; stx: { mutationId: string; sourceId: string } }[]; + expect(body.map((item) => item.name)).toEqual(['a', 'b']); + expect(body.map((item) => item.stx.sourceId)).toEqual(['server', 'server']); + expect(body[0].stx.mutationId).not.toBe('model'); + }); + + it("answers with the route's error type and message, and never with a server error's internals", async () => { + const refuse = named('refuseThing'); + expect(refuse.descriptor.annotations.destructiveHint).toBe(true); + + expect(await refuse.call(ctx, { id: 'missing' })).toMatchObject({ ok: false, type: 'not_found' }); + const broken = await refuse.call(ctx, { id: 'broken' }); + expect(broken).toEqual({ ok: false, type: 'server_error', message: 'Internal server error' }); + }); + + it('refuses two routes with the same operation', () => { + const twice = createBaseApp(); + twice.route('/:tenantId/:organizationId/a', things); + twice.route('/:tenantId/:organizationId/b', things); + expect(() => buildMcpTools(twice)).toThrow(/registered twice/); + }); +}); diff --git a/backend/src/modules/mcp/mcp-tools.ts b/backend/src/modules/mcp/mcp-tools.ts new file mode 100644 index 000000000..5d6650d1f --- /dev/null +++ b/backend/src/modules/mcp/mcp-tools.ts @@ -0,0 +1,204 @@ +import { type OpenAPIHono, type RouteConfig, z } from '@hono/zod-openapi'; +import type { Context } from 'hono'; +import { type AccessScope, type AccessScopedEntityType, accessScopes, type EntityActionType } from 'shared'; +import type { Env } from '#/core/context'; +import type { XTool } from '#/core/openapi-extensions'; +import { createServerStx, createServerStxStamping } from '#/core/stx/create-server-stx'; + +/** A tool as `tools/list` returns it. */ +export interface McpToolDescriptor { + name: string; + description: string; + inputSchema: Record; + annotations: { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean }; + /** Beyond the MCP schema: the scope a token needs, so a client can ask for it up front. */ + _meta: { scope: string; approvalRequired: boolean }; +} + +/** What a tool call answers: the route's JSON body, or the type and message of the error it answered with. */ +export type McpToolOutcome = { ok: true; output: unknown } | { ok: false; type: string; message: string }; + +/** A route exposed to MCP clients: named and described by the route, run by the route's own handler. */ +export interface McpTool { + name: string; + /** What the route acts on and how; the scope a token must carry follows from it (`:read` | `:write`). */ + entity: AccessScopedEntityType; + action: EntityActionType; + scope: AccessScope; + descriptor: McpToolDescriptor; + /** + * Validates the arguments against the route's own schemas (a `ZodError` refuses them), rebuilds the sync + * transaction, and sends the request through the app as the caller, so the route's guards, limiters and cache apply. + */ + call: (ctx: Context, args: unknown) => Promise; +} + +/** Route parameters the MCP endpoint already resolved; never model input. */ +const routeParams = ['tenantId', 'organizationId']; + +/** Request headers a tool call carries over from the MCP request: the caller's token and the client address. */ +const forwardedHeaders = ['authorization', 'x-forwarded-for']; + +const anyObject = (schema: unknown): schema is z.ZodObject => schema instanceof z.ZodObject; + +/** + * The sync transaction (`stx`) is trusted server metadata, never model input: `modelSchema` leaves it out of what the + * model sees, `withServerStx` puts a server-built one back before the route's own schema validates the body. + */ +function splitStx(schema: z.ZodType): { modelSchema: z.ZodType; withServerStx: (value: unknown) => unknown } { + if (anyObject(schema) && 'stx' in schema.shape) { + // Rebuilt from the shape (`.omit()` refuses refined objects); the route's own schema still validates the call. + const { stx: _stx, ...shape } = schema.shape; + return { + modelSchema: z.object(shape), + withServerStx: (value) => { + const record = value as Record; + const ops = record?.ops; + const stx = ops && typeof ops === 'object' ? createServerStxStamping(ops as Record) : createServerStx(); + return { ...record, stx }; + }, + }; + } + if (schema instanceof z.ZodArray) { + const inner = splitStx(schema.element as z.ZodType); + if (inner.modelSchema !== schema.element) { + return { + modelSchema: z.array(inner.modelSchema), + withServerStx: (value) => (Array.isArray(value) ? value.map(inner.withServerStx) : value), + }; + } + } + return { modelSchema: schema, withServerStx: (value) => value }; +} + +/** + * JSON Schema for `tools/list`, the input side of the route's schemas (query strings stay strings, as the route + * reads them); unknown keys are refused so a model cannot smuggle fields past the operation. + */ +function toInputSchema(schema: z.ZodType): Record { + const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as Record; + if (json.type === 'object' && json.additionalProperties === undefined) json.additionalProperties = false; + return json; +} + +/** The route's answer as a tool outcome; a server error reaches the model without its internals, in every mode. */ +async function toOutcome(response: Response): Promise { + const payload: unknown = response.status === 204 ? null : await response.json().catch(() => null); + if (response.ok) return { ok: true, output: payload }; + const error = (payload ?? {}) as { type?: string; message?: string }; + if (response.status >= 500) return { ok: false, type: error.type ?? 'server_error', message: 'Internal server error' }; + return { ok: false, type: error.type ?? 'error', message: error.message ?? response.statusText }; +} + +function buildTool(app: OpenAPIHono, route: RouteConfig, spec: XTool): McpTool { + const name = route.operationId; + if (!name) throw new Error(`[MCP] The tool route ${route.method} ${route.path} has no operationId`); + + const paramsSchema = route.request?.params; + const params = anyObject(paramsSchema) + ? paramsSchema.omit(Object.fromEntries(routeParams.filter((key) => key in paramsSchema.shape).map((key) => [key, true]))) + : z.object({}); + const querySchema = route.request?.query; + const query = anyObject(querySchema) ? querySchema : z.object({}); + const media = route.request?.body?.content?.['application/json']; + const jsonBody = media && 'schema' in media ? media.schema : undefined; + const body = jsonBody instanceof z.ZodType ? jsonBody : undefined; + const modelBody = body ? splitStx(body) : undefined; + const bodyIsObject = anyObject(modelBody?.modelSchema); + // A non-object body (a batch of items) nests under one key so it cannot collide with params or query. + const bodyKey = modelBody && !bodyIsObject ? (modelBody.modelSchema instanceof z.ZodArray ? 'items' : 'body') : undefined; + + const inputSchema = z.object({ + ...params.shape, + ...query.shape, + ...(bodyIsObject && anyObject(modelBody?.modelSchema) ? modelBody.modelSchema.shape : {}), + ...(bodyKey && modelBody ? { [bodyKey]: modelBody.modelSchema } : {}), + }); + const paramKeys = Object.keys(params.shape); + const queryKeys = Object.keys(query.shape); + const method = route.method.toUpperCase(); + const isRead = method === 'GET'; + const action: EntityActionType = isRead ? 'read' : 'update'; + const scope = accessScopes.required(spec.entity, action); + + return { + name, + entity: spec.entity, + action, + scope, + descriptor: { + name, + description: spec.description, + inputSchema: toInputSchema(inputSchema), + annotations: { readOnlyHint: isRead, destructiveHint: method === 'DELETE', idempotentHint: method !== 'POST' }, + _meta: { scope, approvalRequired: spec.approvalRequired }, + }, + call: async (ctx, args) => { + const record = (args ?? {}) as Record; + const pick = (keys: string[]) => Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]])); + const rest = Object.fromEntries(Object.entries(record).filter(([key]) => !paramKeys.includes(key) && !queryKeys.includes(key))); + const rawParams = pick(paramKeys); + const rawQuery = pick(queryKeys); + const rawBody = modelBody ? modelBody.withServerStx(bodyKey ? rest[bodyKey] : rest) : undefined; + + // Each part validates against the route's own schema before the request is sent, so the model gets every issue. + params.parse(rawParams); + query.parse(rawQuery); + if (body) body.parse(rawBody); + + // The route parses the raw values again, as it parses any client's. + const values: Record = { ...rawParams, tenantId: ctx.var.tenantId, organizationId: ctx.var.organizationId }; + const path = route.path.replace(/\{(\w+)\}/g, (_, key: string) => encodeURIComponent(String(values[key] ?? ''))); + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(rawQuery)) { + if (value === undefined || value === null) continue; + for (const item of Array.isArray(value) ? value : [value]) search.append(key, String(item)); + } + const url = new URL(search.size ? `${path}?${search}` : path, ctx.req.url); + + const headers = new Headers({ 'content-type': 'application/json' }); + for (const header of forwardedHeaders) { + const value = ctx.req.header(header); + if (value) headers.set(header, value); + } + + const request = new Request(url, { method, headers, body: rawBody === undefined ? undefined : JSON.stringify(rawBody) }); + return toOutcome(await app.fetch(request, ctx.env)); + }, + }; +} + +/** + * One tool per route carrying `xTool` (`x-tool` in the spec), read from the app's OpenAPI registry, where every + * mounted route has its full path, operationId and request schemas. Calls go through `app`, so pass the app the + * routes are mounted on. + */ +export function buildMcpTools(app: OpenAPIHono): McpTool[] { + const tools: McpTool[] = []; + for (const definition of app.openAPIRegistry.definitions) { + if (definition.type !== 'route') continue; + const route = definition.route as RouteConfig & { 'x-tool'?: XTool }; + const spec = route['x-tool']; + if (!spec) continue; + const tool = buildTool(app, route, spec); + if (tools.some((existing) => existing.name === tool.name)) throw new Error(`[MCP] Tool ${tool.name} is registered twice`); + tools.push(tool); + } + return tools; +} + +let appTools: Promise | undefined; + +/** + * The tools of every module route, built once from the app the module routes are mounted on. Imported on first use: + * `#/routes` imports every module, this one included. The MCP worker mounts them there without serving them. + */ +export function getMcpTools(): Promise { + appTools ??= import('#/routes') + .then(({ baseApp }) => buildMcpTools(baseApp)) + .catch((error) => { + appTools = undefined; + throw error; + }); + return appTools; +} diff --git a/backend/src/modules/mcp/tool-source.ts b/backend/src/modules/mcp/tool-source.ts deleted file mode 100644 index 8ef3b4841..000000000 --- a/backend/src/modules/mcp/tool-source.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from '@hono/zod-openapi'; -import type { McpTool } from '#/core/mcp-tool-registry'; - -/** A tool as `tools/list` returns it. */ -export interface McpToolDescriptor { - name: string; - description: string; - inputSchema: Record; - annotations: McpTool['annotations']; - /** Beyond the MCP schema: the scope a token needs, so a client can ask for it up front. */ - _meta: { scope: string; approvalRequired: boolean }; -} - -/** - * JSON Schema for `tools/list`, the input side of the route's schemas (query strings stay strings, as the route - * reads them); unknown keys are refused so a model cannot smuggle fields past the operation. - */ -function toInputSchema(schema: z.ZodType): Record { - const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as Record; - if (json.type === 'object' && json.additionalProperties === undefined) json.additionalProperties = false; - return json; -} - -/** Tools are fixed after boot, so each descriptor is derived once. */ -const descriptors = new WeakMap(); - -export function describeMcpTools(tools: readonly McpTool[]): McpToolDescriptor[] { - return tools.map((tool) => { - let descriptor = descriptors.get(tool); - if (!descriptor) { - descriptor = { - name: tool.name, - description: tool.description, - inputSchema: toInputSchema(tool.inputSchema), - annotations: tool.annotations, - _meta: { scope: tool.scope, approvalRequired: tool.approvalRequired }, - }; - descriptors.set(tool, descriptor); - } - return descriptor; - }); -} diff --git a/backend/src/modules/mcp/worker/mcp-worker-entry.ts b/backend/src/modules/mcp/worker/mcp-worker-entry.ts index 7595b9475..f613bbcdc 100644 --- a/backend/src/modules/mcp/worker/mcp-worker-entry.ts +++ b/backend/src/modules/mcp/worker/mcp-worker-entry.ts @@ -5,16 +5,18 @@ import { setupGracefulShutdown } from 'shared/utils/worker-lifecycle'; import { env } from '#/env'; import { baseLog } from '#/lib/pino'; import { otel } from '#/lib/tracing'; -import { listenForAuthInvalidation } from '#/middlewares/guard/invalidation-listener'; -import '#/modules'; // composition root: registers every backend module (this worker mounts only mcp routes) +// Composition root: registers every backend module. This worker serves only the mcp routes; tool calls run through +// the module routes `#/routes` mounts. +import '#/modules'; import { mcpHandlers } from '#/modules/mcp/mcp-handlers'; -import { baseApp } from '#/server'; +import { createBaseApp } from '#/server'; /** * The MCP face as its own process: the tool endpoint and its protected-resource metadata, behind tokens from the - * authorization server. Needs no AI credential; `SCW_AI_API_KEY` only switches the app's own AI features on. + * authorization server. Needs no AI credential; `SCW_AI_API_KEY` only switches the app's own AI features on. Under + * singleVM the API process calls this with the mcp port and `inProcess`: telemetry and the wait for the API are its own. */ -export async function startMcpWorker(options: { port?: number } = {}): Promise { +export async function startMcpWorker(options: { port?: number; inProcess?: boolean } = {}): Promise { const port = options.port ?? Number(env.PORT); if (appConfig.services.mcp.enabled === false) { baseLog.info('MCP server disabled by appConfig'); @@ -22,17 +24,18 @@ export async function startMcpWorker(options: { port?: number } = {}): Promise { + const server: ServerType = serve({ fetch: app.fetch, hostname: '0.0.0.0', port }, () => { baseLog.info(`MCP service listening on port ${port}${hasAiKey ? '' : ' (AI features off)'}`); }); @@ -40,8 +43,7 @@ export async function startMcpWorker(options: { port?: number } = {}): Promise { server.close(); - await stopInvalidationListener(); - await otel.shutdown(); + if (!options.inProcess) await otel.shutdown(); }, log: (msg) => baseLog.info(msg), }); diff --git a/backend/src/modules/me/helpers/get-user-info.ts b/backend/src/modules/me/helpers/get-user-info.ts index 0cd7dc183..3b9d305e8 100644 --- a/backend/src/modules/me/helpers/get-user-info.ts +++ b/backend/src/modules/me/helpers/get-user-info.ts @@ -5,7 +5,7 @@ import type { DbContext, Env } from '#/core/context'; import { devicesTable } from '#/modules/auth/devices-db'; import { identitiesTable } from '#/modules/auth/identities-db'; import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; -import { sessionsTable } from '#/modules/auth/sessions-db'; +import { sessionSafeColumns, sessionsTable } from '#/modules/auth/sessions-db'; import { totpsTable } from '#/modules/auth/totps/totps-db'; import type { sessionSchema } from '#/modules/me/me-schema'; import { TimeSpan } from '#/utils/time-span'; @@ -27,9 +27,7 @@ export const getAuthInfo = async (ctx: DbContext, { userId }: { userId: string } const getOAuth = db .select({ provider: identitiesTable.issuer }) .from(identitiesTable) - .where( - and(eq(identitiesTable.userId, userId), eq(identitiesTable.kind, 'oauth'), eq(identitiesTable.verified, true)), - ); + .where(and(eq(identitiesTable.userId, userId), eq(identitiesTable.kind, 'oauth'), eq(identitiesTable.verified, true))); const [passkeys, totps, oauth] = await Promise.all([getPasskeys, getTotp, getOAuth]); return { passkeys, hasTotp: !!totps.length, oauth }; @@ -45,32 +43,21 @@ export const getUserSessions = async (ctx: Context, userId: string): Promis // Compared in SQL: the columns are timestamps without zone, which JavaScript would parse as local time. const revokedSince = new Date(Date.now() - REVOKED_SESSION_WINDOW.milliseconds()).toISOString(); const getSessions = db - .select() + .select(sessionSafeColumns) .from(sessionsTable) - .where( - and( - eq(sessionsTable.userId, userId), - or(isNull(sessionsTable.revokedAt), gt(sessionsTable.revokedAt, revokedSince)), - ), - ) + .where(and(eq(sessionsTable.userId, userId), or(isNull(sessionsTable.revokedAt), gt(sessionsTable.revokedAt, revokedSince)))) .orderBy(desc(sessionsTable.createdAt)); const windowStart = new Date(Date.now() - NEW_DEVICE_WINDOW.milliseconds()).toISOString(); const oldestFirstSeen = sql`(select min(${devicesTable.firstSeenAt}) from ${devicesTable} where ${devicesTable.userId} = ${userId})`; const getNewDevices = db .select({ deviceIdHash: devicesTable.deviceIdHash }) .from(devicesTable) - .where( - and( - eq(devicesTable.userId, userId), - gt(devicesTable.firstSeenAt, windowStart), - gt(devicesTable.firstSeenAt, oldestFirstSeen), - ), - ); + .where(and(eq(devicesTable.userId, userId), gt(devicesTable.firstSeenAt, windowStart), gt(devicesTable.firstSeenAt, oldestFirstSeen))); const [sessions, newDevices] = await Promise.all([getSessions, getNewDevices]); const newDeviceHashes = new Set(newDevices.map(({ deviceIdHash }) => deviceIdHash)); - return sessions.map(({ secret, ...session }) => ({ + return sessions.map((session) => ({ ...session, isCurrent: session.id === ctx.var.sessionId, isNewDevice: session.deviceIdHash !== null && newDeviceHashes.has(session.deviceIdHash), diff --git a/backend/src/modules/me/me-handlers.ts b/backend/src/modules/me/me-handlers.ts index 33c96e391..c7972302d 100644 --- a/backend/src/modules/me/me-handlers.ts +++ b/backend/src/modules/me/me-handlers.ts @@ -1,15 +1,9 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import type { Env } from '#/core/context'; import { AppError } from '#/core/error'; -import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; import { deleteAuthCookie } from '#/modules/auth/general/helpers/cookie'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; -import { mfaFactorRules } from '#/modules/auth/general/helpers/mfa'; -import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; -import { setUserSession } from '#/modules/auth/general/helpers/session'; -import { readStepUp } from '#/modules/auth/step-up/helpers/step-up'; +import { refuseImpersonation } from '#/modules/auth/step-up/helpers/step-up'; import { getUserSessions } from '#/modules/me/helpers/get-user-info'; -import { findCurrentUser, updateUserMfa } from '#/modules/me/me-queries'; import { meRoutes } from '#/modules/me/me-routes'; import { deleteMyMembershipOp } from '#/modules/me/operations/delete-my-membership'; import { getConnectedAppsOp } from '#/modules/me/operations/get-connected-apps'; @@ -19,6 +13,7 @@ import { getMyInvitationsOp } from '#/modules/me/operations/get-my-invitations'; import { getUploadTokenOp } from '#/modules/me/operations/get-upload-token'; import { revokeConnectedAppOp } from '#/modules/me/operations/revoke-connected-app'; import { revokeMySessionsOp } from '#/modules/me/operations/revoke-my-sessions'; +import { toggleMfaOp } from '#/modules/me/operations/toggle-mfa'; import { updateMeOp } from '#/modules/me/operations/update-me'; import { deleteAccounts } from '#/modules/user/helpers/delete-accounts'; import { defaultHook } from '#/utils/default-hook'; @@ -32,42 +27,9 @@ app.openapi(meRoutes.getMe, async (ctx) => { }); app.openapi(meRoutes.toggleMfa, async (ctx) => { - const { user, session } = ctx.var; - const { mfaRequired } = ctx.req.valid('json'); - - // The guard refused a session that has not stepped up; the factor that proved this one signs the mfa session minted - // below in. Turning MFA on needs both factors enrolled, so a passing step-up always names one. - const { factor } = await readStepUp(session); - - // The flag and the sessions it ends change together, after a factor delete that got the lock first. - const updatedUser = await mfaFactorRules.locked(user.id, async (tx) => { - if (mfaRequired) await mfaFactorRules.assertCanEnable(tx, user.id); - const txCtx = { var: { ...ctx.var, db: tx } }; - const updated = await updateUserMfa(txCtx, { mfaRequired }); - if (updated.mfaRequired) { - // This browser's session gives way to the mfa session minted below; every other regular session ends. - await endSessions(txCtx, { userId: user.id, sessionIds: [ctx.var.sessionId], reason: 'replaced', by: user.id }); - await endSessions(txCtx, { userId: user.id, all: true, type: 'regular', reason: 'mfa_enabled', by: user.id }); - } - return updated; - }); - - await invalidateCache.user(ctx.var.db, user.id); - - if (updatedUser.mfaRequired && factor) { - // Clear session cookie to enforce fresh login - deleteAuthCookie(ctx, 'session'); - - await setUserSession(ctx, user, factor, 'mfa'); - } - - sendAccountSecurityEmail(user, mfaRequired ? 'mfa-enabled' : 'mfa-disabled'); - - // Re-select to include the user_counters subqueries - const userWithActivity = await findCurrentUser(ctx); - - return ctx.json(userWithActivity, 200); + const data = await toggleMfaOp(ctx, mfaRequired); + return ctx.json(data, 200); }); app.openapi(meRoutes.getMyAuth, async (ctx) => { @@ -82,6 +44,8 @@ app.openapi(meRoutes.getMyInvitations, async (ctx) => { }); app.openapi(meRoutes.revokeMySessions, async (ctx) => { + // The admin acts as the user, never on the user's sessions. + refuseImpersonation(ctx.var.session); const { ids } = ctx.req.valid('json'); const { data, rejectedIds, signedOut } = await revokeMySessionsOp(ctx, ids); if (signedOut) deleteAuthCookie(ctx, 'session'); diff --git a/backend/src/modules/me/me-mocks.ts b/backend/src/modules/me/me-mocks.ts index c3bfa3041..4373a7d4a 100644 --- a/backend/src/modules/me/me-mocks.ts +++ b/backend/src/modules/me/me-mocks.ts @@ -6,10 +6,7 @@ import { resourceUri } from '#/modules/oauth-server/resources'; import { mockUserResponse } from '#/modules/user/user-mocks'; import { mockChannelBase } from '#/schemas/entity-base-mocks'; -export const mockMeResponse = (key = 'me:default'): MeResponse => ({ - user: mockUserResponse(`${key}:user`), - isSystemAdmin: false, -}); +export const mockMeResponse = (key = 'me:default'): MeResponse => ({ user: mockUserResponse(`${key}:user`), isSystemAdmin: false }); export const mockMeAuthResponse = (key = 'me-auth:default'): MeAuthResponse => withFakerSeed(key, () => { @@ -59,12 +56,7 @@ export const mockUploadTokenResponse = (key = 'upload-token:default'): UploadTok sub: mockNanoid(), s3: true, signature: faker.string.alphanumeric(64), - params: { - auth: { - key: `uploads/${mockNanoid()}`, - expires: expiresAt.toISOString(), - }, - }, + params: { auth: { key: `uploads/${mockNanoid()}`, expires: expiresAt.toISOString() } }, }; }); diff --git a/backend/src/modules/me/me-queries.ts b/backend/src/modules/me/me-queries.ts index 6b30fd430..1689793f0 100644 --- a/backend/src/modules/me/me-queries.ts +++ b/backend/src/modules/me/me-queries.ts @@ -17,10 +17,10 @@ interface UpsertLastStartedOpts { /** Upsert the lastStartedAt counter for a user (avoids CDC noise on users table). */ export const upsertLastStarted = async (ctx: UserContext, { lastStartedAt }: UpsertLastStartedOpts) => { const { db, userId } = ctx.var; - return db.insert(userCountersTable).values({ userId, lastStartedAt }).onConflictDoUpdate({ - target: userCountersTable.userId, - set: { lastStartedAt }, - }); + return db + .insert(userCountersTable) + .values({ userId, lastStartedAt }) + .onConflictDoUpdate({ target: userCountersTable.userId, set: { lastStartedAt } }); }; /** Select a user by ID with activity timestamps (from user_counters). */ @@ -52,9 +52,7 @@ export const updateMe = async (ctx: UserContext, { values }: UpdateMeOpts) => { const updateData = { ...rest, - ...(userFlags && { - userFlags: sql`${usersTable.userFlags} || ${JSON.stringify(userFlags)}::jsonb`, - }), + ...(userFlags && { userFlags: sql`${usersTable.userFlags} || ${JSON.stringify(userFlags)}::jsonb` }), }; return db.update(usersTable).set(updateData).where(eq(usersTable.id, userId)); @@ -66,9 +64,7 @@ interface DeleteMyMembershipOpts { export const deleteMyMembership = async (ctx: UserContext, { channelId }: DeleteMyMembershipOpts) => { const { db, userId } = ctx.var; - return db - .delete(membershipsTable) - .where(and(eq(membershipsTable.userId, userId), eq(membershipsTable.channelId, channelId))); + return db.delete(membershipsTable).where(and(eq(membershipsTable.userId, userId), eq(membershipsTable.channelId, channelId))); }; interface FindPendingInvitationsOpts { @@ -85,10 +81,7 @@ export const findPendingInvitations = async (ctx: DbContext, { userId }: FindPen const channelBaseSelect = pick(cols, keys); return db - .select({ - entity: channelBaseSelect, - inactiveMembership: inactiveMembershipsTable, - }) + .select({ entity: channelBaseSelect, inactiveMembership: inactiveMembershipsTable }) .from(inactiveMembershipsTable) .innerJoin(entityTable, eq(entityTable.id, inactiveMembershipsTable.channelId)) .where( diff --git a/backend/src/modules/me/me-routes.ts b/backend/src/modules/me/me-routes.ts index 61f881ce7..89b7f5d4f 100644 --- a/backend/src/modules/me/me-routes.ts +++ b/backend/src/modules/me/me-routes.ts @@ -1,6 +1,6 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; -import { crossTenantGuard, noImpersonationGuard, stepUpGuard, userGuard } from '#/middlewares/guard'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { stepUpGuard, userGuard } from '#/middlewares/guard'; import { bulkPointsLimiter, singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; import { connectedAppSchema, @@ -15,266 +15,124 @@ import { import { membershipBaseSchema } from '#/modules/memberships/memberships-schema'; import { mockUserResponse } from '#/modules/user/user-mocks'; import { userFlagsSchema, userSchema, userUpdateBodySchema } from '#/modules/user/user-schema'; -import { - batchResponseSchema, - entityIdParamSchema, - entityWithTypeQuerySchema, - errorResponseRefs, - idsBodySchema, - paginationSchema, -} from '#/schemas'; -import { - mockConnectedApp, - mockMeAuthResponse, - mockMeResponse, - mockPaginatedInvitationsResponse, - mockUploadTokenResponse, -} from './me-mocks'; +import { batchResponseSchema, entityIdParamSchema, entityWithTypeQuerySchema, idsBodySchema, paginationSchema } from '#/schemas'; +import { mockConnectedApp, mockMeAuthResponse, mockMeResponse, mockPaginatedInvitationsResponse, mockUploadTokenResponse } from './me-mocks'; -const meRoutes = { - getMe: createXRoute({ - operationId: 'getMe', +const meRoutes = createXRoutes(['me', 'cella'], { + getMe: xRoute({ method: 'get', path: '/', xGuard: [userGuard], - tags: ['me', 'cella'], summary: 'Get self', description: 'Returns the current user.', - responses: { - 200: { - description: 'User', - content: { - 'application/json': { - schema: meSchema, - example: mockMeResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('User', meSchema, mockMeResponse()) }, }), - getMyInvitations: createXRoute({ - operationId: 'getMyInvitations', + getMyInvitations: xRoute({ method: 'get', path: '/invitations', - xGuard: [userGuard, crossTenantGuard], - tags: ['me', 'cella'], + xGuard: [userGuard], summary: 'Get list of invitations', description: 'Returns a list of pending memberships with entity data.', - responses: { - 200: { - description: 'Invitations pending', - content: { - 'application/json': { - schema: paginationSchema(mePendingInvitationSchema), - example: mockPaginatedInvitationsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Invitations pending', paginationSchema(mePendingInvitationSchema), mockPaginatedInvitationsResponse()) }, }), - updateMe: createXRoute({ - operationId: 'updateMe', + updateMe: xRoute({ method: 'put', path: '/', xGuard: [userGuard], xRateLimiter: [singlePointsLimiter], - tags: ['me', 'cella'], summary: 'Update self', description: 'Updates the current user.', - request: { - body: { - required: true, - content: { - 'application/json': { - schema: userUpdateBodySchema.extend({ userFlags: userFlagsSchema.partial().optional() }), - }, - }, - }, - }, - responses: { - 200: { - description: 'User', - content: { 'application/json': { schema: userSchema, example: mockUserResponse() } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(userUpdateBodySchema.extend({ userFlags: userFlagsSchema.partial().optional() })) }, + responses: { 200: json('User', userSchema, mockUserResponse()) }, }), - deleteMe: createXRoute({ - operationId: 'deleteMe', + deleteMe: xRoute({ method: 'delete', path: '/', xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['me', 'cella'], summary: 'Delete self', description: "Deletes the current user. This also removes the user's memberships (cascade) and sets references to the user to null where applicable.", - responses: { - 204: { description: 'User deleted' }, - ...errorResponseRefs, - }, + responses: { 204: { description: 'User deleted' } }, }), - getMyAuth: createXRoute({ - operationId: 'getMyAuth', + getMyAuth: xRoute({ method: 'get', path: '/auth', xGuard: [userGuard], - tags: ['me', 'cella'], summary: 'Get auth data', - description: - 'Returns authentication related data of current user, including sessions, passkeys, TOTP and the enabled sign-in providers.', - responses: { - 200: { - description: 'User sign-up info', - content: { 'application/json': { schema: meAuthDataSchema, example: mockMeAuthResponse() } }, - }, - ...errorResponseRefs, - }, + description: 'Returns authentication related data of current user, including sessions, passkeys, TOTP and the enabled sign-in providers.', + responses: { 200: json('User sign-up info', meAuthDataSchema, mockMeAuthResponse()) }, }), - revokeMySessions: createXRoute({ - operationId: 'revokeMySessions', + revokeMySessions: xRoute({ method: 'delete', path: '/sessions', - xGuard: [userGuard, noImpersonationGuard], + xGuard: [userGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['me', 'cella'], summary: 'Revoke sessions', description: 'Revokes sessions of the current user by id. The rows stay for the audit trail and the sessions list shows them as revoked for 30 days. Revoking the current session signs out.', - request: { - body: { - required: true, - content: { 'application/json': { schema: idsBodySchema() } }, - }, - }, + request: { body: jsonBody(idsBodySchema()) }, - responses: { - 200: { - description: 'Sessions were revoked', - content: { 'application/json': { schema: batchResponseSchema(sessionBaseSchema) } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Sessions were revoked', batchResponseSchema(sessionBaseSchema)) }, }), - deleteMyMembership: createXRoute({ - operationId: 'deleteMyMembership', + deleteMyMembership: xRoute({ method: 'delete', path: '/leave', - xGuard: [userGuard, crossTenantGuard], + xGuard: [userGuard], xRateLimiter: [singlePointsLimiter], - tags: ['me', 'cella'], summary: 'Leave entity', description: 'Removes the current user from an entity they are a member of.', request: { query: entityWithTypeQuerySchema }, - responses: { - 204: { - description: 'Membership removed', - }, - ...errorResponseRefs, - }, + responses: { 204: { description: 'Membership removed' } }, }), - getUploadToken: createXRoute({ - operationId: 'getUploadToken', + getUploadToken: xRoute({ method: 'get', path: '/upload-token', xGuard: [userGuard], - tags: ['me', 'cella'], summary: 'Get upload token', description: 'Generates and returns an upload token for uploading files or images, scoped to the current user and organization. The upload template decides the bucket: avatars, covers and newsletter images are public, attachments private. Only a system admin gets a newsletter image token.', request: { query: uploadTokenQuerySchema }, - responses: { - 200: { - description: 'Upload token with a scope for a user or organization', - content: { 'application/json': { schema: uploadTokenSchema, example: mockUploadTokenResponse() } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Upload token with a scope for a user or organization', uploadTokenSchema, mockUploadTokenResponse()) }, }), - toggleMfa: createXRoute({ - operationId: 'toggleMfa', + toggleMfa: xRoute({ method: 'put', path: '/mfa', xGuard: [userGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['me', 'cella'], summary: 'Toggle MFA', - description: - 'Enable or disable multifactor authentication for the current user. Needs a session stepped up with a passkey or TOTP.', - request: { - body: { required: true, content: { 'application/json': { schema: toggleMfaBodySchema } } }, - }, - responses: { - 200: { - description: 'User', - content: { 'application/json': { schema: userSchema, example: mockUserResponse() } }, - }, - ...errorResponseRefs, - }, + description: 'Enable or disable multifactor authentication for the current user. Needs a session stepped up with a passkey or TOTP.', + request: { body: jsonBody(toggleMfaBodySchema) }, + responses: { 200: json('User', userSchema, mockUserResponse()) }, }), - getMyMemberships: createXRoute({ - operationId: 'getMyMemberships', + getMyMemberships: xRoute({ method: 'get', path: '/memberships', xGuard: [userGuard], - tags: ['me', 'cella'], summary: 'Get my memberships', description: 'Returns all memberships for the current user across all channel entities.', - responses: { - 200: { - description: 'User memberships', - content: { - 'application/json': { - schema: z.object({ items: z.array(membershipBaseSchema) }), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('User memberships', z.object({ items: z.array(membershipBaseSchema) })) }, }), - getConnectedApps: createXRoute({ - operationId: 'getConnectedApps', + getConnectedApps: xRoute({ method: 'get', path: '/connected-apps', xGuard: [userGuard], - tags: ['me', 'cella'], summary: 'Get connected apps', description: 'Lists the OAuth clients the user consented to (MCP clients, registered apps) with their scopes.', responses: { - 200: { - description: 'Connected apps', - content: { - 'application/json': { - schema: z.object({ items: z.array(connectedAppSchema) }), - example: { items: [mockConnectedApp()] }, - }, - }, - }, - ...errorResponseRefs, + 200: json('Connected apps', z.object({ items: z.array(connectedAppSchema) }), { items: [mockConnectedApp()] }), }, }), - revokeConnectedApp: createXRoute({ - operationId: 'revokeConnectedApp', + revokeConnectedApp: xRoute({ method: 'delete', path: '/connected-apps/{id}', xGuard: [userGuard], xRateLimiter: [singlePointsLimiter], - tags: ['me', 'cella'], summary: 'Revoke connected app', description: 'Revokes a consent: the grant and every token issued under it are deleted.', request: { params: entityIdParamSchema }, - responses: { - 200: { - description: 'Consent was revoked', - content: { - 'application/json': { schema: batchResponseSchema() }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Consent was revoked', batchResponseSchema()) }, }), -}; +}); export { meRoutes }; diff --git a/backend/src/modules/me/me-schema.ts b/backend/src/modules/me/me-schema.ts index 1f1315867..b711e1383 100644 --- a/backend/src/modules/me/me-schema.ts +++ b/backend/src/modules/me/me-schema.ts @@ -8,7 +8,7 @@ import { inactiveMembershipSchema } from '#/modules/memberships/memberships-sche import { enabledOAuthProvidersSchema, userSchema } from '#/modules/user/user-schema'; import { validUuidSchema } from '#/schemas'; import { channelBaseSchema } from '#/schemas/entity-base'; -import { mockMeAuthResponse, mockMeResponse, mockUploadTokenResponse } from './me-mocks'; +import { mockConnectedApp, mockMeAuthResponse, mockMeResponse, mockUploadTokenResponse } from './me-mocks'; /** A session row as stored, secret omitted: what a revoke returns. */ export const sessionBaseSchema = createSelectSchema(sessionsTable); @@ -16,9 +16,7 @@ export const sessionBaseSchema = createSelectSchema(sessionsTable); /** A session as the account page lists it. */ export const sessionSchema = sessionBaseSchema.extend({ isCurrent: z.boolean(), - isNewDevice: z - .boolean() - .openapi({ description: 'The browser was first seen recently and is not the first one known.' }), + isNewDevice: z.boolean().openapi({ description: 'The browser was first seen recently and is not the first one known.' }), }); export const meSchema = z @@ -47,19 +45,12 @@ export const meAuthDataSchema = z export const uploadTokenSchema = z .object({ - publicBucket: z - .boolean() - .openapi({ description: 'Whether the upload is stored public-read in the public bucket; the template decides.' }), + publicBucket: z.boolean().openapi({ description: 'Whether the upload is stored public-read in the public bucket; the template decides.' }), sub: z.string(), s3: z.boolean(), signature: z.string().nullable(), params: z - .object({ - auth: z.object({ - key: z.string(), - expires: z.string().optional(), - }), - }) + .object({ auth: z.object({ key: z.string(), expires: z.string().optional() }) }) .catchall(z.any()) .nullable(), }) @@ -71,17 +62,11 @@ export const uploadTokenSchema = z export type { MeAuthResponse, MeResponse, UploadTokenResponse } from './types'; -export const uploadTokenQuerySchema = z.object({ - organizationId: validUuidSchema.optional(), - templateId: z.enum(appConfig.uploadTemplateIds), -}); +export const uploadTokenQuerySchema = z.object({ organizationId: validUuidSchema.optional(), templateId: z.enum(appConfig.uploadTemplateIds) }); export const toggleMfaBodySchema = z.object({ mfaRequired: z.boolean() }); -export const mePendingInvitationSchema = z.object({ - entity: channelBaseSchema, - inactiveMembership: inactiveMembershipSchema, -}); +export const mePendingInvitationSchema = z.object({ entity: channelBaseSchema, inactiveMembership: inactiveMembershipSchema }); /** A consent the user gave to an OAuth client, as the account page lists it. */ export const connectedAppSchema = z @@ -96,6 +81,7 @@ export const connectedAppSchema = z }) .openapi('ConnectedApp', { description: 'An OAuth consent (grant) of the current user.', + example: mockConnectedApp(), 'x-tags': schemaTags('data', 'me', 'cella'), }); diff --git a/backend/src/modules/me/operations/delete-my-membership.ts b/backend/src/modules/me/operations/delete-my-membership.ts index e1a0eb9c7..d98b8f40c 100644 --- a/backend/src/modules/me/operations/delete-my-membership.ts +++ b/backend/src/modules/me/operations/delete-my-membership.ts @@ -8,13 +8,10 @@ import { deleteMyMembership } from '#/modules/me/me-queries'; import { log } from '#/utils/logger'; export async function deleteMyMembershipOp(ctx: UserContext, entityType: ChannelEntityType, entityId: string) { - const user = ctx.var.user; - const entity = await resolveEntity(ctx, { entityType, identifier: entityId }); if (!entity) throw new AppError(404, 'not_found', 'warn', { entityType }); await deleteMyMembership({ var: { ...ctx.var, db: baseDb } }, { channelId: entity.id }); - - await invalidateCache.user(baseDb, user.id); + invalidateCache.user(ctx.var.user.id); log.info('User left entity'); } diff --git a/backend/src/modules/me/operations/get-my-auth.ts b/backend/src/modules/me/operations/get-my-auth.ts index 2e32870de..53513adab 100644 --- a/backend/src/modules/me/operations/get-my-auth.ts +++ b/backend/src/modules/me/operations/get-my-auth.ts @@ -18,9 +18,7 @@ export async function getMyAuthOp(ctx: UserContext, { sessions }: GetMyAuthOpts) const { oauth, ...restInfo } = authInfo; const enabledOAuth = oauth .map(({ provider }) => provider) - .filter((provider): provider is EnabledOAuthProvider => - appConfig.enabledOAuthProviders.includes(provider as EnabledOAuthProvider), - ); + .filter((provider): provider is EnabledOAuthProvider => appConfig.enabledOAuthProviders.includes(provider as EnabledOAuthProvider)); return { ...restInfo, enabledOAuth, sessions }; } diff --git a/backend/src/modules/me/operations/get-my-invitations.ts b/backend/src/modules/me/operations/get-my-invitations.ts index 774a0c916..aac7180e7 100644 --- a/backend/src/modules/me/operations/get-my-invitations.ts +++ b/backend/src/modules/me/operations/get-my-invitations.ts @@ -9,10 +9,7 @@ export async function getMyInvitationsOp(ctx: UserContext) { const allMemberships = rawItems.map((item) => item.inactiveMembership); const populatedMemberships = await withAuditUsers(ctx, allMemberships); - const items = rawItems.map((item, i) => ({ - ...item, - inactiveMembership: populatedMemberships[i], - })); + const items = rawItems.map((item, i) => ({ ...item, inactiveMembership: populatedMemberships[i] })); const total = items.length; return { items, total }; diff --git a/backend/src/modules/me/operations/get-upload-token.ts b/backend/src/modules/me/operations/get-upload-token.ts index 6b3771208..48a92cfb8 100644 --- a/backend/src/modules/me/operations/get-upload-token.ts +++ b/backend/src/modules/me/operations/get-upload-token.ts @@ -49,8 +49,6 @@ export function getUploadTokenOp(ctx: UserContext, { organizationId, templateId return { sub, publicBucket, s3: !!env.S3_ACCESS_KEY_ID, params, signature }; } catch (error) { if (error instanceof AppError) throw error; - throw new AppError(500, 'auth_key_not_found', 'error', { - ...(error instanceof Error ? { originalError: error } : {}), - }); + throw new AppError(500, 'auth_key_not_found', 'error', error instanceof Error ? { originalError: error } : {}); } } diff --git a/backend/src/modules/me/operations/revoke-my-sessions.ts b/backend/src/modules/me/operations/revoke-my-sessions.ts index 0f543a0c6..4c37e2442 100644 --- a/backend/src/modules/me/operations/revoke-my-sessions.ts +++ b/backend/src/modules/me/operations/revoke-my-sessions.ts @@ -1,27 +1,20 @@ import type { UserContext } from '#/core/context'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; +import { revokeSessions } from '#/modules/auth/general/helpers/revoke-sessions'; /** * Revokes the user's own sessions by id. Revoking the session behind this request is a sign-out; the others end from - * this session. Rows stay for the sessions list; connections bound to them close through `endSessions`. Ids of + * this session. Rows stay for the sessions list; connections bound to them close through `revokeSessions`. Ids of * sessions the user does not hold, or that ended already, come back rejected. */ export async function revokeMySessionsOp(ctx: UserContext, ids: string[]) { const { user, sessionId: currentSessionId } = ctx.var; + const otherIds = ids.filter((id) => id !== currentSessionId); + const ownIds = ids.filter((id) => id === currentSessionId); + const [others, own] = await Promise.all([ - endSessions(ctx, { - userId: user.id, - sessionIds: ids.filter((id) => id !== currentSessionId), - reason: 'other_session', - by: user.id, - }), - endSessions(ctx, { - userId: user.id, - sessionIds: ids.filter((id) => id === currentSessionId), - reason: 'sign_out', - by: user.id, - }), + revokeSessions(ctx, { userId: user.id, sessionIds: otherIds, reason: 'other_session', by: user.id }), + revokeSessions(ctx, { userId: user.id, sessionIds: ownIds, reason: 'sign_out', by: user.id }), ]); const data = [...others, ...own]; const revokedIds = data.map((session) => session.id); diff --git a/backend/src/modules/me/operations/toggle-mfa.ts b/backend/src/modules/me/operations/toggle-mfa.ts new file mode 100644 index 000000000..a9f2b6270 --- /dev/null +++ b/backend/src/modules/me/operations/toggle-mfa.ts @@ -0,0 +1,50 @@ +import type { Context } from 'hono'; +import type { Env } from '#/core/context'; +import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; +import { deleteAuthCookie } from '#/modules/auth/general/helpers/cookie'; +import { revokeSessions } from '#/modules/auth/general/helpers/revoke-sessions'; +import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { setUserSession } from '#/modules/auth/general/helpers/session'; +import { mfaFactorRules } from '#/modules/auth/mfa/operations/factor-rules'; +import { readStepUp } from '#/modules/auth/step-up/helpers/step-up'; +import { findCurrentUser, updateUserMfa } from '#/modules/me/me-queries'; + +/** + * Turns MFA on or off for the signed-in user. Turning it on ends every other regular session and replaces this + * browser's with an mfa session, signed by the factor that stepped this session up. + * @returns The user as `getMe` returns it, with the new MFA flag and the new session's sign-in time. + */ +export async function toggleMfaOp(ctx: Context, mfaRequired: boolean) { + const { user, session } = ctx.var; + + // The guard refused a session that has not stepped up; the factor that proved this one signs the mfa session minted + // below in. Turning MFA on needs both factors enrolled, so a passing step-up always names one. + const { factor } = await readStepUp(session); + + // The flag and the sessions it ends change together, after a factor delete that got the lock first. + const updatedUser = await mfaFactorRules.locked(user.id, async (tx) => { + if (mfaRequired) await mfaFactorRules.assertCanEnable(tx, user.id); + const txCtx = { var: { ...ctx.var, db: tx } }; + const updated = await updateUserMfa(txCtx, { mfaRequired }); + if (updated.mfaRequired) { + // This browser's session gives way to the mfa session minted below; every other regular session ends. + await revokeSessions(txCtx, { userId: user.id, sessionIds: [ctx.var.sessionId], reason: 'replaced', by: user.id }); + await revokeSessions(txCtx, { userId: user.id, all: true, type: 'regular', reason: 'mfa_enabled', by: user.id }); + } + return updated; + }); + + invalidateCache.user(user.id); + + if (updatedUser.mfaRequired && factor) { + // Clear session cookie to enforce fresh login + deleteAuthCookie(ctx, 'session'); + + await setUserSession(ctx, user, factor, 'mfa'); + } + + sendAccountSecurityEmail(user, mfaRequired ? 'mfa-enabled' : 'mfa-disabled'); + + // Re-select to include the user_counters subqueries + return findCurrentUser(ctx); +} diff --git a/backend/src/modules/me/operations/update-me.ts b/backend/src/modules/me/operations/update-me.ts index e4960b846..a0ff6ccb5 100644 --- a/backend/src/modules/me/operations/update-me.ts +++ b/backend/src/modules/me/operations/update-me.ts @@ -41,7 +41,7 @@ export async function updateMeOp(ctx: UserContext, input: UpdateMeInput) { }; await updateMe(ctx, { values: updateData as UpdateMeOpts['values'] }); - await invalidateCache.user(ctx.var.db, user.id); + invalidateCache.user(user.id); const userWithActivity = await findCurrentUser(ctx); return userWithActivity; diff --git a/backend/src/modules/me/types.ts b/backend/src/modules/me/types.ts index dc8d0f8cd..c54cd06f1 100644 --- a/backend/src/modules/me/types.ts +++ b/backend/src/modules/me/types.ts @@ -11,11 +11,7 @@ export interface MeResponse { } /** Session for auth data response (token already omitted by SessionModel) */ -export type MeSession = Omit & { - expiresAt: string; - isCurrent: boolean; - isNewDevice: boolean; -}; +export type MeSession = Omit & { expiresAt: string; isCurrent: boolean; isNewDevice: boolean }; export interface MeAuthResponse { enabledOAuth: EnabledOAuthProvider[]; @@ -29,11 +25,5 @@ export interface UploadTokenResponse { sub: string; s3: boolean; signature: string; - params: { - auth: { - key: string; - expires?: string; - }; - [key: string]: unknown; - }; + params: { auth: { key: string; expires?: string }; [key: string]: unknown }; } diff --git a/backend/src/modules/memberships/helpers/deferred-invites.ts b/backend/src/modules/memberships/helpers/deferred-invites.ts index 8627d2b3e..269f04e3f 100644 --- a/backend/src/modules/memberships/helpers/deferred-invites.ts +++ b/backend/src/modules/memberships/helpers/deferred-invites.ts @@ -42,10 +42,7 @@ export async function dispatchDeferredInvites(ctx: UserContext, { channelIds }: for (const group of groups.values()) { const { channelType, channelId, role } = group[0]; - const entity = await resolveEntity(ctx, { - entityType: channelType as ChannelEntityType, - identifier: channelId, - }); + const entity = await resolveEntity(ctx, { entityType: channelType as ChannelEntityType, identifier: channelId }); if (!entity) continue; const invited: InvitedAddress[] = []; diff --git a/backend/src/modules/memberships/helpers/invitation-mail.ts b/backend/src/modules/memberships/helpers/invitation-mail.ts index c3d094d04..01e723a44 100644 --- a/backend/src/modules/memberships/helpers/invitation-mail.ts +++ b/backend/src/modules/memberships/helpers/invitation-mail.ts @@ -1,14 +1,11 @@ import { appConfig, type ChannelEntityType, type EntityRole } from 'shared'; import type { DbContext } from '#/core/context'; import { mailer } from '#/lib/mailer'; +import { tokenLinkUrl } from '#/modules/auth/tokens/token-policies'; import { findAccountLanguages } from '#/modules/memberships/memberships-queries'; import { slugFromEmail } from '#/utils/slug-from-email'; import { memberAddedEmail, memberInviteEmail, memberInviteWithTokenEmail, systemInviteEmail } from '../../../../emails'; -/** The link an emailed invitation token opens. */ -export const invitationTokenLink = (rawToken: string) => - `${appConfig.backendAuthUrl}/invoke-token/invitation/${rawToken}`; - export interface InvitedAddress { email: string; /** The account holding the address, when one does: it reads the mail in its own language. */ @@ -19,7 +16,7 @@ export interface InvitedAddress { interface InvitationMailOpts { /** The inviter: named in the mail, and replies reach them. */ - sender: { name: string; thumbnailUrl: string | null; email?: string }; + sender: { name: string; email?: string }; /** The invited channel and role; a system invitation names none. */ channel?: { type: ChannelEntityType; slug: string; name: string; role: EntityRole }; /** Whose default language an address without an account reads. */ @@ -38,18 +35,17 @@ interface InvitationMailOpts { export async function sendInvitationMails(ctx: DbContext, opts: InvitationMailOpts): Promise { const { sender, channel, organization, invited, added = [] } = opts; - const languages = await findAccountLanguages(ctx, { - userIds: [...invited, ...added].flatMap(({ userId }) => (userId ? [userId] : [])), - }); + const userIds = [...invited, ...added].flatMap(({ userId }) => (userId ? [userId] : [])); + const languages = await findAccountLanguages(ctx, { userIds }); const recipient = ({ email, userId }: InvitedAddress) => ({ email, lng: (userId && languages.get(userId)) || organization?.defaultLanguage || appConfig.defaultLanguage, name: slugFromEmail(email), }); const withToken = invited.flatMap((address) => - address.rawToken ? [{ ...recipient(address), inviteLink: invitationTokenLink(address.rawToken) }] : [], + address.rawToken ? [{ ...recipient(address), inviteLink: tokenLinkUrl('invitation', address.rawToken) }] : [], ); - const senderProps = { senderName: sender.name, senderThumbnailUrl: sender.thumbnailUrl }; + const senderProps = { senderName: sender.name }; if (!channel) { if (withToken.length) await mailer.prepareEmails(systemInviteEmail, senderProps, withToken, sender.email); @@ -58,9 +54,7 @@ export async function sendInvitationMails(ctx: DbContext, opts: InvitationMailOp const statics = { ...senderProps, entityName: channel.name, role: channel.role }; const page = `${appConfig.frontendUrl}/${channel.type}/${channel.slug}`; - const withoutToken = invited.flatMap((address) => - address.rawToken ? [] : [{ ...recipient(address), memberInviteLink: page }], - ); + const withoutToken = invited.flatMap((address) => (address.rawToken ? [] : [{ ...recipient(address), memberInviteLink: page }])); const addedRecipients = added.map((address) => ({ ...recipient(address), entityLink: page })); if (withToken.length) await mailer.prepareEmails(memberInviteWithTokenEmail, statics, withToken, sender.email); diff --git a/backend/src/modules/memberships/helpers/member-counts.ts b/backend/src/modules/memberships/helpers/member-counts.ts index 203f7d1fe..a652c02b9 100644 --- a/backend/src/modules/memberships/helpers/member-counts.ts +++ b/backend/src/modules/memberships/helpers/member-counts.ts @@ -30,17 +30,10 @@ const memberStatTable = (productType: MemberStatProductType): MemberStatTable => entityTables[productType as keyof typeof entityTables] as unknown as MemberStatTable; /** Channel types a member row can carry a membership count for (all sub-organization channels). */ -const memberStatChannelTypes = hierarchy - .getOrderedDescendants('organization') - .filter((type): type is ChannelEntityType => isChannel(type)); +const memberStatChannelTypes = hierarchy.getOrderedDescendants('organization').filter((type): type is ChannelEntityType => isChannel(type)); /** Rows a member gets credited for: live, published rows they created within the viewed scope. */ -const liveAuthoredWhere = ( - productType: MemberStatProductType, - entityType: ChannelEntityType, - entityId: string, - organizationId: string, -) => { +const liveAuthoredWhere = (productType: MemberStatProductType, entityType: ChannelEntityType, entityId: string, organizationId: string) => { const t = memberStatTable(productType); const scope: (SQL | undefined)[] = [ eq(t.createdBy, usersTable.id), @@ -89,9 +82,7 @@ export const memberCountsSelect = (entityType: ChannelEntityType, entityId: stri }); return { - memberships: sql< - Partial> - >`json_build_object(${sql.join(membershipPairs, sql`, `)})`, + memberships: sql>>`json_build_object(${sql.join(membershipPairs, sql`, `)})`, products: sql>`json_build_object(${sql.join(productPairs, sql`, `)})`, activity: sql>`json_build_object(${sql.join(activityPairs, sql`, `)})`, }; diff --git a/backend/src/modules/memberships/helpers/membership-helpers.ts b/backend/src/modules/memberships/helpers/membership-helpers.ts index 69169805b..a42053def 100644 --- a/backend/src/modules/memberships/helpers/membership-helpers.ts +++ b/backend/src/modules/memberships/helpers/membership-helpers.ts @@ -116,12 +116,7 @@ export const insertMemberships = async ( assignedCounts.set(userId, alreadyAssigned + 1); - const baseMembership = { - userId, - role, - createdBy, - displayOrder: nextOrder, - } as const; + const baseMembership = { userId, role, createdBy, displayOrder: nextOrder } as const; return { targetEntitiesIdColumnKeys, baseMembership, entity, extraFields: info.extraFields }; }); @@ -173,26 +168,20 @@ export const insertMemberships = async ( }) .filter((row): row is NonNullable => row !== null); - const targetRows: InsertMembershipModel[] = prepared.map( - ({ baseMembership, targetEntitiesIdColumnKeys, entity, extraFields }) => ({ - ...baseMembership, - tenantId: entity.tenantId, - channelType: entity.entityType, - channelId: entity.id, - ...targetEntitiesIdColumnKeys, - ...extraFields, - }), - ); + const targetRows: InsertMembershipModel[] = prepared.map(({ baseMembership, targetEntitiesIdColumnKeys, entity, extraFields }) => ({ + ...baseMembership, + tenantId: entity.tenantId, + channelType: entity.entityType, + channelId: entity.id, + ...targetEntitiesIdColumnKeys, + ...extraFields, + })); const [insertedTarget] = await Promise.all([ db.insert(membershipsTable).values(targetRows).returning(membershipBaseSelect), - organizationRows.length - ? db.insert(membershipsTable).values(organizationRows).onConflictDoNothing() - : Promise.resolve(), - associatedRows.length - ? db.insert(membershipsTable).values(associatedRows).onConflictDoNothing() - : Promise.resolve(), + organizationRows.length ? db.insert(membershipsTable).values(organizationRows).onConflictDoNothing() : Promise.resolve(), + associatedRows.length ? db.insert(membershipsTable).values(associatedRows).onConflictDoNothing() : Promise.resolve(), ]); if (insertedTarget.length) { diff --git a/backend/src/modules/memberships/memberships-mocks.ts b/backend/src/modules/memberships/memberships-mocks.ts index a1a60316d..98b29d70f 100644 --- a/backend/src/modules/memberships/memberships-mocks.ts +++ b/backend/src/modules/memberships/memberships-mocks.ts @@ -123,10 +123,8 @@ export const mockChannelMembership = ( }; /** Deterministic membership-base fragment. */ -export const mockMembershipBase = ( - key = 'membership-base:default', - options: MockMembershipBaseOptions = {}, -): MembershipBase => withFakerSeed(key, () => generateMembershipBase(options)); +export const mockMembershipBase = (key = 'membership-base:default', options: MockMembershipBaseOptions = {}): MembershipBase => + withFakerSeed(key, () => generateMembershipBase(options)); /** Deterministic stored membership row. */ export const mockMembership = (key = 'membership:default', options: MockMembershipBaseOptions = {}): MembershipModel => @@ -134,23 +132,14 @@ export const mockMembership = (key = 'membership:default', options: MockMembersh const createdAt = mockPastIsoDate(); const base = generateMembershipBase(options); - return { - ...base, - createdAt, - createdBy: base.userId, - updatedAt: createdAt, - updatedBy: null, - }; + return { ...base, createdAt, createdBy: base.userId, updatedAt: createdAt, updatedBy: null }; }); /** Membership response example; its wire shape matches the stored membership shape. */ export const mockMembershipResponse = mockMembership; /** Deterministic stored inactive-membership row. */ -export const mockInactiveMembership = ( - key = 'inactive-membership:default', - options: MockMembershipBaseOptions = {}, -): InactiveMembershipModel => +export const mockInactiveMembership = (key = 'inactive-membership:default', options: MockMembershipBaseOptions = {}): InactiveMembershipModel => withFakerSeed(key, () => { const createdAt = mockPastIsoDate(); const base = generateMembershipBase(options); @@ -173,15 +162,9 @@ export const mockInactiveMembership = ( }); /** Inactive membership wire response with its creator hydrated. */ -export const mockInactiveMembershipResponse = ( - key = 'inactive-membership:default', - options: MockMembershipBaseOptions = {}, -) => { +export const mockInactiveMembershipResponse = (key = 'inactive-membership:default', options: MockMembershipBaseOptions = {}) => { const membership = mockInactiveMembership(key, options); - return { - ...membership, - createdBy: mockUserMinimalBase(`${key}:created-by`, membership.createdBy), - }; + return { ...membership, createdBy: mockUserMinimalBase(`${key}:created-by`, membership.createdBy) }; }; /** Pending-invitation list row: the invited address, role and inviter, with no account fields. */ @@ -190,16 +173,11 @@ export const mockPendingMembershipResponse = (key = 'pending-membership:default' return { id, email, role, createdAt, createdBy }; }; -export const mockPaginatedPendingMembershipsResponse = (count = 2) => - mockPaginated(mockPendingMembershipResponse, count); +export const mockPaginatedPendingMembershipsResponse = (count = 2) => mockPaginated(mockPendingMembershipResponse, count); export const mockMemberResponse = (key = 'member:default') => { const user = mockUserBase(`${key}:user`); - return { - ...user, - lastSeenAt: user.updatedAt, - membership: mockMembershipBase(`${key}:membership`, { userId: user.id }), - }; + return { ...user, lastSeenAt: user.updatedAt, membership: mockMembershipBase(`${key}:membership`, { userId: user.id }) }; }; export const mockPaginatedMembersResponse = (count = 2) => mockPaginated(mockMemberResponse, count); diff --git a/backend/src/modules/memberships/memberships-queries.ts b/backend/src/modules/memberships/memberships-queries.ts index 9d7bd4816..9c37b3f95 100644 --- a/backend/src/modules/memberships/memberships-queries.ts +++ b/backend/src/modules/memberships/memberships-queries.ts @@ -20,10 +20,7 @@ interface CountMembershipsByChannelOpts { channelId: string; } -export const countMembershipsByChannel = async ( - ctx: DbContext, - { channelType, channelId }: CountMembershipsByChannelOpts, -) => { +export const countMembershipsByChannel = async (ctx: DbContext, { channelType, channelId }: CountMembershipsByChannelOpts) => { const { db } = ctx.var; const [{ currentOrgMemberships }] = await db .select({ currentOrgMemberships: count() }) @@ -38,10 +35,7 @@ interface CountPendingInvitesByChannelOpts { } /** Invitations still waiting for an answer; a rejected one is answered. */ -export const countPendingInvitesByChannel = async ( - ctx: DbContext, - { channelType, channelId }: CountPendingInvitesByChannelOpts, -) => { +export const countPendingInvitesByChannel = async (ctx: DbContext, { channelType, channelId }: CountPendingInvitesByChannelOpts) => { const { db } = ctx.var; const [{ pendingInvites }] = await db .select({ pendingInvites: count() }) @@ -66,10 +60,7 @@ interface FindInvitationAccountsOpts { * The accounts behind invited addresses, one row per address an account holds: its primary address, and whether it is * a member of the channel and of the organization. Addresses no account holds have no row. */ -export const findInvitationAccounts = async ( - ctx: OrgContext, - { emails, entityType, entityId }: FindInvitationAccountsOpts, -) => { +export const findInvitationAccounts = async (ctx: OrgContext, { emails, entityType, entityId }: FindInvitationAccountsOpts) => { const { db, organizationId } = ctx.var; const orgMemberships = alias(membershipsTable, 'org_memberships'); @@ -85,19 +76,11 @@ export const findInvitationAccounts = async ( .innerJoin(usersTable, eq(usersTable.id, emailsTable.userId)) .leftJoin( membershipsTable, - and( - eq(membershipsTable.userId, usersTable.id), - eq(membershipsTable.channelType, entityType), - eq(membershipsTable.channelId, entityId), - ), + and(eq(membershipsTable.userId, usersTable.id), eq(membershipsTable.channelType, entityType), eq(membershipsTable.channelId, entityId)), ) .leftJoin( orgMemberships, - and( - eq(orgMemberships.userId, usersTable.id), - eq(orgMemberships.channelType, 'organization'), - eq(orgMemberships.channelId, organizationId), - ), + and(eq(orgMemberships.userId, usersTable.id), eq(orgMemberships.channelType, 'organization'), eq(orgMemberships.channelId, organizationId)), ) .where(inArray(emailsTable.email, emails)); }; @@ -108,10 +91,7 @@ interface FindInvitationsToAddressesOpts { } /** The channel's invitations addressed to exactly these addresses, pending or rejected. */ -export const findInvitationsToAddresses = async ( - ctx: DbContext, - { emails, channelId }: FindInvitationsToAddressesOpts, -) => { +export const findInvitationsToAddresses = async (ctx: DbContext, { emails, channelId }: FindInvitationsToAddressesOpts) => { const { db } = ctx.var; if (!emails.length) return []; return db @@ -135,10 +115,7 @@ interface FindAccountLanguagesOpts { /** Each account's language, for a mail an invited account reads in its own. */ export const findAccountLanguages = async (ctx: DbContext, { userIds }: FindAccountLanguagesOpts) => { if (!userIds.length) return new Map(); - const rows = await ctx.var.db - .select({ id: usersTable.id, language: usersTable.language }) - .from(usersTable) - .where(inArray(usersTable.id, userIds)); + const rows = await ctx.var.db.select({ id: usersTable.id, language: usersTable.language }).from(usersTable).where(inArray(usersTable.id, userIds)); return new Map(rows.map((row) => [row.id, row.language])); }; @@ -147,10 +124,7 @@ interface FindPendingInactiveMembershipsByChannelsOpts { } /** Pending (not rejected) inactive memberships for a set of contexts (deferred-invite dispatch). */ -export const findPendingInactiveMembershipsByChannels = async ( - ctx: DbContext, - { channelIds }: FindPendingInactiveMembershipsByChannelsOpts, -) => { +export const findPendingInactiveMembershipsByChannels = async (ctx: DbContext, { channelIds }: FindPendingInactiveMembershipsByChannelsOpts) => { const { db } = ctx.var; if (!channelIds.length) return []; return db @@ -165,10 +139,7 @@ interface StampInactiveMembershipsRemindedOpts { } /** Stamp remindedAt (last email dispatch) on inactive memberships. */ -export const stampInactiveMembershipsReminded = async ( - ctx: DbContext, - { ids, remindedAt }: StampInactiveMembershipsRemindedOpts, -) => { +export const stampInactiveMembershipsReminded = async (ctx: DbContext, { ids, remindedAt }: StampInactiveMembershipsRemindedOpts) => { const { db } = ctx.var; if (!ids.length) return; return db.update(inactiveMembershipsTable).set({ remindedAt }).where(inArray(inactiveMembershipsTable.id, ids)); @@ -180,10 +151,7 @@ interface UpdateInactiveMembershipTokenOpts { } /** Point an inactive membership at a fresh invitation token (rotation at deferred dispatch). */ -export const updateInactiveMembershipToken = async ( - ctx: DbContext, - { id, tokenId }: UpdateInactiveMembershipTokenOpts, -) => { +export const updateInactiveMembershipToken = async (ctx: DbContext, { id, tokenId }: UpdateInactiveMembershipTokenOpts) => { const { db } = ctx.var; return db.update(inactiveMembershipsTable).set({ tokenId }).where(eq(inactiveMembershipsTable.id, id)); }; @@ -207,10 +175,7 @@ interface FindMembershipsByUserIdsAndChannelOpts { channelId: string; } -export const findMembershipsByUserIdsAndChannel = async ( - ctx: DbContext, - { userIds, channelId }: FindMembershipsByUserIdsAndChannelOpts, -) => { +export const findMembershipsByUserIdsAndChannel = async (ctx: DbContext, { userIds, channelId }: FindMembershipsByUserIdsAndChannelOpts) => { const { db } = ctx.var; return db .select(membershipBaseSelect) @@ -224,9 +189,7 @@ interface DeleteMembershipsByIdsOpts { export const deleteMembershipsByIds = async (ctx: OrgContext, { ids }: DeleteMembershipsByIdsOpts) => { const { db, organizationId } = ctx.var; - return db - .delete(membershipsTable) - .where(and(inArray(membershipsTable.id, ids), eq(membershipsTable.organizationId, organizationId))); + return db.delete(membershipsTable).where(and(inArray(membershipsTable.id, ids), eq(membershipsTable.organizationId, organizationId))); }; interface UpdateMembershipOpts { @@ -251,10 +214,11 @@ interface InsertInactiveMembershipsOpts { /** Insert inactive memberships in bulk, ignoring conflicts. */ export const insertInactiveMemberships = async (ctx: DbContext, { memberships }: InsertInactiveMembershipsOpts) => { const { db } = ctx.var; - return db.insert(inactiveMembershipsTable).values(memberships).onConflictDoNothing().returning({ - id: inactiveMembershipsTable.id, - email: inactiveMembershipsTable.email, - }); + return db + .insert(inactiveMembershipsTable) + .values(memberships) + .onConflictDoNothing() + .returning({ id: inactiveMembershipsTable.id, email: inactiveMembershipsTable.email }); }; interface FindInactiveMembershipByIdOpts { @@ -277,13 +241,7 @@ export const findInactiveMembershipForUser = async (ctx: UserContext, { id }: Fi const [membership] = await db .select() .from(inactiveMembershipsTable) - .where( - and( - eq(inactiveMembershipsTable.id, id), - eq(inactiveMembershipsTable.userId, userId), - isNull(inactiveMembershipsTable.rejectedAt), - ), - ) + .where(and(eq(inactiveMembershipsTable.id, id), eq(inactiveMembershipsTable.userId, userId), isNull(inactiveMembershipsTable.rejectedAt))) .limit(1); return membership; }; @@ -294,10 +252,7 @@ export const findInactiveMembershipForUser = async (ctx: UserContext, { id }: Fi * and a rejected one is never revived. */ const claimableBy = (userId: string) => - and( - isNull(inactiveMembershipsTable.rejectedAt), - or(isNull(inactiveMembershipsTable.userId), eq(inactiveMembershipsTable.userId, userId)), - ); + and(isNull(inactiveMembershipsTable.rejectedAt), or(isNull(inactiveMembershipsTable.userId), eq(inactiveMembershipsTable.userId, userId))); /** Token path: the invitation is answerable by this user when {@link claimableBy} holds. */ export const findClaimableInactiveMembership = async (ctx: UserContext, { id }: FindInactiveMembershipForUserOpts) => { @@ -337,10 +292,7 @@ interface BindInactiveMembershipsByEmailOpts { * caller has proven that inbox. An invitation already bound to the user is left out, so the link flow that bound it * keeps its token. */ -export const bindInactiveMembershipsByEmail = async ( - ctx: DbContext, - { email, userId }: BindInactiveMembershipsByEmailOpts, -) => { +export const bindInactiveMembershipsByEmail = async (ctx: DbContext, { email, userId }: BindInactiveMembershipsByEmailOpts) => { const { db } = ctx.var; const bound = await db .update(inactiveMembershipsTable) @@ -369,9 +321,7 @@ export const findMembersPaginated = async (ctx: DbContext, opts: FindMembersPagi const { db } = ctx.var; const { organizationId, entityId, entityType, q, sort, order, offset, limit, role, userIds, includeCounts } = opts; - const $or = q - ? [ilike(usersTable.name, prepareStringForILikeFilter(q)), ilike(usersTable.email, prepareStringForILikeFilter(q))] - : []; + const $or = q ? [ilike(usersTable.name, prepareStringForILikeFilter(q)), ilike(usersTable.email, prepareStringForILikeFilter(q))] : []; const membersFilters: SQL[] = [ eq(membershipsTable.organizationId, organizationId), @@ -443,10 +393,7 @@ interface FindMemberPreviewsByChannelsOpts { * Member previews for a set of contexts in one batched query: the first `limit` members per context with the given * role, oldest membership first. Overflow counts come from the `m:c:{role}` counters, so previews need no second query. */ -export const findMemberPreviewsByChannels = async ( - ctx: DbContext, - { channelType, channelIds, role, limit }: FindMemberPreviewsByChannelsOpts, -) => { +export const findMemberPreviewsByChannels = async (ctx: DbContext, { channelType, channelIds, role, limit }: FindMemberPreviewsByChannelsOpts) => { const { db } = ctx.var; const previews = new Map(); if (!channelIds.length) return previews; @@ -467,13 +414,7 @@ export const findMemberPreviewsByChannels = async ( }) .from(membershipsTable) .innerJoin(usersTable, eq(usersTable.id, membershipsTable.userId)) - .where( - and( - eq(membershipsTable.channelType, channelType), - inArray(membershipsTable.channelId, channelIds), - eq(membershipsTable.role, role), - ), - ) + .where(and(eq(membershipsTable.channelType, channelType), inArray(membershipsTable.channelId, channelIds), eq(membershipsTable.role, role))) .as('ranked_members'); const rows = await db @@ -516,22 +457,10 @@ export const findPendingMembershipsPaginated = async (ctx: DbContext, opts: Find const { organizationId, entityId, sort, order, offset, limit } = opts; const table = inactiveMembershipsTable; - const orderBy = getOrderColumns({ - sort, - order, - fallback: ['createdAt', 'desc'], - columns: { createdAt: table.createdAt }, - tieBreaker: table.id, - }); + const orderBy = getOrderColumns({ sort, order, fallback: ['createdAt', 'desc'], columns: { createdAt: table.createdAt }, tieBreaker: table.id }); const pendingMembershipsQuery = db - .select({ - id: table.id, - role: table.role, - email: table.email, - createdAt: table.createdAt, - createdBy: table.createdBy, - }) + .select({ id: table.id, role: table.role, email: table.email, createdAt: table.createdAt, createdBy: table.createdBy }) .from(table) .where(and(eq(table.channelId, entityId), eq(table.organizationId, organizationId), isNull(table.rejectedAt))); diff --git a/backend/src/modules/memberships/memberships-routes.ts b/backend/src/modules/memberships/memberships-routes.ts index 443385fdf..52b9592b7 100644 --- a/backend/src/modules/memberships/memberships-routes.ts +++ b/backend/src/modules/memberships/memberships-routes.ts @@ -1,6 +1,6 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; -import { crossTenantGuard, orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { bulkPointsLimiter, singlePointsLimiter, spamLimiter } from '#/middlewares/rate-limiter/limiters'; import { memberListQuerySchema, @@ -15,7 +15,6 @@ import { memberSchema } from '#/modules/user/user-schema'; import { batchResponseSchema, entityWithTypeQuerySchema, - errorResponseRefs, idInTenantOrgParamSchema, idsBodySchema, paginationSchema, @@ -31,182 +30,86 @@ import { mockPaginatedPendingMembershipsResponse, } from './memberships-mocks'; -const membershipRoutes = { - createMemberships: createXRoute({ +const membershipRoutes = createXRoutes(['memberships', 'cella'], { + createMemberships: xRoute({ operationId: 'membershipInvite', method: 'post', path: '/', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [spamLimiter, bulkPointsLimiter], - tags: ['memberships', 'cella'], summary: 'Create memberships', description: "Creates one or more memberships, inviting users (existing or new) to a channel entity such as an organization. A created membership carries muted, archived and display order only when it is the caller's own.", - request: { - params: tenantOrgParamSchema, - query: entityWithTypeQuerySchema, - body: { - required: true, - content: { 'application/json': { schema: membershipCreateBodySchema } }, - }, - }, + request: { params: tenantOrgParamSchema, query: entityWithTypeQuerySchema, body: jsonBody(membershipCreateBodySchema) }, responses: { - 200: { - description: 'Created memberships and invite count', - content: { - 'application/json': { - schema: batchResponseSchema(memberMembershipSchema).extend({ invitesSentCount: z.number() }), - example: mockMembershipInviteResponse(), - }, - }, - }, - ...errorResponseRefs, + 200: json( + 'Created memberships and invite count', + batchResponseSchema(memberMembershipSchema).extend({ invitesSentCount: z.number() }), + mockMembershipInviteResponse(), + ), }, }), - deleteMemberships: createXRoute({ - operationId: 'deleteMemberships', + deleteMemberships: xRoute({ method: 'delete', path: '/', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['memberships', 'cella'], summary: 'Delete memberships', - description: - 'Deletes one or more memberships by ID. This removes the membership but does not delete the associated user(s).', - request: { - params: tenantOrgParamSchema, - query: entityWithTypeQuerySchema, - body: { - required: true, - content: { 'application/json': { schema: idsBodySchema() } }, - }, - }, - responses: { - 200: { - description: 'Success', - content: { - 'application/json': { - schema: batchResponseSchema(), - }, - }, - }, - ...errorResponseRefs, - }, + description: 'Deletes one or more memberships by ID. This removes the membership but does not delete the associated user(s).', + request: { params: tenantOrgParamSchema, query: entityWithTypeQuerySchema, body: jsonBody(idsBodySchema()) }, + responses: { 200: json('Success', batchResponseSchema()) }, }), - updateMembership: createXRoute({ - operationId: 'updateMembership', + updateMembership: xRoute({ method: 'put', path: '/{id}', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [singlePointsLimiter], - tags: ['memberships', 'cella'], summary: 'Update membership', description: "Updates a membership: its role, or the muted, archived or display order status. Send at least one field. Muted, archived and display order are set by the member only, and the response carries them only on the caller's own membership. A role change, and any change to another member's membership, requires update permission on the channel.", - request: { - params: idInTenantOrgParamSchema, - body: { - required: true, - content: { 'application/json': { schema: membershipUpdateBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Membership updated', - content: { 'application/json': { schema: updatedMembershipSchema, example: mockMembershipResponse() } }, - }, - ...errorResponseRefs, - }, + request: { params: idInTenantOrgParamSchema, body: jsonBody(membershipUpdateBodySchema) }, + responses: { 200: json('Membership updated', updatedMembershipSchema, mockMembershipResponse()) }, }), - handleMembershipInvitation: createXRoute({ - operationId: 'handleMembershipInvitation', + handleMembershipInvitation: xRoute({ method: 'post', path: '/{id}/{acceptOrReject}', - xGuard: [userGuard, crossTenantGuard], + xGuard: [userGuard], xRateLimiter: [singlePointsLimiter], - tags: ['memberships', 'cella'], summary: 'Respond to membership invitation', description: 'Accepting activates the associated membership. Rejecting simply removes the invitation token.', - request: { - params: z.object({ id: validIdSchema, acceptOrReject: z.enum(['accept', 'reject']) }), - }, - responses: { - 200: { - description: 'Invitation was accepted', - content: { 'application/json': { schema: channelBaseSchema, example: mockChannelBase() } }, - }, - ...errorResponseRefs, - }, + request: { params: z.object({ id: validIdSchema, acceptOrReject: z.enum(['accept', 'reject']) }) }, + responses: { 200: json('Invitation was accepted', channelBaseSchema, mockChannelBase()) }, }), - getMembers: createXRoute({ - operationId: 'getMembers', + getMembers: xRoute({ method: 'get', path: '/members', xGuard: [userGuard, tenantGuard, orgGuard], - tags: ['memberships', 'cella'], summary: 'Get list of members', description: 'Retrieves members (users) of a channel entity by ID, including their associated membership data.', - request: { - params: tenantOrgParamSchema, - query: memberListQuerySchema, - }, - responses: { - 200: { - description: 'Members', - content: { - 'application/json': { - schema: paginationSchema(memberSchema), - example: mockPaginatedMembersResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, query: memberListQuerySchema }, + responses: { 200: json('Members', paginationSchema(memberSchema), mockPaginatedMembersResponse()) }, }), - getPendingMemberships: createXRoute({ - operationId: 'getPendingMemberships', + getPendingMemberships: xRoute({ method: 'get', path: '/pending', xGuard: [userGuard, tenantGuard, orgGuard], - tags: ['memberships', 'cella'], summary: 'Get list of pending memberships', description: 'Returns the pending invitations of a channel entity, identified by ID: the address each went to, its role and its inviter. A row looks the same whether an account holds the address or not.', - request: { - params: tenantOrgParamSchema, - query: pendingMembershipListQuerySchema, - }, - responses: { - 200: { - description: 'Pending memberships', - content: { - 'application/json': { - schema: paginationSchema(pendingMembershipSchema), - example: mockPaginatedPendingMembershipsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, query: pendingMembershipListQuerySchema }, + responses: { 200: json('Pending memberships', paginationSchema(pendingMembershipSchema), mockPaginatedPendingMembershipsResponse()) }, }), - resendPendingInvitation: createXRoute({ - operationId: 'resendPendingInvitation', + resendPendingInvitation: xRoute({ method: 'post', path: '/pending/{id}/resend', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [spamLimiter, singlePointsLimiter], - tags: ['memberships', 'cella'], summary: 'Resend pending invitation', description: 'Re-sends the invitation email for a pending membership, named by its own id; an invitation holding a token gets a fresh one. Answers 204 alike for every pending invitation. Requires update permission on the invited channel; the public auth resend endpoint stays for invitees holding an expired token.', - request: { - params: idInTenantOrgParamSchema, - }, - responses: { - 204: { description: 'Invitation resent' }, - ...errorResponseRefs, - }, + request: { params: idInTenantOrgParamSchema }, + responses: { 204: { description: 'Invitation resent' } }, }), -}; +}); export { membershipRoutes }; diff --git a/backend/src/modules/memberships/memberships-schema.ts b/backend/src/modules/memberships/memberships-schema.ts index 2fd16ce55..76eb20b88 100644 --- a/backend/src/modules/memberships/memberships-schema.ts +++ b/backend/src/modules/memberships/memberships-schema.ts @@ -46,12 +46,7 @@ export const inactiveMembershipSchema = z }); export const membershipBaseSchema = membershipSchema - .omit({ - createdAt: true, - createdBy: true, - updatedAt: true, - updatedBy: true, - }) + .omit({ createdAt: true, createdBy: true, updatedAt: true, updatedBy: true }) .openapi('MembershipBase', { description: 'Core membership fields shared across active and inactive memberships.', example: mockMembershipBase(), @@ -73,10 +68,7 @@ export const memberMembershipSchema = membershipBaseSchema.omit(personalViewMask /** An updated membership with its audit fields; archive, mute and menu order as in `memberMembershipSchema`. */ export const updatedMembershipSchema = membershipSchema.omit(personalViewMask).extend(optionalPersonalView); -export const membershipCreateBodySchema = z.object({ - emails: validEmailSchema.array().min(1).max(50), - role: membershipSchema.shape.role, -}); +export const membershipCreateBodySchema = z.object({ emails: validEmailSchema.array().min(1).max(50), role: membershipSchema.shape.role }); export const membershipUpdateBodySchema = z .object({ role: membershipSchema.shape.role.optional(), ...optionalPersonalView }) diff --git a/backend/src/modules/memberships/operations/create-memberships.ts b/backend/src/modules/memberships/operations/create-memberships.ts index c025fe38c..1b121e9fc 100644 --- a/backend/src/modules/memberships/operations/create-memberships.ts +++ b/backend/src/modules/memberships/operations/create-memberships.ts @@ -50,20 +50,11 @@ export async function createMembershipsOp(ctx: UserContext, input: CreateMembers const channelIsDraft = entity.publishedAt === null; const deferDispatch = channelIsDraft && role !== hierarchy.getRoles(entityType)[0]; - const currentOrgMemberships = await countMembershipsByChannel(ctx, { - channelType: 'organization', - channelId: organization.id, - }); - const pendingInvites = await countPendingInvitesByChannel(ctx, { - channelType: 'organization', - channelId: organization.id, - }); + const currentOrgMemberships = await countMembershipsByChannel(ctx, { channelType: 'organization', channelId: organization.id }); + const pendingInvites = await countPendingInvitesByChannel(ctx, { channelType: 'organization', channelId: organization.id }); const membersRestrictions = ctx.var.tenant.restrictions.quotas.user; - if ( - membersRestrictions !== 0 && - currentOrgMemberships + pendingInvites + normalizedEmails.length > membersRestrictions - ) { + if (membersRestrictions !== 0 && currentOrgMemberships + pendingInvites + normalizedEmails.length > membersRestrictions) { throw new AppError(403, 'restrict_by_org', 'warn', { entityType }); } @@ -119,8 +110,7 @@ export async function createMembershipsOp(ctx: UserContext, input: CreateMembers const isAdminInvitingSelf = user.email === email && isSystemAdmin; // An organization member invited below the organization by their listed address joins at once. Draft context: // existing users are deferred too, with no membership, nav entry, or email. - const joinsDirectly = - entityType !== 'organization' && !!account.orgMembershipId && isListedAddress && !deferDispatch; + const joinsDirectly = entityType !== 'organization' && !!account.orgMembershipId && isListedAddress && !deferDispatch; if (isAdminInvitingSelf || joinsDirectly) existingUsersToDirectAdd.push({ userId: account.userId, email }); else existingUsersToActivate.push({ userId: account.userId, email }); @@ -156,7 +146,7 @@ export async function createMembershipsOp(ctx: UserContext, input: CreateMembers })); createdMemberships = await insertMemberships({ var: { db } }, { items: membershipsToInsert }); - for (const { userId } of existingUsersToDirectAdd) await invalidateCache.user(db, userId); + for (const { userId } of existingUsersToDirectAdd) invalidateCache.user(userId); } const newUserInactiveMembershipIdsByEmail = new Map(); @@ -194,9 +184,7 @@ export async function createMembershipsOp(ctx: UserContext, input: CreateMembers } if (inactiveMembershipsToInsert.length > 0) { - insertedInactiveMemberships = await insertInactiveMemberships(ctx, { - memberships: inactiveMembershipsToInsert, - }); + insertedInactiveMemberships = await insertInactiveMemberships(ctx, { memberships: inactiveMembershipsToInsert }); } // A new address gets its token's link once its invitation row stands. @@ -217,19 +205,12 @@ export async function createMembershipsOp(ctx: UserContext, input: CreateMembers // Track reminder dispatch for the 7-day throttle if (!deferDispatch && remindedInactiveMembershipIds.length > 0) { - await stampInactiveMembershipsReminded(ctx, { - ids: remindedInactiveMembershipIds, - remindedAt: new Date().toISOString(), - }); + await stampInactiveMembershipsReminded(ctx, { ids: remindedInactiveMembershipIds, remindedAt: new Date().toISOString() }); } const invitesSentCount = insertedInactiveMemberships.length; - log.info('Users invited on entity level', { - count: invitesSentCount, - entityType, - entityId, - }); + log.info('Users invited on entity level', { count: invitesSentCount, entityType, entityId }); const data = createdMemberships.map((membership) => membershipAsSeenBy(membership, user.id)); diff --git a/backend/src/modules/memberships/operations/delete-memberships.ts b/backend/src/modules/memberships/operations/delete-memberships.ts index 836282394..b63b0402c 100644 --- a/backend/src/modules/memberships/operations/delete-memberships.ts +++ b/backend/src/modules/memberships/operations/delete-memberships.ts @@ -18,10 +18,7 @@ export async function deleteMembershipsOp(ctx: UserContext, input: DeleteMembers const membershipIds = Array.isArray(ids) ? ids : [ids]; - const targets = await findMembershipsByUserIdsAndChannel(ctx, { - userIds: membershipIds, - channelId: entity.id, - }); + const targets = await findMembershipsByUserIdsAndChannel(ctx, { userIds: membershipIds, channelId: entity.id }); const rejectedIds: string[] = []; @@ -31,11 +28,8 @@ export async function deleteMembershipsOp(ctx: UserContext, input: DeleteMembers if (targets.length === 0) return { data: [] as never[], rejectedIds }; - await deleteMembershipsByIds(ctx, { - ids: targets.map((target) => target.id), - }); - - for (const target of targets) await invalidateCache.user(ctx.var.db, target.userId); + await deleteMembershipsByIds(ctx, { ids: targets.map((target) => target.id) }); + for (const target of targets) invalidateCache.user(target.userId); log.info('Memberships deleted', { count: targets.length, ids: targets.map((t) => t.userId) }); diff --git a/backend/src/modules/memberships/operations/get-members.ts b/backend/src/modules/memberships/operations/get-members.ts index e3d52758a..683245640 100644 --- a/backend/src/modules/memberships/operations/get-members.ts +++ b/backend/src/modules/memberships/operations/get-members.ts @@ -20,32 +20,16 @@ interface GetMembersInput { } export async function getMembersOp(ctx: UserContext, input: GetMembersInput) { - const organization = ctx.var.organization; - - const { entityId, entityType, q, sort, order, offset, limit, role, userIds, include } = input; - - const { entity } = await getValidChannel(ctx, entityId, entityType, 'read'); + const { include, ...query } = input; + const { entity } = await getValidChannel(ctx, query.entityId, query.entityType, 'read'); const includeCounts = include?.includes('counts') ?? false; - - const listOpts = { - organizationId: organization.id, - entityId: entity.id, - entityType, - q, - sort, - order, - offset, - limit, - role, - userIds, - includeCounts, - }; + const listOpts = { ...query, organizationId: ctx.var.organization.id, entityId: entity.id, includeCounts }; // Member counts and the lastPostedAt sort read RLS-guarded product tables, // which read empty on this route's bare baseDb; tenantGuard pinned the tenant, so read as it. const { items, total } = - includeCounts || sort === 'lastPostedAt' + includeCounts || query.sort === 'lastPostedAt' ? await tenantRead(ctx, (readCtx) => findMembersPaginated(readCtx, listOpts)) : await findMembersPaginated(ctx, listOpts); diff --git a/backend/src/modules/memberships/operations/handle-membership-invitation.ts b/backend/src/modules/memberships/operations/handle-membership-invitation.ts index cfe69427f..0cc001734 100644 --- a/backend/src/modules/memberships/operations/handle-membership-invitation.ts +++ b/backend/src/modules/memberships/operations/handle-membership-invitation.ts @@ -7,11 +7,7 @@ import { deleteInvitationTokens } from '#/modules/auth/tokens/tokens-queries'; import { resolveEntity } from '#/modules/entities/entities-queries'; import { insertMemberships } from '#/modules/memberships/helpers/membership-helpers'; import { inactiveMembershipsTable } from '#/modules/memberships/inactive-memberships-db'; -import { - bindInactiveMemberships, - findClaimableInactiveMembership, - findInactiveMembershipForUser, -} from '#/modules/memberships/memberships-queries'; +import { bindInactiveMemberships, findClaimableInactiveMembership, findInactiveMembershipForUser } from '#/modules/memberships/memberships-queries'; import { getIsoDate } from '#/utils/iso-date'; import { log } from '#/utils/logger'; @@ -79,17 +75,12 @@ export async function handleMembershipInvitationOp( await deleteInvitationTokens({ var: { db: tx } }, { inactiveMembershipIds: [inactiveMembership.id] }); } }); - - // The guards cache the user's memberships: the next request sees the new one, in-app and by token alike. - if (acceptOrReject === 'accept') await invalidateCache.user(baseDb, userId); + if (acceptOrReject === 'accept') invalidateCache.user(userId); const organizationId = inactiveMembership.organizationId; if (!organizationId) throw new AppError(500, 'server_error', 'error', { entityType: 'organization' }); - const entity = await resolveEntity( - { var: { db: baseDb } }, - { entityType: 'organization', identifier: organizationId }, - ); + const entity = await resolveEntity({ var: { db: baseDb } }, { entityType: 'organization', identifier: organizationId }); if (!entity) throw new AppError(404, 'not_found', 'error', { entityType: 'organization' }); return entity; diff --git a/backend/src/modules/memberships/operations/resend-pending-invitation.ts b/backend/src/modules/memberships/operations/resend-pending-invitation.ts index fceac26a7..84bc14928 100644 --- a/backend/src/modules/memberships/operations/resend-pending-invitation.ts +++ b/backend/src/modules/memberships/operations/resend-pending-invitation.ts @@ -34,15 +34,11 @@ export async function resendPendingInvitationOp(ctx: UserContext, id: string) { } /** The invitation email without a token, as an invitation to an address held by an account is first sent. */ -async function remindInvitee( - ctx: UserContext, - invitation: InactiveMembershipModel, - entity: EntityModel, -): Promise { +async function remindInvitee(ctx: UserContext, invitation: InactiveMembershipModel, entity: EntityModel): Promise { // Replies reach the inviter, as on the first invitation. const sender = await findUserById(ctx, { id: invitation.createdBy }); await sendInvitationMails(ctx, { - sender: sender ?? { name: 'System', thumbnailUrl: null }, + sender: sender ?? { name: 'System' }, channel: { type: invitation.channelType, slug: entity.slug, name: entity.name, role: invitation.role }, organization: ctx.var.organization, invited: [{ email: invitation.email, userId: invitation.userId }], diff --git a/backend/src/modules/memberships/operations/update-membership.ts b/backend/src/modules/memberships/operations/update-membership.ts index 5a718b040..d40d14a0e 100644 --- a/backend/src/modules/memberships/operations/update-membership.ts +++ b/backend/src/modules/memberships/operations/update-membership.ts @@ -70,8 +70,7 @@ export async function updateMembershipOp(ctx: UserContext, membershipId: string, updatedAt: getIsoDate(), }; const updatedMembership = await updateMembership(ctx, { id: membershipId, values }); - - await invalidateCache.user(ctx.var.db, updatedMembership.userId); + invalidateCache.user(updatedMembership.userId); log.info('Membership updated', { userId: updatedMembership.userId, membershipId: updatedMembership.id }); diff --git a/backend/src/modules/metrics/metrics-mocks.ts b/backend/src/modules/metrics/metrics-mocks.ts index 0106ddb4b..c4ef84554 100644 --- a/backend/src/modules/metrics/metrics-mocks.ts +++ b/backend/src/modules/metrics/metrics-mocks.ts @@ -3,10 +3,7 @@ import { appConfig, type EntityType } from 'shared'; import { withFakerSeed } from '#/mocks'; export const mockPublicCountsResponse = (key = 'metrics:public-counts') => - withFakerSeed( - key, - () => - Object.fromEntries( - appConfig.entityTypes.map((entityType) => [entityType, faker.number.int({ min: 0, max: 500 })]), - ) as Record, - ); + withFakerSeed(key, () => { + const counts = Object.fromEntries(appConfig.entityTypes.map((entityType) => [entityType, faker.number.int({ min: 0, max: 500 })])); + return counts as Record; + }); diff --git a/backend/src/modules/metrics/metrics-routes.ts b/backend/src/modules/metrics/metrics-routes.ts index bf0e61aac..2c6b243b2 100644 --- a/backend/src/modules/metrics/metrics-routes.ts +++ b/backend/src/modules/metrics/metrics-routes.ts @@ -1,29 +1,20 @@ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, xRoute } from '#/core/x-routes'; import { publicGuard } from '#/middlewares/guard'; import { isNoBot } from '#/middlewares/is-no-bot'; import { publicCountsSchema } from '#/modules/metrics/metrics-schema'; -import { errorResponseRefs } from '#/schemas'; import { mockPublicCountsResponse } from './metrics-mocks'; -const metricRouteConfig = { - getPublicCounts: createXRoute({ - operationId: 'getPublicCounts', +const metricRouteConfig = createXRoutes(['metrics', 'cella'], { + getPublicCounts: xRoute({ method: 'get', path: '/public', xGuard: [publicGuard], middleware: isNoBot, - tags: ['metrics', 'cella'], summary: 'Get public counts', description: `Returns basic count metrics for entity types such as users and organizations. This endpoint is public and uses a 1 minute in memory cache for performance.`, - responses: { - 200: { - description: 'Public counts', - content: { 'application/json': { schema: publicCountsSchema, example: mockPublicCountsResponse() } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Public counts', publicCountsSchema, mockPublicCountsResponse()) }, }), -}; +}); export { metricRouteConfig }; diff --git a/backend/src/modules/notification/digest/build-digest.test.ts b/backend/src/modules/notification/digest/build-digest.test.ts deleted file mode 100644 index 3084f320a..000000000 --- a/backend/src/modules/notification/digest/build-digest.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { type DigestSection, renderSectionsHtml } from './build-digest'; - -const section: DigestSection = { - channelId: 'c1', - channelName: 'Ontwerp', - lines: ['Nieuwe reactie op Roadmap'], - overflow: 3, -}; - -describe('renderSectionsHtml', () => { - // The committed locale bundles, so a language missing the line fails here. - it("writes the overflow line in the recipient's language", () => { - expect(renderSectionsHtml([section], 'nl')).toBe( - '

Ontwerp

  • Nieuwe reactie op Roadmap
  • en nog 3
', - ); - expect(renderSectionsHtml([section], 'en')).toContain('
  • and 3 more
  • '); - }); - - it('leaves the overflow line out when every row is quoted', () => { - expect(renderSectionsHtml([{ ...section, overflow: 0 }], 'en')).toBe( - '

    Ontwerp

    • Nieuwe reactie op Roadmap
    ', - ); - }); -}); diff --git a/backend/src/modules/notification/digest/build-digest.ts b/backend/src/modules/notification/digest/build-digest.ts deleted file mode 100644 index 541887d72..000000000 --- a/backend/src/modules/notification/digest/build-digest.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { i18n } from '../../../../emails/i18n'; -import { accessForUserIds } from '../helpers/access-for-users'; -import { findChannelNames } from '../helpers/channel-names'; -import { findReadableSubjectIds } from '../helpers/readable-subjects'; -import { escapeString } from '../helpers/render-digest-html'; -import { findSubjectNames } from '../helpers/subject-names'; -import { findUndigestedNotifications } from '../notification-queries'; - -/** Rows quoted per channel before the section collapses into "and N more". */ -const ROWS_PER_CHANNEL = 5; - -/** Rows considered per digest; a larger backlog is summarised by the overflow counts. */ -const MAX_ROWS = 500; - -export interface DigestSection { - channelId: string; - channelName: string; - lines: string[]; - overflow: number; -} - -export interface DigestContent { - notificationIds: string[]; - sections: DigestSection[]; -} - -/** - * Assemble one user's digest for the window `[since, now)`, with lines in the recipient's - * language. The runner bounds `since` (run-digest.ts). - * - * Rows already emailed instantly are excluded, so a mention never arrives twice. So are rows of - * an organization the user left and rows whose subject the user may no longer read: the digest - * names only what the recipient can open. - */ -export async function buildDigestForUser(userId: string, since: Date, lng: string): Promise { - const empty: DigestContent = { notificationIds: [], sections: [] }; - const undigested = await findUndigestedNotifications(userId, since.toISOString(), MAX_ROWS); - const access = undigested.length ? (await accessForUserIds([userId])).get(userId) : undefined; - if (!access) return empty; - - const readable = await findReadableSubjectIds(access, undigested); - const rows = undigested.filter((row) => readable.has(row.subjectId)); - if (rows.length === 0) return empty; - - const contextNames = await findSubjectNames(rows.map((row) => ({ ...row, id: row.contextId }))); - const channelNames = await findChannelNames(rows.map((row) => row.channelId)); - - const byChannel = new Map(); - for (const row of rows) { - const list = byChannel.get(row.channelId) ?? []; - list.push(row); - byChannel.set(row.channelId, list); - } - - const sections: DigestSection[] = []; - for (const [channelId, channelRows] of byChannel) { - const visible = channelRows.slice(0, ROWS_PER_CHANNEL); - sections.push({ - channelId, - channelName: channelNames.get(channelId) ?? '', - lines: visible.map((row) => describeDigestRow(row.type, contextNames.get(row.contextId ?? '') ?? '', lng)), - overflow: Math.max(0, channelRows.length - visible.length), - }); - } - - return { notificationIds: rows.map((row) => row.id), sections }; -} - -/** - * One digest line as HTML, from `c:email.digest_line.` (apps add theirs to `app.json`) with the - * generic line as fallback. Kept short: the email links through and never reproduces the thread. - * The title is interpolated escaped; any markup around it lives in the translation string. - * @param type - Notification type, selecting the line's translation key. - * @param contextTitle - Title of the item the notification is about; empty renders as `-`. - * @param lng - Recipient language. - * @returns The line, safe to place in the digest's HTML list. - */ -export function describeDigestRow(type: string, contextTitle: string, lng: string): string { - return i18n.t([`c:email.digest_line.${type}`, 'c:email.digest_line.default'], { lng, title: contextTitle || '-' }); -} - -/** - * Digest sections as HTML with every user-derived fragment escaped: the digest mail's declared HTML param. - * @param sections - The sections `buildDigestForUser` assembled. - * @param lng - Recipient language, for the line that counts the rows left out. - * @returns The sections, safe to place in the digest mail. - */ -export function renderSectionsHtml(sections: DigestSection[], lng: string): string { - return sections - .map((section) => { - const items = section.lines.map((line) => `
  • ${line}
  • `).join(''); - const more = - section.overflow > 0 ? `
  • ${i18n.t('c:email.digest_overflow', { lng, count: section.overflow })}
  • ` : ''; - return `

    ${escapeString(section.channelName)}

      ${items}${more}
    `; - }) - .join(''); -} diff --git a/backend/src/modules/notification/emails/comment-email.tsx b/backend/src/modules/notification/emails/comment-email.tsx new file mode 100644 index 000000000..2805ea1e1 --- /dev/null +++ b/backend/src/modules/notification/emails/comment-email.tsx @@ -0,0 +1,42 @@ +import { i18n, plainText } from '../../../../emails/i18n'; +import { defineEmailTemplate } from '../../../../emails/types'; +import { SubjectEmail, type SubjectEmailRecipient } from './mention-email'; + +interface CommentStatic { + /** Empty string when the actor is gone. */ + actorName: string; + channelName: string; + /** A `reply` notification, else a `comment`. */ + reply: boolean; +} + +/** + * Instant email for a comment or reply notification. Off unless the app sets `has.commentEmail` and + * the recipient turns comment emails on; a mention on the same subject is mailed as a mention. + */ +export const commentEmail = defineEmailTemplate()({ + translate(lng, { actorName, channelName, reply }) { + const key = reply ? 'reply' : 'comment'; + return { + subject: i18n.t(`c:email.${key}.subject`, { lng, actorName, channelName, ...plainText }), + previewText: i18n.t(`c:email.${key}.preview`, { lng, actorName, ...plainText }), + headerHtml: i18n.t(`c:email.${key}.title`, { lng, actorName }), + inText: i18n.t('c:email.mention.in', { lng, channelName, ...plainText }), + buttonText: i18n.t('c:email.mention.button', { lng }), + unsubscribeText: i18n.t('c:email.unsubscribe_comments', { lng }), + supportText: i18n.t('backend:email.support_email', { lng }), + }; + }, + component(props) { + return ; + }, + preview: { + statics: { actorName: 'John', channelName: 'Design 101', reply: false }, + recipient: { + subjectTitle: 'Roadmap review', + excerpt: 'I added the dates we discussed.', + link: 'https://example.com/acme', + unsubscribeLink: 'https://example.com/unsubscribe', + }, + }, +}); diff --git a/backend/src/modules/notification/emails/digest-email.tsx b/backend/src/modules/notification/emails/digest-email.tsx index 6370222c5..cd3010230 100644 --- a/backend/src/modules/notification/emails/digest-email.tsx +++ b/backend/src/modules/notification/emails/digest-email.tsx @@ -1,15 +1,5 @@ -import { - EmailBody, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../../../../emails/components'; -import { Link } from '../../../../emails/components/primitives'; +import { EmailLayout, EmailText, SafeHtml } from '../../../../emails/components'; import { i18n } from '../../../../emails/i18n'; -import { smallTextStyle } from '../../../../emails/styles'; import { defineEmailTemplate, type EmailRecipient } from '../../../../emails/types'; interface DigestStatic { @@ -21,10 +11,7 @@ interface DigestStatic { * language and fills the rest through Brevo placeholders, which are strings only. It is a declared * HTML param: `renderSectionsHtml` escapes every user-derived fragment, and Brevo prints it as is. */ -type DigestRecipient = EmailRecipient & { - sectionsHtml: string; - unsubscribeLink: string; -}; +type DigestRecipient = EmailRecipient & { sectionsHtml: string; unsubscribeLink: string }; export const digestEmail = defineEmailTemplate()({ translate(lng, { daily }) { @@ -40,23 +27,16 @@ export const digestEmail = defineEmailTemplate()( }, component({ previewText, headerHtml, introText, unsubscribeText, supportText, sectionsHtml, unsubscribeLink }) { return ( - - } /> - - {introText} - - - -
    - - {unsubscribeText} - -
    -
    - - - -
    + + {introText} + + ); }, htmlParams: { sectionsHtml: 'richText' }, diff --git a/backend/src/modules/notification/emails/mention-email.tsx b/backend/src/modules/notification/emails/mention-email.tsx index bcf268a04..5cafe59c5 100644 --- a/backend/src/modules/notification/emails/mention-email.tsx +++ b/backend/src/modules/notification/emails/mention-email.tsx @@ -1,16 +1,5 @@ -import { - EmailBody, - EmailButton, - EmailContainer, - EmailFooter, - EmailHeader, - EmailLogo, - EmailText, - SafeHtml, -} from '../../../../emails/components'; -import { Link } from '../../../../emails/components/primitives'; +import { EmailButton, EmailLayout, EmailText } from '../../../../emails/components'; import { i18n, plainText } from '../../../../emails/i18n'; -import { smallTextStyle } from '../../../../emails/styles'; import { defineEmailTemplate, type EmailRecipient } from '../../../../emails/types'; interface MentionStatic { @@ -19,12 +8,45 @@ interface MentionStatic { channelName: string; } -type MentionRecipient = EmailRecipient & { - subjectTitle: string; - excerpt: string; - link: string; - unsubscribeLink: string; -}; +/** Per-recipient props of an instant email about one subject: the mention mail and the comment mail. */ +export type SubjectEmailRecipient = EmailRecipient & { subjectTitle: string; excerpt: string; link: string; unsubscribeLink: string }; + +interface SubjectEmailProps extends Omit { + previewText: string; + headerHtml: string; + inText: string; + buttonText: string; + unsubscribeText: string; + supportText: string; +} + +/** The body every instant email shares: where, the subject's title and excerpt, a link and an unsubscribe line. */ +export const SubjectEmail = ({ + previewText, + headerHtml, + inText, + buttonText, + unsubscribeText, + supportText, + subjectTitle, + excerpt, + link, + unsubscribeLink, +}: SubjectEmailProps) => ( + + {inText} + + {subjectTitle} + + {excerpt} + + +); /** * Instant email for a direct mention: the one activity email that is on by default, because a @@ -33,7 +55,7 @@ type MentionRecipient = EmailRecipient & { * Lives in the module, not `backend/emails/templates`, keeping the feature self-contained; the * mailer takes any template satisfying the contract regardless of where it sits. */ -export const mentionEmail = defineEmailTemplate()({ +export const mentionEmail = defineEmailTemplate()({ translate(lng, { actorName, channelName }) { return { subject: i18n.t('c:email.mention.subject', { lng, actorName, channelName, ...plainText }), @@ -45,41 +67,8 @@ export const mentionEmail = defineEmailTemplate supportText: i18n.t('backend:email.support_email', { lng }), }; }, - component({ - previewText, - headerHtml, - inText, - buttonText, - unsubscribeText, - supportText, - subjectTitle, - excerpt, - link, - unsubscribeLink, - }) { - return ( - - } /> - - {inText} - - {subjectTitle} - - {excerpt} - - - -
    - - {unsubscribeText} - -
    -
    - - - -
    - ); + component(props) { + return ; }, preview: { statics: { actorName: 'John', channelName: 'Design 101' }, diff --git a/backend/src/modules/notification/helpers/access-for-users.ts b/backend/src/modules/notification/helpers/access-for-users.ts deleted file mode 100644 index 28ebcae99..000000000 --- a/backend/src/modules/notification/helpers/access-for-users.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { eq, inArray } from 'drizzle-orm'; -import type { Access } from 'shared'; -import { baseDb } from '#/db/db'; -import { type MembershipBaseModel, toMembershipBase } from '#/modules/memberships/helpers/select'; -import { membershipsTable } from '#/modules/memberships/memberships-db'; -import { systemRolesTable } from '#/modules/system/system-roles-db'; - -/** Always the identified variant: these are known users, never the anonymous actor. */ -export type UserAccess = Extract, { actorId: string }>; - -/** - * Build permission `Access` objects for arbitrary users, connected or not. - * - * `actorFrom`/`accessFrom` read the request context, so they only ever describe the caller, and - * stream fan-out only sees users with an open SSE connection. Notifications must decide what an - * offline user may read, which needs memberships and system-admin status loaded by user id. - * - * Both halves are loaded and paired here on purpose: `accessFrom` warns that hand-assembling an - * Access risks pairing one user's memberships with another's identity, and that warning applies - * with more force to a loop over many users. - */ -export async function accessForUserIds(userIds: string[]): Promise> { - const result = new Map(); - if (userIds.length === 0) return result; - - const unique = [...new Set(userIds)]; - - const [memberships, systemAdmins] = await Promise.all([ - baseDb.select().from(membershipsTable).where(inArray(membershipsTable.userId, unique)), - baseDb.select({ userId: systemRolesTable.userId }).from(systemRolesTable).where(eq(systemRolesTable.role, 'admin')), - ]); - - const adminIds = new Set(systemAdmins.map((row) => row.userId)); - - const byUser = new Map(); - for (const membership of memberships) { - const list = byUser.get(membership.userId) ?? []; - list.push(toMembershipBase(membership as Record)); - byUser.set(membership.userId, list); - } - - for (const userId of unique) { - result.set(userId, { - // An offline user is read as a session would be: unmasked. - scopes: null, - actorId: userId, - isSystemAdmin: adminIds.has(userId), - memberships: byUser.get(userId) ?? [], - }); - } - - return result; -} diff --git a/backend/src/modules/notification/helpers/channel-names.ts b/backend/src/modules/notification/helpers/channel-names.ts deleted file mode 100644 index 4ef81c1b7..000000000 --- a/backend/src/modules/notification/helpers/channel-names.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { inArray } from 'drizzle-orm'; -import type { AnyPgTable, PgColumn } from 'drizzle-orm/pg-core'; -import { appConfig } from 'shared'; -import { baseDb } from '#/db/db'; -import { getEntityTable } from '#/tables'; - -/** The two columns every channel table has; the table union needs narrowing to select them. */ -type NamedTable = AnyPgTable & { id: PgColumn; name: PgColumn }; - -/** - * Display names for a set of channel ids, so a digest can group by channel without the caller - * knowing which table each id lives in. - * - * Driven by `appConfig.channelEntityTypes`, so a hierarchy change is picked up automatically. Channel tables sit outside RLS (application-layer guards cover them), - * and the ids only ever come from rows the recipient was already cleared to read. - */ -export async function findChannelNames(channelIds: string[]): Promise> { - const names = new Map(); - if (channelIds.length === 0) return names; - - const unique = [...new Set(channelIds)]; - - await Promise.all( - appConfig.channelEntityTypes.map(async (channelType) => { - const table = getEntityTable(channelType) as NamedTable; - const rows = await baseDb.select({ id: table.id, name: table.name }).from(table).where(inArray(table.id, unique)); - for (const row of rows) names.set(String(row.id), String(row.name)); - }), - ); - - return names; -} diff --git a/backend/src/modules/notification/helpers/extract-mentions.test.ts b/backend/src/modules/notification/helpers/extract-mentions.test.ts deleted file mode 100644 index 1d24eecb2..000000000 --- a/backend/src/modules/notification/helpers/extract-mentions.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { extractMentionIds } from './extract-mentions'; - -const alice = '11111111-1111-4111-8111-111111111111'; -const bob = '22222222-2222-4222-8222-222222222222'; - -describe('extractMentionIds', () => { - it('reads ids from stored HTML, which is what comment and item bodies contain', () => { - const html = `

    Hi @ Alice, see this

    `; - expect(extractMentionIds(html)).toEqual([alice]); - }); - - it('reads ids from BlockNote JSON, so a body saved as blocks still resolves', () => { - const blocks = JSON.stringify([ - { - type: 'paragraph', - content: [ - { type: 'text', text: 'Hi ' }, - { type: 'mention', props: { id: alice, name: 'Alice', slug: 'alice' } }, - ], - }, - ]); - expect(extractMentionIds(blocks)).toEqual([alice]); - }); - - it('finds mentions nested inside child blocks', () => { - const blocks = JSON.stringify([ - { - type: 'bulletListItem', - content: [], - children: [{ type: 'paragraph', content: [{ type: 'mention', props: { id: bob } }] }], - }, - ]); - expect(extractMentionIds(blocks)).toEqual([bob]); - }); - - it('deduplicates a user mentioned twice', () => { - const html = `@ A@ A`; - expect(extractMentionIds(html)).toEqual([alice]); - }); - - it('ignores non-uuid ids, so a hand-written attribute or mention node cannot inject a recipient', () => { - expect(extractMentionIds('@ X')).toEqual([]); - expect(extractMentionIds(JSON.stringify([{ type: 'mention', props: { id: 'not-a-uuid' } }]))).toEqual([]); - }); - - it('returns nothing for empty or absent bodies', () => { - expect(extractMentionIds(null)).toEqual([]); - expect(extractMentionIds('')).toEqual([]); - expect(extractMentionIds('

    no mentions here

    ')).toEqual([]); - }); - - it('never throws on malformed JSON: a bad body must not fail the write it is derived from', () => { - expect(extractMentionIds('[{"type":')).toEqual([]); - }); -}); diff --git a/backend/src/modules/notification/helpers/extract-mentions.ts b/backend/src/modules/notification/helpers/extract-mentions.ts deleted file mode 100644 index a6c9b7138..000000000 --- a/backend/src/modules/notification/helpers/extract-mentions.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isRecord } from 'shared/utils/as-record'; -import { isUuid } from 'shared/utils/entity-id'; - -// Two body shapes exist because editors differ: BlockNote stores mentions as inline content -// nodes in its JSON document, while HTML bodies carry them as a span with a data attribute. -const HTML_MENTION_PATTERN = /data-mention-id=["']([0-9a-f-]{36})["']/gi; - -/** Walks arbitrary BlockNote JSON, collecting `{ type: 'mention', props: { id } }` nodes at any depth. */ -function collectFromBlocks(node: unknown, into: Set): void { - if (Array.isArray(node)) { - for (const child of node) collectFromBlocks(child, into); - return; - } - if (!isRecord(node)) return; - - if (node.type === 'mention' && isRecord(node.props)) { - const id = node.props.id; - // Ids are UUIDs; anything else is a malformed or hand-written payload and is dropped. - if (typeof id === 'string' && isUuid(id)) into.add(id); - } - - for (const value of Object.values(node)) { - if (Array.isArray(value) || isRecord(value)) collectFromBlocks(value, into); - } -} - -/** - * Extract mentioned user ids from a stored body, server-side: trusting a client-posted array - * would let anyone notify anyone, so ids are re-derived here and permission-filtered by the - * caller. Returns unique ids in document order; never throws, because a malformed body must not - * fail the write it is derived from. - */ -export function extractMentionIds(body: string | null | undefined): string[] { - if (!body) return []; - - const found = new Set(); - - const trimmed = body.trimStart(); - if (trimmed.startsWith('[') || trimmed.startsWith('{')) { - try { - collectFromBlocks(JSON.parse(body), found); - } catch { - // Not JSON after all; fall through to the HTML scan. - } - } - - for (const match of body.matchAll(HTML_MENTION_PATTERN)) { - const id = match[1]; - if (id && isUuid(id)) found.add(id.toLowerCase()); - } - - return [...found]; -} diff --git a/backend/src/modules/notification/helpers/readable-access.ts b/backend/src/modules/notification/helpers/readable-access.ts deleted file mode 100644 index a253c15fc..000000000 --- a/backend/src/modules/notification/helpers/readable-access.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ProductEntityType } from 'shared'; -import type { NotificationSubjectRow } from '#/lib/module'; -import { checkAccessFanout } from '#/permissions'; -import { buildSubjectFromEntity } from '#/permissions/build-subject'; -import { accessForUserIds, type UserAccess } from './access-for-users'; - -/** - * Access objects of the given users who may read the row, keyed by user id. Mention derivation - * and the fan-out both need this decision, and the fan-out also reads the memberships for mute. - * Fails closed: an unknown user drops the whole set, as a doctored id must never notify anyone. - */ -export async function readableAccess( - entityType: ProductEntityType, - row: NotificationSubjectRow, - userIds: string[], -): Promise> { - const readable = new Map(); - if (userIds.length === 0) return readable; - - const accessByUser = await accessForUserIds(userIds); - const accesses = userIds.map((userId) => accessByUser.get(userId)).filter((access) => access !== undefined); - if (accesses.length !== userIds.length) return readable; - - const subject = buildSubjectFromEntity(entityType, row); - const decisions = checkAccessFanout(accesses, 'read', subject, { onInvalidMembership: 'deny' }); - accesses.forEach((access, index) => { - if (decisions[index]?.allowed) readable.set(access.actorId, access); - }); - return readable; -} diff --git a/backend/src/modules/notification/helpers/render-digest-html.test.ts b/backend/src/modules/notification/helpers/render-digest-html.test.ts index 7ddedaf59..bbf80d625 100644 --- a/backend/src/modules/notification/helpers/render-digest-html.test.ts +++ b/backend/src/modules/notification/helpers/render-digest-html.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { htmlToExcerpt } from './render-digest-html'; +import { type DigestSection, htmlToExcerpt, renderSectionsHtml } from './render-digest-html'; describe('htmlToExcerpt', () => { it('strips markup and collapses whitespace', () => { @@ -26,3 +26,19 @@ describe('htmlToExcerpt', () => { expect(htmlToExcerpt('short', 100)).toBe('short'); }); }); + +const section: DigestSection = { channelId: 'c1', channelName: 'Ontwerp', lines: ['Nieuwe reactie op Roadmap'], overflow: 3 }; + +describe('renderSectionsHtml', () => { + // The committed locale bundles, so a language missing the line fails here. + it("writes the overflow line in the recipient's language", () => { + expect(renderSectionsHtml([section], 'nl')).toBe('

    Ontwerp

    • Nieuwe reactie op Roadmap
    • en nog 3
    '); + expect(renderSectionsHtml([section], 'en')).toContain('
  • and 3 more
  • '); + }); + + it('leaves the overflow line out when every row is quoted', () => { + expect(renderSectionsHtml([{ ...section, overflow: 0 }], 'en')).toBe( + '

    Ontwerp

    • Nieuwe reactie op Roadmap
    ', + ); + }); +}); diff --git a/backend/src/modules/notification/helpers/render-digest-html.ts b/backend/src/modules/notification/helpers/render-digest-html.ts index 8761f9653..c2ac5bb45 100644 --- a/backend/src/modules/notification/helpers/render-digest-html.ts +++ b/backend/src/modules/notification/helpers/render-digest-html.ts @@ -1,17 +1,18 @@ +import { i18n } from '../../../../emails/i18n'; import { escapeString } from '../../../../emails/renderer/escape-string'; +export interface DigestSection { + channelId: string; + channelName: string; + lines: string[]; + overflow: number; +} + // Bodies are stored as HTML, so they are reduced to plain text before being placed in an email: // arbitrary markup would fight the template's styling. The text is escaped once, where it lands: // Brevo escapes a param it fills, the renderer escapes JSX text. const TAG = /<[^>]*>/g; -const NAMED_ENTITIES: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'", - ' ': ' ', -}; +const NAMED_ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ' }; /** Strip markup, decode the entities a stored body carries, and collapse whitespace. */ function htmlToPlainText(html: string): string { @@ -32,4 +33,31 @@ export function htmlToExcerpt(html: string, maxLength: number): string { return `${cut.slice(0, lastSpace > maxLength * 0.6 ? lastSpace : maxLength)}…`; } -export { escapeString }; +/** + * One digest line as HTML, from `c:email.digest_line.` (apps add theirs to `app.json`) with the + * generic line as fallback. Kept short: the email links through and never reproduces the thread. + * The title is interpolated escaped; any markup around it lives in the translation string. + * @param type - Notification type, selecting the line's translation key. + * @param contextTitle - Title of the item the notification is about; empty renders as `-`. + * @param lng - Recipient language. + * @returns The line, safe to place in the digest's HTML list. + */ +export function describeDigestRow(type: string, contextTitle: string, lng: string): string { + return i18n.t([`c:email.digest_line.${type}`, 'c:email.digest_line.default'], { lng, title: contextTitle || '-' }); +} + +/** + * Digest sections as HTML with every user-derived fragment escaped: the digest mail's declared HTML param. + * @param sections - The sections `buildDigestForUser` assembled. + * @param lng - Recipient language, for the line that counts the rows left out. + * @returns The sections, safe to place in the digest mail. + */ +export function renderSectionsHtml(sections: DigestSection[], lng: string): string { + return sections + .map((section) => { + const items = section.lines.map((line) => `
  • ${line}
  • `).join(''); + const more = section.overflow > 0 ? `
  • ${i18n.t('c:email.digest_overflow', { lng, count: section.overflow })}
  • ` : ''; + return `

    ${escapeString(section.channelName)}

      ${items}${more}
    `; + }) + .join(''); +} diff --git a/backend/src/modules/notification/helpers/subject-names.ts b/backend/src/modules/notification/helpers/subject-names.ts deleted file mode 100644 index d65950144..000000000 --- a/backend/src/modules/notification/helpers/subject-names.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { tenantReadById } from '#/db/tenant-context'; -import { getNotificationSource, loadSubjectNames } from '../notification-sources'; - -interface SubjectRef { - tenantId: string; - entityType: string; - /** The subject or context id to name. */ - id: string | null; -} - -/** - * Display names for subject rows, keyed by id. Names live in tenant-scoped product tables and - * must be read under a tenant transaction: on a bare connection the fail-closed RLS policy - * returns nothing. One round trip per tenant and source type, normally one in total. - */ -export async function findSubjectNames(refs: SubjectRef[]): Promise> { - const idsByTenantAndType = new Map }>(); - for (const ref of refs) { - if (!ref.id) continue; - const key = `${ref.tenantId}:${ref.entityType}`; - const group = idsByTenantAndType.get(key) ?? { tenantId: ref.tenantId, entityType: ref.entityType, ids: new Set() }; - group.ids.add(ref.id); - idsByTenantAndType.set(key, group); - } - - const names = new Map(); - for (const { tenantId, entityType, ids } of idsByTenantAndType.values()) { - const source = getNotificationSource(entityType); - if (!source) continue; - const found = await tenantReadById(tenantId, (tx) => loadSubjectNames(source, tx, [...ids])); - for (const [id, name] of found) names.set(id, name); - } - return names; -} diff --git a/backend/src/modules/notification/notification-db.ts b/backend/src/modules/notification/notification-db.ts index 16c8f261a..22f50dd97 100644 --- a/backend/src/modules/notification/notification-db.ts +++ b/backend/src/modules/notification/notification-db.ts @@ -11,6 +11,9 @@ import { notificationTypes } from './notification-types'; export const digestFrequencies = ['off', 'daily', 'weekly'] as const; export type DigestFrequency = (typeof digestFrequencies)[number]; +/** Cadence of a user without a preferences row, and the column default. */ +export const defaultDigestFrequency: DigestFrequency = 'weekly'; + /** * Per-recipient inbox rows for mentions and addressed activity, fanned out from the product * modules that declare a `notifications` source (see lib/module.ts). Ambient posts stay out: @@ -57,10 +60,7 @@ export const notificationsTable = snakeCase.table( index('notifications_user_unread_index').on(table.userId, table.readAt), index('notifications_user_created_index').on(table.userId, table.createdAt.desc()), index('notifications_subject_index').on(table.subjectId), - foreignKey({ - columns: [table.userId], - foreignColumns: [usersTable.id], - }).onDelete('cascade'), + foreignKey({ columns: [table.userId], foreignColumns: [usersTable.id] }).onDelete('cascade'), ], ); @@ -77,7 +77,7 @@ export const notificationPreferencesTable = snakeCase.table('notification_prefer /** In-app delivery is never opt-out; only email is. */ mentionEmail: boolean().notNull().default(true), commentEmail: boolean().notNull().default(false), - digest: varchar({ enum: digestFrequencies }).notNull().default('weekly'), + digest: varchar({ enum: digestFrequencies }).notNull().default(defaultDigestFrequency), /** Start of the next digest window. Null means "never digested", handled as the first run. */ lastDigestAt: timestamp({ mode: 'string' }), updatedAt: timestampColumns.updatedAt, diff --git a/backend/src/modules/notification/notification-listeners.test.ts b/backend/src/modules/notification/notification-listeners.test.ts new file mode 100644 index 000000000..e2b0b530c --- /dev/null +++ b/backend/src/modules/notification/notification-listeners.test.ts @@ -0,0 +1,43 @@ +import { generateId } from 'shared/utils/entity-id'; +import { describe, expect, it, vi } from 'vitest'; +import { type ActivityEvent, activityBus } from '#/lib/activity-bus'; +import { fanOutNotifications } from './operations/fan-out'; +import { sendPendingInstantEmails } from './operations/send-instant-emails'; +import './notification-listeners'; + +vi.mock('./operations/fan-out', () => ({ fanOutNotifications: vi.fn() })); +vi.mock('./operations/send-instant-emails', () => ({ sendPendingInstantEmails: vi.fn(async () => undefined) })); + +/** A product write as the CDC worker delivers it; the listener reads only these fields. */ +const productWrite = (): ActivityEvent => + ({ + id: `act:${generateId()}`, + type: 'attachment.updated', + action: 'update', + entityType: 'attachment', + subjectId: generateId(), + organizationId: generateId(), + tenantId: 'tenant1', + }) as unknown as ActivityEvent; + +/** Emits the write and waits until the listener has settled. */ +const deliver = async (event: ActivityEvent) => { + activityBus.emit(event); + await vi.waitFor(() => expect(fanOutNotifications).toHaveBeenCalledWith(event)); + await new Promise((resolve) => setImmediate(resolve)); +}; + +describe('notification listeners', () => { + it('runs the instant email pass for the organization after the fan-out wrote a mailable row', async () => { + vi.mocked(fanOutNotifications).mockResolvedValueOnce(true); + const event = productWrite(); + await deliver(event); + expect(sendPendingInstantEmails).toHaveBeenCalledExactlyOnceWith(event.organizationId); + }); + + it('skips the email pass for a write that added no mailable row (no source, no recipient, an edit)', async () => { + vi.mocked(fanOutNotifications).mockResolvedValueOnce(false); + await deliver(productWrite()); + expect(sendPendingInstantEmails).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/notification/notification-listeners.ts b/backend/src/modules/notification/notification-listeners.ts index 75ffeadfe..ccef8f9e0 100644 --- a/backend/src/modules/notification/notification-listeners.ts +++ b/backend/src/modules/notification/notification-listeners.ts @@ -4,16 +4,16 @@ import { log } from '#/utils/logger'; import { fanOutNotifications } from './operations/fan-out'; import { sendPendingInstantEmails } from './operations/send-instant-emails'; -// Activity bus listeners: product writes become per-recipient inbox rows. Registered for every -// product type and gated per event on a declared source (notification-sources.ts), so they are -// inert until a module declares one; deletes are ignored (the inbox drops unreadable rows). +// Activity bus listeners: product writes become per-recipient inbox rows. The fan-out skips types without a declared +// source (notification-sources.ts) and the instant email pass runs only after it wrote a row the pass mails, so both +// stay inert until a module declares one. Deletes are ignored (the inbox drops unreadable rows). for (const entityType of appConfig.productEntityTypes) { for (const action of ['created', 'updated'] as const) { activityBus.on(`${entityType}.${action}`, async (event) => { if (!event.subjectId || !event.organizationId) return; try { - await fanOutNotifications(event); - await sendPendingInstantEmails(event.organizationId); + const mailable = await fanOutNotifications(event); + if (mailable) await sendPendingInstantEmails(event.organizationId); } catch (error) { log.error('Failed to fan out notifications', { error, activityId: event.id }); } diff --git a/backend/src/modules/notification/notification-mocks.ts b/backend/src/modules/notification/notification-mocks.ts index ae2ac4300..8a3137272 100644 --- a/backend/src/modules/notification/notification-mocks.ts +++ b/backend/src/modules/notification/notification-mocks.ts @@ -35,9 +35,7 @@ export function mockSeedNotification( ): InsertNotificationModel { const [deepest] = hierarchy.resolveNonNullAncestors(entityType, subject); const home = - deepest && isChannel(deepest.type) - ? { id: deepest.id, type: deepest.type } - : { id: subject.organizationId, type: 'organization' as const }; + deepest && isChannel(deepest.type) ? { id: deepest.id, type: deepest.type } : { id: subject.organizationId, type: 'organization' as const }; const createdAt = seedNotificationDate(subject.createdAt); return { userId, diff --git a/backend/src/modules/notification/notification-module.ts b/backend/src/modules/notification/notification-module.ts index 923660087..93003bf3a 100644 --- a/backend/src/modules/notification/notification-module.ts +++ b/backend/src/modules/notification/notification-module.ts @@ -1,6 +1,6 @@ import { defineBackendModule } from '#/lib/module'; -import { runDigest } from './digest/run-digest'; import { notificationHandlers } from './notification-handlers'; +import { runDigest } from './operations/run-digest'; import './notification-sources'; defineBackendModule({ diff --git a/backend/src/modules/notification/notification-queries.ts b/backend/src/modules/notification/notification-queries.ts index 84a733115..91beff913 100644 --- a/backend/src/modules/notification/notification-queries.ts +++ b/backend/src/modules/notification/notification-queries.ts @@ -1,14 +1,17 @@ import { and, asc, desc, eq, gte, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'; +import type { AnyPgTable, PgColumn } from 'drizzle-orm/pg-core'; +import { type Access, appConfig } from 'shared'; import { generateId } from 'shared/utils/entity-id'; import type { DbContext } from '#/core/context'; -import { baseDb } from '#/db/db'; +import { type MembershipBaseModel, toMembershipBase } from '#/modules/memberships/helpers/select'; import { membershipsTable } from '#/modules/memberships/memberships-db'; import { systemRolesTable } from '#/modules/system/system-roles-db'; import { emailsTable } from '#/modules/user/emails-db'; import { toUserMinimalBase, type UserMinimalBase } from '#/modules/user/helpers/audit-user'; import { usersTable } from '#/modules/user/user-db'; -import { type DigestFrequency, notificationPreferencesTable, notificationsTable } from './notification-db'; -import type { NotificationType } from './notification-types'; +import { getEntityTable } from '#/tables'; +import { type DigestFrequency, defaultDigestFrequency, notificationPreferencesTable, notificationsTable } from './notification-db'; +import { instantEmailTypes, type NotificationType } from './notification-types'; /** * The recipient still belongs to the notification's organization, or is a system admin. A member who left keeps no @@ -53,18 +56,17 @@ export async function findNotificationsByUser(ctx: DbContext, opts: FindNotifica .selectDistinctOn([notificationsTable.userId, notificationsTable.activityId, notificationsTable.type]) .from(notificationsTable) .where(and(...filters)) - .orderBy( - notificationsTable.userId, - notificationsTable.activityId, - notificationsTable.type, - desc(notificationsTable.createdAt), - ) + .orderBy(notificationsTable.userId, notificationsTable.activityId, notificationsTable.type, desc(notificationsTable.createdAt)) .limit(limit); return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } -export async function countUnreadByUser(ctx: DbContext, userId: string): Promise { +interface CountUnreadByUserOpts { + userId: string; +} + +export async function countUnreadByUser(ctx: DbContext, { userId }: CountUnreadByUserOpts): Promise { const [row] = await ctx.var.db .select({ count: sql`count(distinct (${notificationsTable.activityId}, ${notificationsTable.type}))::int` }) .from(notificationsTable) @@ -73,8 +75,14 @@ export async function countUnreadByUser(ctx: DbContext, userId: string): Promise return row?.count ?? 0; } +interface MarkNotificationsReadOpts { + userId: string; + /** Every unread row of the user when omitted. */ + ids?: string[]; +} + /** Marks the given rows read, or every unread row when `ids` is omitted. Idempotent. */ -export async function markNotificationsRead(ctx: DbContext, userId: string, ids?: string[]): Promise { +export async function markNotificationsRead(ctx: DbContext, { userId, ids }: MarkNotificationsReadOpts): Promise { const filters = [eq(notificationsTable.userId, userId), isNull(notificationsTable.readAt)]; if (ids?.length) filters.push(inArray(notificationsTable.id, ids)); @@ -87,18 +95,17 @@ export async function markNotificationsRead(ctx: DbContext, userId: string, ids? return updated.length; } +interface MarkContextNotificationsReadOpts { + userId: string; + contextId: string; +} + /** Marks everything sharing one context read: the "opening the thread clears its badge" path. */ -export async function markContextNotificationsRead(ctx: DbContext, userId: string, contextId: string): Promise { +export async function markContextNotificationsRead(ctx: DbContext, { userId, contextId }: MarkContextNotificationsReadOpts): Promise { const updated = await ctx.var.db .update(notificationsTable) .set({ readAt: new Date().toISOString() }) - .where( - and( - eq(notificationsTable.userId, userId), - eq(notificationsTable.contextId, contextId), - isNull(notificationsTable.readAt), - ), - ) + .where(and(eq(notificationsTable.userId, userId), eq(notificationsTable.contextId, contextId), isNull(notificationsTable.readAt))) .returning({ id: notificationsTable.id }); return updated.length; @@ -106,33 +113,30 @@ export async function markContextNotificationsRead(ctx: DbContext, userId: strin // ── Preferences ────────────────────────────────────────────────────────────── +interface FindOrCreatePreferencesOpts { + userId: string; +} + /** Preferences row, created on first read so callers never handle a missing row. */ -export async function findOrCreatePreferences(ctx: DbContext, userId: string) { +export async function findOrCreatePreferences(ctx: DbContext, { userId }: FindOrCreatePreferencesOpts) { const { db } = ctx.var; - const [existing] = await db - .select() - .from(notificationPreferencesTable) - .where(eq(notificationPreferencesTable.userId, userId)) - .limit(1); + const [existing] = await db.select().from(notificationPreferencesTable).where(eq(notificationPreferencesTable.userId, userId)).limit(1); if (existing) return existing; const [created] = await db.insert(notificationPreferencesTable).values({ userId }).onConflictDoNothing().returning(); if (created) return created; - const [raced] = await db - .select() - .from(notificationPreferencesTable) - .where(eq(notificationPreferencesTable.userId, userId)) - .limit(1); + const [raced] = await db.select().from(notificationPreferencesTable).where(eq(notificationPreferencesTable.userId, userId)).limit(1); return raced; } -export async function updatePreferences( - ctx: DbContext, - userId: string, - values: { mentionEmail?: boolean; commentEmail?: boolean; digest?: DigestFrequency }, -) { +interface UpdatePreferencesOpts { + userId: string; + values: { mentionEmail?: boolean; commentEmail?: boolean; digest?: DigestFrequency }; +} + +export async function updatePreferences(ctx: DbContext, { userId, values }: UpdatePreferencesOpts) { const [updated] = await ctx.var.db .update(notificationPreferencesTable) .set({ ...values, updatedAt: new Date().toISOString() }) @@ -144,11 +148,16 @@ export async function updatePreferences( // ── Fan-out ────────────────────────────────────────────────────────────────── +interface FindNotifiedUserIdsOpts { + subjectId: string; + userIds: string[]; +} + /** Users already holding a notification for this subject, so an edit cannot notify them twice. */ -export async function findNotifiedUserIds(subjectId: string, userIds: string[]): Promise> { +export async function findNotifiedUserIds(ctx: DbContext, { subjectId, userIds }: FindNotifiedUserIdsOpts): Promise> { if (userIds.length === 0) return new Set(); - const existing = await baseDb + const existing = await ctx.var.db .select({ userId: notificationsTable.userId }) .from(notificationsTable) .where(and(eq(notificationsTable.subjectId, subjectId), inArray(notificationsTable.userId, userIds))); @@ -170,6 +179,10 @@ export interface NotificationInsert { actorId: string | null; } +interface InsertNotificationsIgnoringDuplicatesOpts { + rows: NotificationInsert[]; +} + /** * Insert notifications, skipping any the recipient already has for this activity. * @@ -177,7 +190,7 @@ export interface NotificationInsert { * an arbiter; the `NOT EXISTS` guard absorbs at-least-once redelivery. Safe as the only writer: the * CDC worker holds one backend connection, so the fan-out runs once per event. */ -export async function insertNotificationsIgnoringDuplicates(rows: NotificationInsert[]): Promise { +export async function insertNotificationsIgnoringDuplicates(ctx: DbContext, { rows }: InsertNotificationsIgnoringDuplicatesOpts): Promise { if (rows.length === 0) return; const values = sql.join( @@ -188,7 +201,7 @@ export async function insertNotificationsIgnoringDuplicates(rows: NotificationIn sql`, `, ); - await baseDb.execute(sql` + await ctx.var.db.execute(sql` WITH candidate (id, created_at, user_id, actor_id, type, entity_type, subject_id, context_id, channel_id, channel_type, organization_id, tenant_id, activity_id) AS ( VALUES ${values} ) @@ -202,17 +215,112 @@ export async function insertNotificationsIgnoringDuplicates(rows: NotificationIn `); } +/** Always the identified variant: these are known users, never the anonymous actor. */ +export type UserAccess = Extract, { actorId: string }>; + +interface GetUserAccessOpts { + userIds: string[]; +} + +/** + * Build permission `Access` objects for arbitrary users, connected or not. + * + * `actorFrom`/`accessFrom` read the request context, so they only ever describe the caller, and + * stream fan-out only sees users with an open SSE connection. Notifications must decide what an + * offline user may read, which needs memberships and system-admin status loaded by user id. + * + * Both halves are loaded and paired here on purpose: `accessFrom` warns that hand-assembling an + * Access risks pairing one user's memberships with another's identity, and that warning applies + * with more force to a loop over many users. + */ +export async function getUserAccess(ctx: DbContext, { userIds }: GetUserAccessOpts): Promise> { + const result = new Map(); + if (userIds.length === 0) return result; + + const unique = [...new Set(userIds)]; + + const [memberships, systemAdmins] = await Promise.all([ + ctx.var.db.select().from(membershipsTable).where(inArray(membershipsTable.userId, unique)), + ctx.var.db.select({ userId: systemRolesTable.userId }).from(systemRolesTable).where(eq(systemRolesTable.role, 'admin')), + ]); + + const adminIds = new Set(systemAdmins.map((row) => row.userId)); + + const byUser = new Map(); + for (const membership of memberships) { + const list = byUser.get(membership.userId) ?? []; + list.push(toMembershipBase(membership as Record)); + byUser.set(membership.userId, list); + } + + for (const userId of unique) { + result.set(userId, { + // An offline user is read as a session would be: unmasked. + scopes: null, + actorId: userId, + isSystemAdmin: adminIds.has(userId), + memberships: byUser.get(userId) ?? [], + }); + } + + return result; +} + +/** The two columns every channel table has; the table union needs narrowing to select them. */ +type NamedTable = AnyPgTable & { id: PgColumn; name: PgColumn }; + +interface FindChannelNamesOpts { + channelIds: string[]; +} + +/** + * Display names for a set of channel ids, so a digest can group by channel without the caller + * knowing which table each id lives in. + * + * Driven by `appConfig.channelEntityTypes`, so a hierarchy change is picked up automatically. Channel tables sit outside RLS (application-layer guards cover them), + * and the ids only ever come from rows the recipient was already cleared to read. + */ +export async function findChannelNames(ctx: DbContext, { channelIds }: FindChannelNamesOpts): Promise> { + const names = new Map(); + if (channelIds.length === 0) return names; + + const unique = [...new Set(channelIds)]; + + await Promise.all( + appConfig.channelEntityTypes.map(async (channelType) => { + const table = getEntityTable(channelType) as NamedTable; + const rows = await ctx.var.db.select({ id: table.id, name: table.name }).from(table).where(inArray(table.id, unique)); + for (const row of rows) names.set(String(row.id), String(row.name)); + }), + ); + + return names; +} + // ── Instant email ──────────────────────────────────────────────────────────── +interface FindPendingInstantEmailsOpts { + organizationId: string; + limit: number; +} + /** - * Unmailed mention notifications for recipients who still want the email, oldest first so a - * backlog drains in order. The preferences row is created on first read of the settings, so a - * missing row means the default (on), hence the left join. + * Unmailed rows the instant pass mails, oldest first so a backlog drains in order. A mention goes + * to recipients who keep mention email on; the preferences row is created on first read of the + * settings, so a missing row means the default (on), hence the left join. A comment or reply, when + * the app offers them (`instantEmailTypes`), goes only to recipients who turned comment email on. */ -export async function findPendingMentionEmails(organizationId: string, limit: number) { - return baseDb +export async function findPendingInstantEmails(ctx: DbContext, { organizationId, limit }: FindPendingInstantEmailsOpts) { + const mentionEmailOn = or(isNull(notificationPreferencesTable.userId), eq(notificationPreferencesTable.mentionEmail, true)); + const wantsMail = or( + and(eq(notificationsTable.type, 'mention'), mentionEmailOn), + and(ne(notificationsTable.type, 'mention'), eq(notificationPreferencesTable.commentEmail, true)), + ); + + return ctx.var.db .select({ id: notificationsTable.id, + type: notificationsTable.type, userId: notificationsTable.userId, subjectId: notificationsTable.subjectId, entityType: notificationsTable.entityType, @@ -228,10 +336,10 @@ export async function findPendingMentionEmails(organizationId: string, limit: nu .where( and( eq(notificationsTable.organizationId, organizationId), - eq(notificationsTable.type, 'mention'), + inArray(notificationsTable.type, instantEmailTypes()), + wantsMail, isNull(notificationsTable.emailedAt), isNull(notificationsTable.readAt), - or(isNull(notificationPreferencesTable.userId), eq(notificationPreferencesTable.mentionEmail, true)), recipientStillBelongs, ), ) @@ -239,17 +347,16 @@ export async function findPendingMentionEmails(organizationId: string, limit: nu .limit(limit); } +interface UserIdsOpts { + userIds: string[]; +} + /** Recipients with a verified address; anyone else keeps the in-app notification only. */ -export async function findVerifiedRecipients(userIds: string[]) { +export async function findVerifiedRecipients(ctx: DbContext, { userIds }: UserIdsOpts) { if (userIds.length === 0) return []; - return baseDb - .selectDistinctOn([usersTable.id], { - id: usersTable.id, - email: usersTable.email, - name: usersTable.name, - language: usersTable.language, - }) + return ctx.var.db + .selectDistinctOn([usersTable.id], { id: usersTable.id, email: usersTable.email, name: usersTable.name, language: usersTable.language }) .from(usersTable) .innerJoin(emailsTable, and(eq(emailsTable.userId, usersTable.id), eq(emailsTable.verified, true))) .where(inArray(usersTable.id, userIds)) @@ -257,10 +364,10 @@ export async function findVerifiedRecipients(userIds: string[]) { } /** Minimal user objects for actors, keyed by id; a deleted actor is simply absent. */ -export async function findUsersMinimal(userIds: string[]) { +export async function findUsersMinimal(ctx: DbContext, { userIds }: UserIdsOpts) { if (userIds.length === 0) return new Map(); - const rows = await baseDb + const rows = await ctx.var.db .select({ id: usersTable.id, name: usersTable.name, slug: usersTable.slug, thumbnailUrl: usersTable.thumbnailUrl }) .from(usersTable) .where(inArray(usersTable.id, userIds)); @@ -268,59 +375,91 @@ export async function findUsersMinimal(userIds: string[]) { return new Map(rows.map((row) => [row.id, toUserMinimalBase(row)])); } -export async function findUserNames(userIds: string[]): Promise> { +export async function findUserNames(ctx: DbContext, { userIds }: UserIdsOpts): Promise> { if (userIds.length === 0) return new Map(); - const rows = await baseDb - .select({ id: usersTable.id, name: usersTable.name }) - .from(usersTable) - .where(inArray(usersTable.id, userIds)); + const rows = await ctx.var.db.select({ id: usersTable.id, name: usersTable.name }).from(usersTable).where(inArray(usersTable.id, userIds)); return new Map(rows.map((row) => [row.id, row.name])); } +interface NotificationIdsOpts { + ids: string[]; +} + /** Settles rows the instant-mail pass took, mailed or skipped for good: neither it nor the digest reads them again. */ -export async function stampEmailed(notificationIds: string[]): Promise { - if (notificationIds.length === 0) return; - await baseDb - .update(notificationsTable) - .set({ emailedAt: new Date().toISOString() }) - .where(inArray(notificationsTable.id, notificationIds)); +export async function stampEmailed(ctx: DbContext, { ids }: NotificationIdsOpts): Promise { + if (ids.length === 0) return; + await ctx.var.db.update(notificationsTable).set({ emailedAt: new Date().toISOString() }).where(inArray(notificationsTable.id, ids)); } // ── Digest ─────────────────────────────────────────────────────────────────── -/** Recipients whose digest is due: cadence on, verified address, not yet run for this window. */ -export async function findDueDigestRecipients(dayStart: string, includeWeekly: boolean, limit: number) { - const notRunThisWindow = or( - isNull(notificationPreferencesTable.lastDigestAt), - lt(notificationPreferencesTable.lastDigestAt, dayStart), - ); +interface FindDueDigestRecipientsOpts { + /** Start of today: a recipient already run since then is not due. */ + dayStart: string; + includeWeekly: boolean; + /** How far back each cadence's window reaches at most. */ + earliest: Record<'daily' | 'weekly', string>; + limit: number; +} - const cadences = [and(eq(notificationPreferencesTable.digest, 'daily'), notRunThisWindow)]; - if (includeWeekly) cadences.push(and(eq(notificationPreferencesTable.digest, 'weekly'), notRunThisWindow)); +/** + * Recipients whose digest is due: verified address, cadence on (the default without a preferences row), not yet + * run today, and at least one row `findUndigestedNotifications` would return for the runner's window + * (`lastDigestAt`, no further back than `earliest` for the cadence; see run-digest.ts). A run walks only users + * with something to send. + */ +export async function findDueDigestRecipients(ctx: DbContext, { dayStart, includeWeekly, earliest, limit }: FindDueDigestRecipientsOpts) { + const digest = sql`coalesce(${notificationPreferencesTable.digest}, ${defaultDigestFrequency})`; + const cadences: DigestFrequency[] = includeWeekly ? ['daily', 'weekly'] : ['daily']; + const earliestForCadence = sql`case when ${digest} = 'weekly' + then ${earliest.weekly}::timestamp else ${earliest.daily}::timestamp end`; + // greatest() skips nulls: without a stamp the window starts at the earliest start. + const windowStart = sql`greatest(${notificationPreferencesTable.lastDigestAt}, ${earliestForCadence})`; + const hasUndigested = sql`exists ( + select 1 from ${notificationsTable} + where ${notificationsTable.userId} = ${usersTable.id} + and ${notificationsTable.readAt} is null + and ${notificationsTable.emailedAt} is null + and ${notificationsTable.digestedAt} is null + and ${notificationsTable.createdAt} >= ${windowStart} + and ${recipientStillBelongs} + )`; return ( - baseDb - .selectDistinctOn([notificationPreferencesTable.userId], { - userId: notificationPreferencesTable.userId, - digest: notificationPreferencesTable.digest, + ctx.var.db + .selectDistinctOn([usersTable.id], { + userId: usersTable.id, + digest, lastDigestAt: notificationPreferencesTable.lastDigestAt, email: usersTable.email, language: usersTable.language, }) - .from(notificationPreferencesTable) - .innerJoin(usersTable, eq(usersTable.id, notificationPreferencesTable.userId)) + .from(usersTable) // Verified addresses only; mailing dormant and never-activated accounts helps no one. .innerJoin(emailsTable, and(eq(emailsTable.userId, usersTable.id), eq(emailsTable.verified, true))) - .where(and(ne(notificationPreferencesTable.digest, 'off'), or(...cadences))) - .orderBy(notificationPreferencesTable.userId) + .leftJoin(notificationPreferencesTable, eq(notificationPreferencesTable.userId, usersTable.id)) + .where( + and( + inArray(digest, cadences), + or(isNull(notificationPreferencesTable.lastDigestAt), lt(notificationPreferencesTable.lastDigestAt, dayStart)), + hasUndigested, + ), + ) + .orderBy(usersTable.id) .limit(limit) ); } +interface FindUndigestedNotificationsOpts { + userId: string; + since: string; + limit: number; +} + /** Unread, un-emailed, un-digested rows since `since`; the digest's whole content source. */ -export async function findUndigestedNotifications(userId: string, since: string, limit: number) { +export async function findUndigestedNotifications(ctx: DbContext, { userId, since, limit }: FindUndigestedNotificationsOpts) { const filters = [ eq(notificationsTable.userId, userId), isNull(notificationsTable.readAt), @@ -330,7 +469,7 @@ export async function findUndigestedNotifications(userId: string, since: string, recipientStillBelongs, ]; - return baseDb + return ctx.var.db .selectDistinctOn([notificationsTable.activityId, notificationsTable.type], { id: notificationsTable.id, type: notificationsTable.type, @@ -347,18 +486,21 @@ export async function findUndigestedNotifications(userId: string, since: string, .limit(limit); } -export async function stampDigested(notificationIds: string[]): Promise { - if (notificationIds.length === 0) return; - await baseDb - .update(notificationsTable) - .set({ digestedAt: new Date().toISOString() }) - .where(inArray(notificationsTable.id, notificationIds)); +export async function stampDigested(ctx: DbContext, { ids }: NotificationIdsOpts): Promise { + if (ids.length === 0) return; + await ctx.var.db.update(notificationsTable).set({ digestedAt: new Date().toISOString() }).where(inArray(notificationsTable.id, ids)); +} + +interface StampDigestRunOpts { + userIds: string[]; + ranAt: string; } -export async function stampDigestRun(userIds: string[], ranAt: string): Promise { +/** Upserts, so a user who never saved preferences gets a row with the defaults and the stamp. */ +export async function stampDigestRun(ctx: DbContext, { userIds, ranAt }: StampDigestRunOpts): Promise { if (userIds.length === 0) return; - await baseDb - .update(notificationPreferencesTable) - .set({ lastDigestAt: ranAt }) - .where(inArray(notificationPreferencesTable.userId, userIds)); + await ctx.var.db + .insert(notificationPreferencesTable) + .values(userIds.map((userId) => ({ userId, lastDigestAt: ranAt }))) + .onConflictDoUpdate({ target: notificationPreferencesTable.userId, set: { lastDigestAt: ranAt } }); } diff --git a/backend/src/modules/notification/notification-routes.ts b/backend/src/modules/notification/notification-routes.ts index 0c3b29a34..ed223af74 100644 --- a/backend/src/modules/notification/notification-routes.ts +++ b/backend/src/modules/notification/notification-routes.ts @@ -1,8 +1,8 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { publicGuard, userGuard } from '#/middlewares/guard'; import { syncReadLimiter, tokenLimiter } from '#/middlewares/rate-limiter/limiters'; -import { errorResponseRefs, locationSchema, validIdSchema } from '#/schemas'; +import { locationSchema, validIdSchema } from '#/schemas'; import { unsubscribeCategories } from './helpers/category-token'; import { markReadBodySchema, @@ -13,107 +13,59 @@ import { updatePreferencesBodySchema, } from './notification-schema'; -const notificationRoutes = { - getNotifications: createXRoute({ - operationId: 'getNotifications', +const notificationRoutes = createXRoutes(['notifications'], { + getNotifications: xRoute({ method: 'get', path: '/', xGuard: [userGuard], xRateLimiter: [syncReadLimiter], - tags: ['notifications'], summary: 'List notifications', description: 'Returns the current user notification inbox, newest first, with the unread count. ' + 'Ambient posts are not included: those are covered by unseen counts. ' + 'Rows older than the retention window are removed with their partition.', request: { query: notificationListQuerySchema }, - responses: { - 200: { - description: 'Notifications and unread count', - content: { 'application/json': { schema: notificationListResponseSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Notifications and unread count', notificationListResponseSchema) }, }), - markNotificationsRead: createXRoute({ - operationId: 'markNotificationsRead', + markNotificationsRead: xRoute({ method: 'post', path: '/read', xGuard: [userGuard], - tags: ['notifications'], summary: 'Mark notifications as read', description: - 'Marks specific notifications read by id, everything sharing one context, or all unread ' + - 'notifications when the body is empty. Idempotent.', - request: { - body: { required: true, content: { 'application/json': { schema: markReadBodySchema } } }, - }, - responses: { - 200: { - description: 'Number of notifications marked read', - content: { 'application/json': { schema: markReadResponseSchema } }, - }, - ...errorResponseRefs, - }, + 'Marks specific notifications read by id, everything sharing one context, or all unread notifications when the body is empty. Idempotent.', + request: { body: jsonBody(markReadBodySchema) }, + responses: { 200: json('Number of notifications marked read', markReadResponseSchema) }, }), - getNotificationPreferences: createXRoute({ - operationId: 'getNotificationPreferences', + getNotificationPreferences: xRoute({ method: 'get', path: '/preferences', xGuard: [userGuard], - tags: ['notifications'], summary: 'Get notification preferences', description: 'Email and digest preferences for the current user. In-app delivery is not opt-out.', - responses: { - 200: { - description: 'Notification preferences', - content: { 'application/json': { schema: preferencesSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Notification preferences', preferencesSchema) }, }), - updateNotificationPreferences: createXRoute({ - operationId: 'updateNotificationPreferences', + updateNotificationPreferences: xRoute({ method: 'patch', path: '/preferences', xGuard: [userGuard], - tags: ['notifications'], summary: 'Update notification preferences', description: 'Partial update; unspecified keys keep their stored value.', - request: { - body: { required: true, content: { 'application/json': { schema: updatePreferencesBodySchema } } }, - }, - responses: { - 200: { - description: 'Updated notification preferences', - content: { 'application/json': { schema: preferencesSchema } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(updatePreferencesBodySchema) }, + responses: { 200: json('Updated notification preferences', preferencesSchema) }, }), - unsubscribeNotifications: createXRoute({ - operationId: 'unsubscribeNotifications', + unsubscribeNotifications: xRoute({ method: 'get', path: '/unsubscribe', xGuard: [publicGuard], xRateLimiter: [tokenLimiter('unsubscribe')], - tags: ['notifications'], summary: 'Unsubscribe from a notification category', description: 'Turns off one email category from a link in an email. The token identifies the user and ' + 'the category, so unsubscribing from the digest leaves other email untouched. No auth.', - request: { - query: z.object({ - user: validIdSchema, - category: z.enum(unsubscribeCategories), - token: z.string(), - }), - }, - responses: { - 302: { description: 'Redirect to FE', headers: locationSchema }, - ...errorResponseRefs, - }, + request: { query: z.object({ user: validIdSchema, category: z.enum(unsubscribeCategories), token: z.string() }) }, + responses: { 302: { description: 'Redirect to FE', headers: locationSchema } }, }), -}; +}); export { notificationRoutes }; diff --git a/backend/src/modules/notification/notification-schema.ts b/backend/src/modules/notification/notification-schema.ts index d42c78f49..b787fbfc4 100644 --- a/backend/src/modules/notification/notification-schema.ts +++ b/backend/src/modules/notification/notification-schema.ts @@ -33,10 +33,7 @@ export const notificationListQuerySchema = z.object({ before: z.string().optional().describe('createdAt of the last row of the previous page'), }); -export const notificationListResponseSchema = z.object({ - items: notificationSchema.array(), - unreadCount: z.number().int().min(0), -}); +export const notificationListResponseSchema = z.object({ items: notificationSchema.array(), unreadCount: z.number().int().min(0) }); /** Omitting both marks every unread notification read. */ export const markReadBodySchema = z.object({ @@ -44,14 +41,8 @@ export const markReadBodySchema = z.object({ contextId: validIdSchema.optional().describe('Mark everything sharing one context read'), }); -export const markReadResponseSchema = z.object({ - updated: z.number().int().min(0), -}); +export const markReadResponseSchema = z.object({ updated: z.number().int().min(0) }); -export const preferencesSchema = z.object({ - mentionEmail: z.boolean(), - commentEmail: z.boolean(), - digest: z.enum(digestFrequencies), -}); +export const preferencesSchema = z.object({ mentionEmail: z.boolean(), commentEmail: z.boolean(), digest: z.enum(digestFrequencies) }); export const updatePreferencesBodySchema = preferencesSchema.partial(); diff --git a/backend/src/modules/notification/notification-sources.ts b/backend/src/modules/notification/notification-sources.ts index 33da72282..1c9453879 100644 --- a/backend/src/modules/notification/notification-sources.ts +++ b/backend/src/modules/notification/notification-sources.ts @@ -1,30 +1,23 @@ -import { and, eq, getColumns, inArray, isNull, type SQL } from 'drizzle-orm'; +import { and, getColumns, inArray, isNull, type SQL } from 'drizzle-orm'; import type { AnyPgTable, PgColumn } from 'drizzle-orm/pg-core'; -import type { ProductEntityType, TrackedEventType } from 'shared'; +import type { ProductEntityType } from 'shared'; import { textFromDocument } from 'shared/blocknote'; import type { DbOrTx } from '#/db/db'; -import type { mentionableColumns, productColumns } from '#/db/utils/product-columns'; +import { tenantReadById } from '#/db/tenant-context'; +import type { productColumns } from '#/db/utils/product-columns'; import { publishedRowsPredicate } from '#/db/utils/published-predicate'; -import type { BackendModule, ModuleNotifications, NotificationSubjectRow } from '#/lib/module'; +import type { ModuleNotifications, NotificationSubjectRow } from '#/lib/module'; import { onBackendModuleRegister } from '#/lib/module'; -import { registerMutationHandler } from '#/lib/mutation-bus'; import { getEntityTable } from '#/tables'; import { log } from '#/utils/logger'; -import { deriveMentions } from './operations/derive-mentions'; -/** A product table as `productColumns` and the opt-in `mentionableColumns` shape it. */ -type ProductTable = AnyPgTable & - Record, 'id' | 'name' | 'description' | 'deletedAt'>, PgColumn> & - Partial>; +/** A product table as `productColumns` shapes it. */ +type ProductTable = AnyPgTable & Record, 'id' | 'name' | 'description' | 'deletedAt'>, PgColumn>; -/** One registered source: the module's declaration plus the two facts settled at registration. */ +/** One registered source: the product it covers and the module's declaration. */ export interface NotificationSource { entityType: ProductEntityType; declaration: ModuleNotifications; - /** The declaration's value, else whether the product table carries the `mentions` column. */ - mentionable: boolean; - /** The declaration's value, else `both` when the module registers a Yjs materializer, else `client`. */ - deriveFrom: NonNullable; } /** @@ -40,57 +33,32 @@ onBackendModuleRegister((module) => { log.error('Module declares notifications without productEntity; declaration ignored', { module: module.name }); return; } - const source = registeredSource(module, module.productEntity); - sources.set(module.productEntity, source); - - // Mentions derive in the writing transaction, so the stored `mentions` column is server-owned. - if (source.mentionable) { - for (const action of ['created', 'updated'] as const) { - registerMutationHandler(`${module.productEntity}.${action}` as TrackedEventType, (ctx, payload) => - deriveMentions(ctx, payload, source), - ); - } - } + const declaration = module.notifications === true ? {} : module.notifications; + sources.set(module.productEntity, { entityType: module.productEntity, declaration }); }); -function registeredSource(module: BackendModule, entityType: ProductEntityType): NotificationSource { - const declaration = module.notifications === true ? {} : (module.notifications ?? {}); - return { - entityType, - declaration, - mentionable: declaration.mentionable ?? productTable(entityType).mentions !== undefined, - deriveFrom: declaration.deriveFrom ?? (module.yjsMaterializer ? 'both' : 'client'), - }; -} - export const getNotificationSource = (entityType: string): NotificationSource | undefined => sources.get(entityType); export const getNotificationSourceTypes = (): string[] => [...sources.keys()]; // Subject reads: the declaration's function when the app gave one, else the product table. -/** Audience-bearing rows for the ids: live (non-deleted, published) rows without the body and search text. */ -export async function loadSubjectRows(source: NotificationSource, tx: DbOrTx, ids: string[]) { +/** + * Audience-bearing rows for the ids: live (non-deleted, published) rows without the search text, + * and without the body unless `body` asks for it (the fan-out reads mentions from it). + */ +export async function loadSubjectRows(source: NotificationSource, tx: DbOrTx, ids: string[], { body = false } = {}) { if (source.declaration.loadRows) return source.declaration.loadRows(tx, ids); const table = productTable(source.entityType); - const { description: _description, keywords: _keywords, ...columns } = getColumns(table); - const rows = await tx.select(columns).from(table).where(liveRows(table, ids)); + const { description, keywords: _keywords, ...columns } = getColumns(table); + const rows = await tx + .select(body ? { ...columns, description } : columns) + .from(table) + .where(liveRows(table, ids)); // A product row satisfies NotificationSubjectRow; the generic table select is untyped. return rows as NotificationSubjectRow[]; } -/** Persists the server-derived mention set; false when neither the declaration nor the table can. */ -export async function writeSubjectMentions(source: NotificationSource, tx: DbOrTx, id: string, mentions: string[]) { - if (source.declaration.writeMentions) { - await source.declaration.writeMentions(tx, id, mentions); - return true; - } - const table = productTable(source.entityType); - if (!table.mentions) return false; - await tx.update(table).set({ mentions }).where(eq(table.id, id)); - return true; -} - /** Title and plain-text body for the instant email; null for a row that is gone. */ export async function loadSubjectPreview(source: NotificationSource, tx: DbOrTx, subjectId: string) { if (source.declaration.loadPreview) return source.declaration.loadPreview(tx, subjectId); @@ -111,6 +79,41 @@ export async function loadSubjectNames(source: NotificationSource, tx: DbOrTx, i return new Map(rows.map((row) => [String(row.id), String(row.name ?? '')])); } +// Across tenants: product rows are read under a tenant transaction, since on a bare connection the fail-closed RLS +// policy returns nothing. One round trip per tenant and source type, normally one in total. + +interface SubjectRef { + tenantId: string; + entityType: string; + /** The subject or context id; a ref without one is skipped. */ + id: string | null; +} + +/** Display names for subject rows, keyed by id. */ +export async function findSubjectNames(refs: SubjectRef[]): Promise> { + const names = new Map(); + for (const { tenantId, entityType, ids } of groupByTenantAndType(refs)) { + const source = getNotificationSource(entityType); + if (!source) continue; + const found = await tenantReadById(tenantId, (tx) => loadSubjectNames(source, tx, ids)); + for (const [id, name] of found) names.set(id, name); + } + return names; +} + +/** The refs' ids per tenant and entity type: one tenant transaction and source read each. */ +export function groupByTenantAndType(refs: SubjectRef[]) { + const groups = new Map }>(); + for (const { tenantId, entityType, id } of refs) { + if (!id) continue; + const key = `${tenantId}:${entityType}`; + const group = groups.get(key) ?? { tenantId, entityType, ids: new Set() }; + group.ids.add(id); + groups.set(key, group); + } + return [...groups.values()].map((group) => ({ ...group, ids: [...group.ids] })); +} + // Hoisted: the registration listener above runs at import time. Product tables all carry // productColumns; the registry types them as a union of concrete tables. function productTable(entityType: ProductEntityType): ProductTable { diff --git a/backend/src/modules/notification/notification-types.ts b/backend/src/modules/notification/notification-types.ts index e0f64d6df..d5b88a554 100644 --- a/backend/src/modules/notification/notification-types.ts +++ b/backend/src/modules/notification/notification-types.ts @@ -1,18 +1,23 @@ +import { appConfig } from 'shared'; import { appNotificationTypes } from '#/schemas/app-schemas'; /** * Template-owned types. `mention` is addressed to you personally, so it survives a muted channel * and mails instantly by default; `comment` and `reply` are thread activity an app's - * `resolveRecipients` classifies (projectcampus comments), delivered in-app and by digest. + * `resolveRecipients` classifies, delivered in-app and by digest, and mailed instantly only when + * the app sets `has.commentEmail` and the recipient turns comment emails on. */ const templateNotificationTypes = ['mention', 'comment', 'reply'] as const; /** * Notification vocabulary: the template types plus the app's `appNotificationTypes` (pinned * `app-schemas.ts`). Feeds the column enum, the wire schema and the frontend label keys - * (`c:notification.`). App types behave like `comment`: inbox, digest and push, silenced by - * a muted membership, never mailed instantly. + * (`c:notification.`). App types behave like `comment` without its email: inbox, digest and + * push, silenced by a muted membership, never mailed instantly. */ export const notificationTypes = [...templateNotificationTypes, ...appNotificationTypes] as const; export type NotificationType = (typeof notificationTypes)[number]; + +/** Types the instant email pass mails: mentions, plus comments and replies when the app sets `has.commentEmail`. */ +export const instantEmailTypes = (): NotificationType[] => (appConfig.has.commentEmail ? ['mention', 'comment', 'reply'] : ['mention']); diff --git a/backend/src/modules/notification/operations/build-digest.ts b/backend/src/modules/notification/operations/build-digest.ts new file mode 100644 index 000000000..470f0549a --- /dev/null +++ b/backend/src/modules/notification/operations/build-digest.ts @@ -0,0 +1,61 @@ +import { baseDb } from '#/db/db'; +import { type DigestSection, describeDigestRow } from '../helpers/render-digest-html'; +import { findChannelNames, findUndigestedNotifications, getUserAccess } from '../notification-queries'; +import { findSubjectNames } from '../notification-sources'; +import { findReadableSubjectIds } from './readable-subjects'; + +/** Rows quoted per channel before the section collapses into "and N more". */ +const ROWS_PER_CHANNEL = 5; + +/** Rows considered per digest; a larger backlog is summarised by the overflow counts. */ +const MAX_ROWS = 500; + +/** Runs from the digest job, outside any request. */ +const dbCtx = { var: { db: baseDb } }; + +export interface DigestContent { + notificationIds: string[]; + sections: DigestSection[]; +} + +/** + * Assemble one user's digest for the window `[since, now)`, with lines in the recipient's + * language. The runner bounds `since` (run-digest.ts). + * + * Rows already emailed instantly are excluded, so a mention or comment never arrives twice. So are rows of + * an organization the user left and rows whose subject the user may no longer read: the digest + * names only what the recipient can open. + */ +export async function buildDigestForUser(userId: string, since: Date, lng: string): Promise { + const empty: DigestContent = { notificationIds: [], sections: [] }; + const undigested = await findUndigestedNotifications(dbCtx, { userId, since: since.toISOString(), limit: MAX_ROWS }); + const access = undigested.length ? (await getUserAccess(dbCtx, { userIds: [userId] })).get(userId) : undefined; + if (!access) return empty; + + const readable = await findReadableSubjectIds(access, undigested); + const rows = undigested.filter((row) => readable.has(row.subjectId)); + if (rows.length === 0) return empty; + + const contextNames = await findSubjectNames(rows.map((row) => ({ ...row, id: row.contextId }))); + const channelNames = await findChannelNames(dbCtx, { channelIds: rows.map((row) => row.channelId) }); + + const byChannel = new Map(); + for (const row of rows) { + const list = byChannel.get(row.channelId) ?? []; + list.push(row); + byChannel.set(row.channelId, list); + } + + const sections: DigestSection[] = []; + for (const [channelId, channelRows] of byChannel) { + const visible = channelRows.slice(0, ROWS_PER_CHANNEL); + sections.push({ + channelId, + channelName: channelNames.get(channelId) ?? '', + lines: visible.map((row) => describeDigestRow(row.type, contextNames.get(row.contextId ?? '') ?? '', lng)), + overflow: Math.max(0, channelRows.length - visible.length), + }); + } + + return { notificationIds: rows.map((row) => row.id), sections }; +} diff --git a/backend/src/modules/notification/operations/derive-mentions.test.ts b/backend/src/modules/notification/operations/derive-mentions.test.ts deleted file mode 100644 index aeee11108..000000000 --- a/backend/src/modules/notification/operations/derive-mentions.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { UserContext } from '#/core/context'; -import type { MutationPayload } from '#/lib/mutation-bus'; -import type { NotificationSource } from '../notification-sources'; -import { deriveMentions } from './derive-mentions'; - -// Rows whose stored mentions are stale (body carries none), so a derivation that runs must write -// an empty set without touching the permission engine. -const staleRow = { - id: 'row-1', - createdBy: null, - organizationId: 'org-1', - description: '

    none

    ', - mentions: ['u1'], -}; - -// Test mock: only `var.db` is forwarded to `writeMentions`, so the full Hono context is not built. -const ctx = { var: { db: {} } } as unknown as UserContext; - -const run = async (deriveFrom: NotificationSource['deriveFrom'], payload: MutationPayload) => { - const writeMentions = vi.fn(async () => {}); - const source: NotificationSource = { - entityType: 'attachment', - declaration: { writeMentions }, - mentionable: true, - deriveFrom, - }; - await deriveMentions(ctx, payload, source); - return writeMentions.mock.calls.length; -}; - -describe('deriveMentions deriveFrom', () => { - it('client: skips Yjs materialization', async () => { - expect(await run('client', { after: [staleRow] })).toBe(1); - expect(await run('client', { after: [staleRow], materialized: true })).toBe(0); - }); - - it("materialized: derives only from the relay's materialization, the body of record for Yjs-edited rows", async () => { - expect(await run('materialized', { after: [staleRow], materialized: true })).toBe(1); - expect(await run('materialized', { after: [staleRow] })).toBe(0); - }); - - it('both: derives from either path', async () => { - expect(await run('both', { after: [staleRow] })).toBe(1); - expect(await run('both', { after: [staleRow], materialized: true })).toBe(1); - }); - - it('writes nothing when the derived set already matches the stored one', async () => { - const current = { ...staleRow, mentions: [] }; - expect(await run('both', { after: [current], materialized: true })).toBe(0); - }); - - it('skips rows whose body did not change between before and after', async () => { - expect(await run('both', { before: [staleRow], after: [staleRow] })).toBe(0); - }); -}); diff --git a/backend/src/modules/notification/operations/derive-mentions.ts b/backend/src/modules/notification/operations/derive-mentions.ts deleted file mode 100644 index 0ba6b1fb3..000000000 --- a/backend/src/modules/notification/operations/derive-mentions.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ActorContext } from '#/core/context'; -import type { NotificationSubjectRow } from '#/lib/module'; -import type { MutationPayload } from '#/lib/mutation-bus'; -import { log } from '#/utils/logger'; -import { extractMentionIds } from '../helpers/extract-mentions'; -import { readableAccess } from '../helpers/readable-access'; -import { type NotificationSource, writeSubjectMentions } from '../notification-sources'; - -/** - * Re-derives `mentions` from the stored body, inside the writing transaction, for the writes the - * source's `deriveFrom` counts (registered per mentionable source by notification-sources.ts). - * - * Deriving client-side and storing whatever the client sends would let a hand-crafted request - * notify anyone, including users with no access to the row. Deriving server-side and filtering by - * read permission makes the column trustworthy, which is what the fan-out relies on. - */ -export async function deriveMentions( - ctx: ActorContext, - payload: MutationPayload, - source: NotificationSource, -): Promise { - if (!derivesFrom(source.deriveFrom, payload)) return; - - const rows = (payload.after ?? []) as unknown as NotificationSubjectRow[]; - - for (const [index, row] of rows.entries()) { - // `before`/`after` are index-aligned; an edit that left the body alone changes no mentions. - const before = payload.before?.[index]; - if (before && before.description === row.description) continue; - - const mentioned = extractMentionIds(row.description); - // A mention must never leak a row's existence to someone who may not read it. - const readable = await readableAccess(source.entityType, row, mentioned); - const allowed = mentioned.filter((userId) => readable.has(userId)); - - // Only write when the derived set actually differs, so an unrelated edit is a no-op. - if (sameSet(allowed, row.mentions ?? [])) continue; - - const written = await writeSubjectMentions(source, ctx.var.db, row.id, allowed); - if (!written) { - log.error('Mentionable notification source cannot write mentions; derivation skipped', { - entityType: source.entityType, - }); - return; - } - - if (allowed.length !== mentioned.length) { - log.debug('Dropped mentions the user cannot read', { - entityType: source.entityType, - subjectId: row.id, - dropped: mentioned.length - allowed.length, - }); - } - } -} - -function derivesFrom(mode: NotificationSource['deriveFrom'], payload: MutationPayload): boolean { - if (mode === 'both') return true; - return payload.materialized ? mode === 'materialized' : mode === 'client'; -} - -function sameSet(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - const set = new Set(b); - return a.every((value) => set.has(value)); -} diff --git a/backend/src/modules/notification/operations/fan-out.ts b/backend/src/modules/notification/operations/fan-out.ts index ea2450e2e..22262ff43 100644 --- a/backend/src/modules/notification/operations/fan-out.ts +++ b/backend/src/modules/notification/operations/fan-out.ts @@ -1,72 +1,93 @@ import { appConfig, type ChannelEntityType, hierarchy, isChannel, isProduct, type ProductEntityType } from 'shared'; +import { deriveDocument } from 'shared/utils/derive-description-core'; import { buildNotificationLink } from 'shared/utils/notification-link'; +import { baseDb } from '#/db/db'; import { tenantReadById } from '#/db/tenant-context'; import type { ActivityEvent } from '#/lib/activity-bus'; import type { NotificationSubjectRow } from '#/lib/module'; import { isPushSendConfigured, sendNotificationPush } from '#/modules/push/push-sender'; +import { checkAccessFanout } from '#/permissions'; +import { buildSubjectFromEntity } from '#/permissions/build-subject'; import { log } from '#/utils/logger'; -import { readableAccess } from '../helpers/readable-access'; -import { - findNotifiedUserIds, - insertNotificationsIgnoringDuplicates, - type NotificationInsert, -} from '../notification-queries'; +import { findNotifiedUserIds, getUserAccess, insertNotificationsIgnoringDuplicates, type NotificationInsert } from '../notification-queries'; import { getNotificationSource, loadSubjectRows, type NotificationSource } from '../notification-sources'; -import { type NotificationType, notificationTypes } from '../notification-types'; +import { instantEmailTypes, type NotificationType, notificationTypes } from '../notification-types'; /** Types a muted membership silences. Mentions are deliberately absent: they are addressed to you. */ const mutedTypes = new Set(notificationTypes.filter((type) => type !== 'mention')); type Candidate = { userId: string; type: NotificationType }; +/** Runs off the activity bus, outside any request. */ +const dbCtx = { var: { db: baseDb } }; + /** * Turn one CDC event into per-recipient inbox rows, for entity types whose module declared a - * notification source (lib/module.ts). Mentions come from the server-derived `mentions` column; - * further recipients from the source's `resolveRecipients`. + * notification source (lib/module.ts). Mentions come from the row's stored body when the event + * can carry new ones; further recipients from the source's `resolveRecipients`. Every recipient + * passes the same read check, so a mention in a body the client wrote never reaches a user who + * may not read the row. * * Runs post-commit off the activity bus, so the row is durable before anyone is told about it. + * Resolves true when it wrote a row the instant email pass mails (`instantEmailTypes`): a mention, + * or a comment or reply while the app sets `has.commentEmail`. */ -export async function fanOutNotifications(event: ActivityEvent): Promise { +export async function fanOutNotifications(event: ActivityEvent): Promise { const entityType = event.entityType; - if (!entityType || !isProduct(entityType)) return; + if (!entityType || !isProduct(entityType)) return false; const source = getNotificationSource(entityType); - if (!source) return; + if (!source) return false; const { organizationId, tenantId, id: activityId } = event; - if (!organizationId || !tenantId || !activityId) return; + if (!organizationId || !tenantId || !activityId) return false; const subjectIds = collectSubjectIds(event); - if (subjectIds.length === 0) return; + if (subjectIds.length === 0) return false; - // Batch events carry only permission columns, never `mentions`, so the rows are always re-read. - const rows = await tenantReadById(tenantId, (tx) => loadSubjectRows(source, tx, subjectIds)); + // Batch events carry only permission columns, never the body, so the rows are always re-read. + const readsMentions = source.declaration.mentionable !== false && mayAddMentions(event); + const rows = await tenantReadById(tenantId, (tx) => loadSubjectRows(source, tx, subjectIds, { body: readsMentions })); + let mailable = false; for (const row of rows) { try { - await fanOutRow(event, entityType, source, row, tenantId); + if (await fanOutRow(event, entityType, source, row, tenantId, readsMentions)) mailable = true; } catch (error) { log.error('Notification fan-out failed for row', { error, activityId, subjectId: row.id }); } } + return mailable; +} + +/** + * Whether the event can add mentions: a create, or an update whose changed fields include the + * body. A batch carries its first row's changed fields only and a missing list says nothing, so + * both count as a body change; users told before are skipped either way. + */ +function mayAddMentions(event: ActivityEvent): boolean { + if (event.action === 'create') return true; + if (event.action !== 'update') return false; + if (!event.changedFields || (event.batchRows?.length ?? 0) > 1) return true; + return event.changedFields.includes('description'); } /** Single events name one subject; batches list theirs in `batchRows`. */ function collectSubjectIds(event: ActivityEvent): string[] { if (event.batchRows?.length) { - const ids = event.batchRows - .map((batchRow) => (batchRow.rowData as { id?: unknown })?.id) - .filter((id): id is string => typeof id === 'string'); + const ids = event.batchRows.map((batchRow) => (batchRow.rowData as { id?: unknown })?.id).filter((id): id is string => typeof id === 'string'); if (ids.length) return ids; } return event.subjectId ? [event.subjectId] : []; } +/** Writes one row's notifications; true when one is mailable (a redelivered one too, so its email pass reruns). */ async function fanOutRow( event: ActivityEvent, entityType: ProductEntityType, source: NotificationSource, row: NotificationSubjectRow, tenantId: string, -): Promise { + readsMentions: boolean, +): Promise { const actorId = event.userId ?? row.createdBy ?? null; const candidates = new Map(); @@ -76,7 +97,7 @@ async function fanOutRow( if (!candidates.has(userId)) candidates.set(userId, { userId, type }); }; - if (source.mentionable) for (const mentioned of row.mentions ?? []) add(mentioned, 'mention'); + if (readsMentions) for (const mentioned of deriveDocument(row.description).mentions) add(mentioned, 'mention'); const { resolveRecipients, resolveContextId } = source.declaration; if (resolveRecipients) { @@ -84,25 +105,26 @@ async function fanOutRow( for (const recipient of recipients) add(recipient.userId, recipient.type); } - if (candidates.size === 0) return; + if (candidates.size === 0) return false; // An edit must not re-notify people who were already told about this row. - const notified = event.action === 'update' ? await findNotifiedUserIds(row.id, [...candidates.keys()]) : new Set(); + const notified = event.action === 'update' ? await findNotifiedUserIds(dbCtx, { subjectId: row.id, userIds: [...candidates.keys()] }) : new Set(); const fresh = [...candidates.values()].filter((candidate) => !notified.has(candidate.userId)); - if (fresh.length === 0) return; + if (fresh.length === 0) return false; const allowed = await filterByReadAccess(entityType, row, fresh); - if (allowed.length === 0) return; + if (allowed.length === 0) return false; const channel = resolveChannel(entityType, row); const organizationId = event.organizationId as string; - await insertNotificationsIgnoringDuplicates( - allowed.map((recipient) => ({ + const contextId = resolveContextId ? resolveContextId(row) : row.id; + await insertNotificationsIgnoringDuplicates(dbCtx, { + rows: allowed.map((recipient) => ({ userId: recipient.userId, type: recipient.type, entityType, subjectId: row.id, - contextId: resolveContextId ? resolveContextId(row) : row.id, + contextId, channelId: channel.id, channelType: channel.type, organizationId, @@ -110,7 +132,7 @@ async function fanOutRow( activityId: event.id as string, actorId, })), - ); + }); log.debug('Notifications created', { activityId: event.id, subjectId: row.id, recipientCount: allowed.length }); @@ -125,42 +147,40 @@ async function fanOutRow( channelType: channel.type, entityType, subjectId: row.id, + contextId: contextId ?? undefined, }); await sendNotificationPush( allowed.map((recipient) => recipient.userId), { t: 'notif', activityId: event.id as string, channelId: channel.id, type: primaryType, url }, ); } + const mailed = instantEmailTypes(); + return allowed.some((recipient) => mailed.includes(recipient.type)); } -/** Keep only recipients who may read the row, then drop muted-type candidates whose home membership is muted. */ -async function filterByReadAccess( - entityType: ProductEntityType, - row: NotificationSubjectRow, - candidates: Candidate[], -): Promise { - const readable = await readableAccess( - entityType, - row, - candidates.map((candidate) => candidate.userId), - ); +/** + * Keep only recipients who may read the row, then drop muted-type candidates whose home membership is muted. + * Fails closed: an unknown user drops the whole set, as a doctored id must never notify anyone. + */ +async function filterByReadAccess(entityType: ProductEntityType, row: NotificationSubjectRow, candidates: Candidate[]): Promise { + const accessByUser = await getUserAccess(dbCtx, { userIds: candidates.map((candidate) => candidate.userId) }); + const accesses = candidates.map((candidate) => accessByUser.get(candidate.userId)).filter((access) => access !== undefined); + if (accesses.length !== candidates.length) return []; + + const decisions = checkAccessFanout(accesses, 'read', buildSubjectFromEntity(entityType, row), { onInvalidMembership: 'deny' }); const { id: channelId } = resolveChannel(entityType, row); - return candidates.filter((candidate) => { - const access = readable.get(candidate.userId); - if (!access) return false; + return candidates.filter((candidate, index) => { + if (!decisions[index]?.allowed) return false; if (!mutedTypes.has(candidate.type)) return true; - const muted = access.memberships.some((membership) => membership.channelId === channelId && membership.muted); + const muted = accesses[index].memberships.some((membership) => membership.channelId === channelId && membership.muted); return !muted; }); } /** The row's home channel, row-side twin of `homeChannelIdSql`. */ -function resolveChannel( - entityType: ProductEntityType, - row: NotificationSubjectRow, -): { id: string; type: ChannelEntityType } { +function resolveChannel(entityType: ProductEntityType, row: NotificationSubjectRow): { id: string; type: ChannelEntityType } { const [deepest] = hierarchy.resolveNonNullAncestors(entityType, row); if (deepest && isChannel(deepest.type)) return { id: deepest.id, type: deepest.type }; return { id: row.organizationId, type: 'organization' }; diff --git a/backend/src/modules/notification/operations/get-notifications.ts b/backend/src/modules/notification/operations/get-notifications.ts index b7bdc607e..8bef17f0d 100644 --- a/backend/src/modules/notification/operations/get-notifications.ts +++ b/backend/src/modules/notification/operations/get-notifications.ts @@ -1,11 +1,10 @@ import type { z } from '@hono/zod-openapi'; import type { UserContext } from '#/core/context'; import { accessFrom } from '#/permissions/access'; -import { findChannelNames } from '../helpers/channel-names'; -import { findReadableSubjectIds } from '../helpers/readable-subjects'; -import { findSubjectNames } from '../helpers/subject-names'; -import { countUnreadByUser, findNotificationsByUser, findUsersMinimal } from '../notification-queries'; +import { countUnreadByUser, findChannelNames, findNotificationsByUser, findUsersMinimal } from '../notification-queries'; import type { notificationSchema } from '../notification-schema'; +import { findSubjectNames } from '../notification-sources'; +import { findReadableSubjectIds } from './readable-subjects'; type NotificationResponse = z.infer; @@ -27,17 +26,14 @@ export interface GetNotificationsInput { export async function getNotificationsOp(ctx: UserContext, input: GetNotificationsInput) { const userId = ctx.var.user.id; - const [rows, unreadCount] = await Promise.all([ - findNotificationsByUser(ctx, { userId, ...input }), - countUnreadByUser(ctx, userId), - ]); + const [rows, unreadCount] = await Promise.all([findNotificationsByUser(ctx, { userId, ...input }), countUnreadByUser(ctx, { userId })]); const readable = await findReadableSubjectIds(accessFrom(ctx), rows); const readableRows = rows.filter((row) => readable.has(row.subjectId)); const [actors, channelNames, subjectTitles] = await Promise.all([ - findUsersMinimal(rows.map((row) => row.actorId).filter((id): id is string => id !== null)), - findChannelNames(readableRows.map((row) => row.channelId)), + findUsersMinimal(ctx, { userIds: rows.map((row) => row.actorId).filter((id): id is string => id !== null) }), + findChannelNames(ctx, { channelIds: readableRows.map((row) => row.channelId) }), findSubjectNames(readableRows.map((row) => ({ ...row, id: row.subjectId }))), ]); diff --git a/backend/src/modules/notification/operations/mark-read.ts b/backend/src/modules/notification/operations/mark-read.ts index 52a1b93be..3a05f7b66 100644 --- a/backend/src/modules/notification/operations/mark-read.ts +++ b/backend/src/modules/notification/operations/mark-read.ts @@ -13,7 +13,7 @@ export interface MarkReadInput { export async function markReadOp(ctx: UserContext, input: MarkReadInput) { const userId = ctx.var.user.id; - if (input.contextId) return { updated: await markContextNotificationsRead(ctx, userId, input.contextId) }; + if (input.contextId) return { updated: await markContextNotificationsRead(ctx, { userId, contextId: input.contextId }) }; - return { updated: await markNotificationsRead(ctx, userId, input.ids) }; + return { updated: await markNotificationsRead(ctx, { userId, ids: input.ids }) }; } diff --git a/backend/src/modules/notification/operations/preferences.ts b/backend/src/modules/notification/operations/preferences.ts index 9869fb858..99dbc2f43 100644 --- a/backend/src/modules/notification/operations/preferences.ts +++ b/backend/src/modules/notification/operations/preferences.ts @@ -13,7 +13,7 @@ const toResponse = (row: { mentionEmail: boolean; commentEmail: boolean; digest: }); export async function getPreferencesOp(ctx: UserContext): Promise { - return toResponse(await findOrCreatePreferences(ctx, ctx.var.user.id)); + return toResponse(await findOrCreatePreferences(ctx, { userId: ctx.var.user.id })); } /** @@ -25,6 +25,6 @@ export async function getPreferencesOp(ctx: UserContext): Promise { */ export async function updatePreferencesOp(ctx: UserContext, input: PreferencesUpdate): Promise { const userId = ctx.var.user.id; - await findOrCreatePreferences(ctx, userId); - return toResponse(await updatePreferences(ctx, userId, input)); + await findOrCreatePreferences(ctx, { userId }); + return toResponse(await updatePreferences(ctx, { userId, values: input })); } diff --git a/backend/src/modules/notification/helpers/readable-subjects.ts b/backend/src/modules/notification/operations/readable-subjects.ts similarity index 60% rename from backend/src/modules/notification/helpers/readable-subjects.ts rename to backend/src/modules/notification/operations/readable-subjects.ts index 29ebc4c2b..95c21ffd1 100644 --- a/backend/src/modules/notification/helpers/readable-subjects.ts +++ b/backend/src/modules/notification/operations/readable-subjects.ts @@ -1,8 +1,8 @@ -import { type Access, isProduct, type ProductEntityType } from 'shared'; +import { type Access, isProduct } from 'shared'; import { tenantReadById } from '#/db/tenant-context'; import { checkAccessBatch } from '#/permissions'; import { buildSubjectFromEntity } from '#/permissions/build-subject'; -import { getNotificationSource, loadSubjectRows } from '../notification-sources'; +import { getNotificationSource, groupByTenantAndType, loadSubjectRows } from '../notification-sources'; interface SubjectRef { tenantId: string; @@ -13,23 +13,15 @@ interface SubjectRef { /** * The subjects among `refs` that `access` may read now: the row is live and the permission engine allows read. A * notification went only to readers, but access can end afterwards, so the inbox, the digest and the mention mail ask - * again before they name a subject or its channel. One round trip per tenant and source type. + * again before they name a subject or its channel. */ export async function findReadableSubjectIds(access: Access, refs: SubjectRef[]): Promise> { - const groups = new Map }>(); - for (const { tenantId, entityType, subjectId } of refs) { - if (!isProduct(entityType)) continue; - const key = `${tenantId}:${entityType}`; - const group = groups.get(key) ?? { tenantId, entityType, ids: new Set() }; - group.ids.add(subjectId); - groups.set(key, group); - } - const readable = new Set(); - for (const { tenantId, entityType, ids } of groups.values()) { + for (const { tenantId, entityType, ids } of groupByTenantAndType(refs.map((ref) => ({ ...ref, id: ref.subjectId })))) { + if (!isProduct(entityType)) continue; const source = getNotificationSource(entityType); if (!source) continue; - const rows = await tenantReadById(tenantId, (tx) => loadSubjectRows(source, tx, [...ids])); + const rows = await tenantReadById(tenantId, (tx) => loadSubjectRows(source, tx, ids)); const subjects = rows.map((row) => buildSubjectFromEntity(entityType, row)); const { results } = checkAccessBatch(access, 'read', subjects); for (const [id, { allowed }] of results) if (allowed) readable.add(id); diff --git a/backend/src/modules/notification/operations/run-digest.test.ts b/backend/src/modules/notification/operations/run-digest.test.ts new file mode 100644 index 000000000..d151b5819 --- /dev/null +++ b/backend/src/modules/notification/operations/run-digest.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { windowStart } from './run-digest'; + +// The process time zone decides how JavaScript reads zone-less text; Postgres compares the column as +// UTC. The expectations hold in any zone, so a run outside UTC (TZ=Europe/Amsterdam) catches a local read. +describe('digest windowStart', () => { + const now = new Date(Date.UTC(2026, 9, 1, 12)); + + it('reads the zone-less lastDigestAt column as UTC', () => { + const start = windowStart({ digest: 'daily', lastDigestAt: '2026-10-01 05:00:00.123' }, now); + expect(start.toISOString()).toBe('2026-10-01T05:00:00.123Z'); + }); + + it('starts no earlier than the cadence plus a day', () => { + const start = windowStart({ digest: 'weekly', lastDigestAt: '2026-08-01 05:00:00' }, now); + expect(start.toISOString()).toBe('2026-09-23T12:00:00.000Z'); + }); +}); diff --git a/backend/src/modules/notification/digest/run-digest.ts b/backend/src/modules/notification/operations/run-digest.ts similarity index 66% rename from backend/src/modules/notification/digest/run-digest.ts rename to backend/src/modules/notification/operations/run-digest.ts index c1c130cfe..002dc3dbf 100644 --- a/backend/src/modules/notification/digest/run-digest.ts +++ b/backend/src/modules/notification/operations/run-digest.ts @@ -1,9 +1,11 @@ +import { baseDb } from '#/db/db'; import { mailer } from '#/lib/mailer'; import { log } from '#/utils/logger'; import { digestEmail } from '../emails/digest-email'; import { buildUnsubscribeLink } from '../helpers/category-token'; +import { renderSectionsHtml } from '../helpers/render-digest-html'; import { findDueDigestRecipients, stampDigested, stampDigestRun } from '../notification-queries'; -import { buildDigestForUser, renderSectionsHtml } from './build-digest'; +import { buildDigestForUser } from './build-digest'; /** Recipients handled per run; a backlog simply continues on the next hourly tick. */ const MAX_RECIPIENTS_PER_RUN = 500; @@ -16,17 +18,28 @@ const WEEKLY_ISO_WEEKDAY = 5; const DAY_MS = 24 * 60 * 60 * 1000; +/** Runs from the digest job, outside any request. */ +const dbCtx = { var: { db: baseDb } }; + +/** How far back a digest window reaches at most: the cadence plus a day. */ +const MAX_WINDOW_DAYS = { daily: 2, weekly: 8 }; + +const earliestStart = (cadence: keyof typeof MAX_WINDOW_DAYS, now: Date): Date => new Date(now.getTime() - MAX_WINDOW_DAYS[cadence] * DAY_MS); + /** * Where a recipient's digest window starts: at the stored `lastDigestAt`, so a late or skipped run resumes where the * previous one stopped, but never further back than the cadence plus a day. A first digest, and the first after the - * digest was off, cover recent rows only, never the whole inbox. + * digest was off, cover recent rows only, never the whole inbox. `findDueDigestRecipients` mirrors this in SQL. */ -const windowStart = (recipient: { digest: string; lastDigestAt: string | null }, now: Date): Date => { - const earliest = now.getTime() - (recipient.digest === 'weekly' ? 8 : 2) * DAY_MS; - const last = recipient.lastDigestAt ? new Date(recipient.lastDigestAt).getTime() : earliest; +export const windowStart = (recipient: { digest: string; lastDigestAt: string | null }, now: Date): Date => { + const earliest = earliestStart(recipient.digest === 'weekly' ? 'weekly' : 'daily', now).getTime(); + const last = recipient.lastDigestAt ? utcTimestamp(recipient.lastDigestAt).getTime() : earliest; return new Date(Math.max(last, earliest)); }; +/** A `timestamp` (without zone) column value, which holds UTC: JavaScript would read the bare text as local time. */ +const utcTimestamp = (value: string): Date => new Date(`${value.replace(' ', 'T')}Z`); + /** * One digest pass. * @@ -40,11 +53,12 @@ export async function runDigest(now: Date = new Date()): Promise<{ sent: number; const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0); - const due = await findDueDigestRecipients( - dayStart.toISOString(), - isoWeekday(now) === WEEKLY_ISO_WEEKDAY, - MAX_RECIPIENTS_PER_RUN, - ); + const due = await findDueDigestRecipients(dbCtx, { + dayStart: dayStart.toISOString(), + includeWeekly: isoWeekday(now) === WEEKLY_ISO_WEEKDAY, + earliest: { daily: earliestStart('daily', now).toISOString(), weekly: earliestStart('weekly', now).toISOString() }, + limit: MAX_RECIPIENTS_PER_RUN, + }); if (due.length === 0) return { sent: 0, skipped: 0 }; let sent = 0; @@ -71,7 +85,7 @@ export async function runDigest(now: Date = new Date()): Promise<{ sent: number; }, ]); - await stampDigested(content.notificationIds); + await stampDigested(dbCtx, { ids: content.notificationIds }); processed.push(recipient.userId); sent++; } catch (error) { @@ -81,7 +95,7 @@ export async function runDigest(now: Date = new Date()): Promise<{ sent: number; } // Stamped even when nothing was sent, so an empty window is not re-evaluated all day. - await stampDigestRun(processed, now.toISOString()); + await stampDigestRun(dbCtx, { userIds: processed, ranAt: now.toISOString() }); log.info('Digest run complete', { sent, skipped, considered: due.length }); return { sent, skipped }; diff --git a/backend/src/modules/notification/operations/send-instant-emails.ts b/backend/src/modules/notification/operations/send-instant-emails.ts index 3c928abbf..765098f22 100644 --- a/backend/src/modules/notification/operations/send-instant-emails.ts +++ b/backend/src/modules/notification/operations/send-instant-emails.ts @@ -1,47 +1,64 @@ import { appConfig } from 'shared'; import { buildNotificationLink } from 'shared/utils/notification-link'; +import { baseDb } from '#/db/db'; import { tenantReadById } from '#/db/tenant-context'; import { mailer } from '#/lib/mailer'; import { log } from '#/utils/logger'; +import { commentEmail } from '../emails/comment-email'; import { mentionEmail } from '../emails/mention-email'; -import { accessForUserIds } from '../helpers/access-for-users'; import { buildUnsubscribeLink } from '../helpers/category-token'; -import { findChannelNames } from '../helpers/channel-names'; -import { findReadableSubjectIds } from '../helpers/readable-subjects'; import { htmlToExcerpt } from '../helpers/render-digest-html'; -import { findPendingMentionEmails, findUserNames, findVerifiedRecipients, stampEmailed } from '../notification-queries'; +import { + findChannelNames, + findPendingInstantEmails, + findUserNames, + findVerifiedRecipients, + getUserAccess, + stampEmailed, +} from '../notification-queries'; import { getNotificationSource, loadSubjectPreview } from '../notification-sources'; +import { findReadableSubjectIds } from './readable-subjects'; /** Excerpt length in the email body; longer bodies are truncated. */ const EXCERPT_LENGTH = 250; -/** Notifications handled per pass; a backlog continues on the next event. */ +/** + * Notifications handled per pass. A pass runs after each fan-out in the organization that wrote a + * mailable row, so a backlog beyond this drains on the next one. + */ const MAX_PER_RUN = 200; +/** Runs after a fan-out, outside any request. */ +const dbCtx = { var: { db: baseDb } }; + +type PendingEmail = Awaited>[number]; + /** - * Send instant emails for freshly created mention notifications. + * Send instant emails for freshly created mention notifications, and for comment and reply + * notifications when the app sets `has.commentEmail` (`instantEmailTypes`). * - * Only mentions mail instantly, and only when the recipient has not opted out and may still read - * the subject: access can end between the fan-out and this pass. Every row the pass takes is - * stamped `emailedAt`, mailed or skipped for good (no verified address, access or subject gone): - * the digest never repeats a mailed mention, and a skipped row never holds up the next pass. + * A row mails only when the recipient has not opted out (comment email is opt-in) and may still + * read the subject: access can end between the fan-out and this pass. One mail per recipient and + * subject, a mention before a comment or reply. Every row the pass takes is stamped `emailedAt`, + * mailed, folded into another row's mail or skipped for good (no verified address, access or + * subject gone): the digest never repeats a mailed row, and a skipped row never holds up the next + * pass. */ export async function sendPendingInstantEmails(organizationId: string): Promise { - const pending = await findPendingMentionEmails(organizationId, MAX_PER_RUN); + const pending = await findPendingInstantEmails(dbCtx, { organizationId, limit: MAX_PER_RUN }); if (pending.length === 0) return; - const recipients = await findVerifiedRecipients(pending.map((row) => row.userId)); + const recipients = await findVerifiedRecipients(dbCtx, { userIds: pending.map((row) => row.userId) }); const byUser = new Map(recipients.map((row) => [row.id, row])); const readableByUser = await findReadableByUser(pending); - const actorNames = await findUserNames([ - ...new Set(pending.map((row) => row.actorId).filter((id): id is string => Boolean(id))), - ]); - const channelNames = await findChannelNames(pending.map((row) => row.channelId)); + const actorIds = [...new Set(pending.map((row) => row.actorId).filter((id): id is string => Boolean(id)))]; + const actorNames = await findUserNames(dbCtx, { userIds: actorIds }); + const channelNames = await findChannelNames(dbCtx, { channelIds: pending.map((row) => row.channelId) }); let sent = 0; - for (const notification of pending) { + for (const notification of oneRowPerSubject(pending)) { const user = byUser.get(notification.userId); if (!user) continue; if (!readableByUser.get(notification.userId)?.has(notification.subjectId)) continue; @@ -49,48 +66,60 @@ export async function sendPendingInstantEmails(organizationId: string): Promise< const source = getNotificationSource(notification.entityType); if (!source) continue; - const preview = await tenantReadById(notification.tenantId, (tx) => - loadSubjectPreview(source, tx, notification.subjectId), - ); + const preview = await tenantReadById(notification.tenantId, (tx) => loadSubjectPreview(source, tx, notification.subjectId)); if (!preview) continue; - await mailer.prepareEmails( - mentionEmail, - { - actorName: notification.actorId ? (actorNames.get(notification.actorId) ?? '') : '', - channelName: channelNames.get(notification.channelId) ?? '', - }, - [ - { - email: user.email, - // Per recipient, unlike the newsletter path which mails everyone in the sender's language. - lng: user.language, - subjectTitle: preview.title, - excerpt: htmlToExcerpt(preview.body, EXCERPT_LENGTH), - link: buildNotificationLink(appConfig.frontendUrl, { - tenantId: notification.tenantId, - organizationId: notification.organizationId, - channelId: notification.channelId, - channelType: notification.channelType, - entityType: notification.entityType, - subjectId: notification.subjectId, - nid: notification.id, - }), - unsubscribeLink: buildUnsubscribeLink(user.id, 'mention'), - }, - ], - ); + const statics = { + actorName: notification.actorId ? (actorNames.get(notification.actorId) ?? '') : '', + channelName: channelNames.get(notification.channelId) ?? '', + }; + const recipient = { + email: user.email, + // Per recipient, unlike the newsletter path which mails everyone in the sender's language. + lng: user.language, + subjectTitle: preview.title, + excerpt: htmlToExcerpt(preview.body, EXCERPT_LENGTH), + link: buildNotificationLink(appConfig.frontendUrl, { + tenantId: notification.tenantId, + organizationId: notification.organizationId, + channelId: notification.channelId, + channelType: notification.channelType, + entityType: notification.entityType, + subjectId: notification.subjectId, + contextId: notification.contextId ?? undefined, + nid: notification.id, + }), + }; + + if (notification.type === 'mention') { + await mailer.prepareEmails(mentionEmail, statics, [{ ...recipient, unsubscribeLink: buildUnsubscribeLink(user.id, 'mention') }]); + } else { + await mailer.prepareEmails(commentEmail, { ...statics, reply: notification.type === 'reply' }, [ + { ...recipient, unsubscribeLink: buildUnsubscribeLink(user.id, 'comment') }, + ]); + } sent++; } - await stampEmailed(pending.map((row) => row.id)); - if (sent > 0) log.info('Mention emails sent', { count: sent, organizationId }); + await stampEmailed(dbCtx, { ids: pending.map((row) => row.id) }); + if (sent > 0) log.info('Instant notification emails sent', { count: sent, organizationId }); +} + +/** The row each recipient and subject is mailed for: the first mention, else the first comment or reply. */ +function oneRowPerSubject(pending: PendingEmail[]): PendingEmail[] { + const chosen = new Map(); + for (const row of pending) { + const key = `${row.userId}:${row.subjectId}`; + const current = chosen.get(key); + if (!current || (current.type !== 'mention' && row.type === 'mention')) chosen.set(key, row); + } + return [...chosen.values()]; } -/** Per recipient, the subjects of their pending mentions they may read now. */ -async function findReadableByUser(pending: Awaited>) { - const accessByUser = await accessForUserIds(pending.map((row) => row.userId)); +/** Per recipient, the subjects of their pending rows they may read now. */ +async function findReadableByUser(pending: PendingEmail[]) { + const accessByUser = await getUserAccess(dbCtx, { userIds: pending.map((row) => row.userId) }); const readableByUser = new Map>(); for (const [userId, access] of accessByUser) { const refs = pending.filter((row) => row.userId === userId); diff --git a/backend/src/modules/notification/operations/unsubscribe.ts b/backend/src/modules/notification/operations/unsubscribe.ts index 06b1c245d..9cf165948 100644 --- a/backend/src/modules/notification/operations/unsubscribe.ts +++ b/backend/src/modules/notification/operations/unsubscribe.ts @@ -1,8 +1,8 @@ -import { eq } from 'drizzle-orm'; import { appConfig } from 'shared'; import { AppError } from '#/core/error'; import { baseDb } from '#/db/db'; -import { usersTable } from '#/modules/user/user-db'; +import { updateUser } from '#/modules/system/system-queries'; +import { findUserById } from '#/modules/user/user-queries'; import { type UnsubscribeCategory, verifyCategoryToken } from '../helpers/category-token'; import { findOrCreatePreferences, updatePreferences } from '../notification-queries'; @@ -11,11 +11,7 @@ const errorPage = { willRedirect: true, meta: { errorPagePath: '/auth/error' } } /** Turning the digest off is a frequency change; the other two are booleans. */ const disableFor = (category: Exclude) => - category === 'digest' - ? { digest: 'off' as const } - : category === 'mention' - ? { mentionEmail: false } - : { commentEmail: false }; + category === 'digest' ? { digest: 'off' as const } : category === 'mention' ? { mentionEmail: false } : { commentEmail: false }; /** * Turn off one email category from an emailed link, without a session. @@ -24,24 +20,20 @@ const disableFor = (category: Exclude) => * link proves it was received in that user's mail and authorises exactly that one category. The * newsletter is a flag on the user; the notification categories are email preferences. */ -export async function unsubscribeNotificationsOp( - userId: string, - category: UnsubscribeCategory, - token: string, -): Promise { +export async function unsubscribeNotificationsOp(userId: string, category: UnsubscribeCategory, token: string): Promise { if (!verifyCategoryToken(userId, category, token)) { throw new AppError(401, 'unsubscribe_failed', 'warn', { entityType: 'user', ...errorPage }); } - const [user] = await baseDb.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.id, userId)).limit(1); + const dbCtx = { var: { db: baseDb } }; + const user = await findUserById(dbCtx, { id: userId }); if (!user) throw new AppError(404, 'not_found', 'warn', { entityType: 'user', ...errorPage }); if (category === 'newsletter') { - await baseDb.update(usersTable).set({ newsletter: false }).where(eq(usersTable.id, user.id)); + await updateUser(dbCtx, { id: user.id, values: { newsletter: false } }); } else { - const dbCtx = { var: { db: baseDb } }; - await findOrCreatePreferences(dbCtx, user.id); - await updatePreferences(dbCtx, user.id, disableFor(category)); + await findOrCreatePreferences(dbCtx, { userId: user.id }); + await updatePreferences(dbCtx, { userId: user.id, values: disableFor(category) }); } return new URL('/auth/unsubscribed', appConfig.frontendUrl); diff --git a/backend/src/modules/oauth-server/adapter.ts b/backend/src/modules/oauth-server/adapter.ts index 8aa1dd9f5..f36995598 100644 --- a/backend/src/modules/oauth-server/adapter.ts +++ b/backend/src/modules/oauth-server/adapter.ts @@ -2,7 +2,6 @@ import { z } from '@hono/zod-openapi'; import { and, eq, isNull } from 'drizzle-orm'; import { type Adapter, type AdapterPayload, errors } from 'oidc-provider'; import { baseDb } from '#/db/db'; -import { clientCache } from '#/modules/oauth-server/client-cache'; import { oauthClientsTable } from '#/modules/oauth-server/oauth-clients-db'; import { oidcPayloadsTable } from '#/modules/oauth-server/oidc-payloads-db'; import { revokeGrant } from '#/modules/oauth-server/revoke-grant'; @@ -25,15 +24,8 @@ export function clientKindOf(client: object): AppClientMetadata['client_kind'] | return kind === 'registered' || kind === 'service' ? kind : 'unregistered'; } +/** Read at every lookup, so a disabled account or a changed redirect URI counts at once in every process. */ async function findClient(id: string): Promise { - const cached = clientCache.get(id); - if (cached) return cached; - const client = await loadClient(id); - if (client) clientCache.set(id, client); - return client; -} - -async function loadClient(id: string): Promise { const [app] = await baseDb.select().from(oauthClientsTable).where(eq(oauthClientsTable.id, id)).limit(1); if (app) { return { @@ -148,13 +140,7 @@ export class DrizzleAdapter implements Adapter { const [spent] = await baseDb .update(oidcPayloadsTable) .set({ consumedAt: getIsoDate() }) - .where( - and( - eq(oidcPayloadsTable.type, this.name), - eq(oidcPayloadsTable.id, rowId), - isNull(oidcPayloadsTable.consumedAt), - ), - ) + .where(and(eq(oidcPayloadsTable.type, this.name), eq(oidcPayloadsTable.id, rowId), isNull(oidcPayloadsTable.consumedAt))) .returning({ id: oidcPayloadsTable.id }); if (spent) return; @@ -170,21 +156,14 @@ export class DrizzleAdapter implements Adapter { /** A grant the provider deletes itself (a revoked refresh token, a replayed code) takes its tokens' verdicts along. */ async destroy(id: string): Promise { if (this.name === 'Grant') return revokeGrant({ var: { db: baseDb } }, { grantId: id, withTokens: false }); - await baseDb - .delete(oidcPayloadsTable) - .where(and(eq(oidcPayloadsTable.type, this.name), eq(oidcPayloadsTable.id, this.rowId(id)))); + await baseDb.delete(oidcPayloadsTable).where(and(eq(oidcPayloadsTable.type, this.name), eq(oidcPayloadsTable.id, this.rowId(id)))); } async revokeByGrantId(grantId: string): Promise { - await baseDb - .delete(oidcPayloadsTable) - .where(and(eq(oidcPayloadsTable.type, this.name), eq(oidcPayloadsTable.grantId, grantId))); + await baseDb.delete(oidcPayloadsTable).where(and(eq(oidcPayloadsTable.type, this.name), eq(oidcPayloadsTable.grantId, grantId))); } } function toPayload(row: typeof oidcPayloadsTable.$inferSelect): AdapterPayload { - return { - ...row.payload, - ...(row.consumedAt && { consumed: Math.floor(new Date(row.consumedAt).getTime() / 1000) }), - }; + return { ...row.payload, ...(row.consumedAt && { consumed: Math.floor(new Date(row.consumedAt).getTime() / 1000) }) }; } diff --git a/backend/src/modules/oauth-server/client-cache.ts b/backend/src/modules/oauth-server/client-cache.ts deleted file mode 100644 index 5c9722edb..000000000 --- a/backend/src/modules/oauth-server/client-cache.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TTLCache } from '#/lib/ttl-cache'; -import type { AppClientMetadata } from '#/modules/oauth-server/adapter'; - -/** - * The provider caches only static clients; adapter-loaded ones are cached here, dropped in every process when the - * account changes. Its own module so the API can drop an entry without loading the provider. - */ -export const clientCache = new TTLCache({ maxSize: 1000, defaultTtl: 60_000 }); - -export const invalidateOauthClientCache = (id: string): void => { - clientCache.delete(id); -}; - -/** Drops every client: a process whose invalidation channel reconnects may have missed messages. */ -export const clearOauthClientCache = (): void => clientCache.clear(); diff --git a/backend/src/modules/oauth-server/grant-policy.ts b/backend/src/modules/oauth-server/grant-policy.ts index b7314940c..3559f5553 100644 --- a/backend/src/modules/oauth-server/grant-policy.ts +++ b/backend/src/modules/oauth-server/grant-policy.ts @@ -13,11 +13,7 @@ export interface UserGrantSubject { tenantId: string; } -export type UserGrantRefusal = - | 'unknown_user' - | 'not_a_member' - | 'app_not_installed' - | 'unregistered_clients_not_allowed'; +export type UserGrantRefusal = 'unknown_user' | 'not_a_member' | 'app_not_installed' | 'unregistered_clients_not_allowed'; /** * Whether a person's grant still holds: null while it does, else why not. Consent asks before the grant exists, the @@ -33,10 +29,7 @@ export async function grantRefusal({ userId, clientId, tenantId }: UserGrantSubj const [person] = await baseDb .select({ membershipId: membershipsTable.id }) .from(usersTable) - .leftJoin( - membershipsTable, - and(eq(membershipsTable.userId, usersTable.id), eq(membershipsTable.tenantId, tenantId)), - ) + .leftJoin(membershipsTable, and(eq(membershipsTable.userId, usersTable.id), eq(membershipsTable.tenantId, tenantId))) .where(eq(usersTable.id, userId)) .limit(1); if (!person) return 'unknown_user'; diff --git a/backend/src/modules/oauth-server/interactions.ts b/backend/src/modules/oauth-server/interactions.ts index 7f82ce7e8..af29cf67f 100644 --- a/backend/src/modules/oauth-server/interactions.ts +++ b/backend/src/modules/oauth-server/interactions.ts @@ -47,9 +47,7 @@ export function createInteractionsApp(provider: Provider): Hono app.use(limiterScope); /** The provider lands the user-agent here; the React consent page takes over and calls the JSON routes below. */ - app.get('/oauth/interaction/:uid', (c) => - c.redirect(`${appConfig.frontendUrl}/auth/consent?uid=${c.req.param('uid')}`), - ); + app.get('/oauth/interaction/:uid', (c) => c.redirect(`${appConfig.frontendUrl}/auth/consent?uid=${c.req.param('uid')}`)); app.get('/oauth/interaction/:uid/details', async (c) => { const { signedIn, details } = await loadInteraction(provider, c); @@ -86,10 +84,7 @@ export function createInteractionsApp(provider: Provider): Hono // The browser's earlier grant for this client carries on only when it is this user's own. const existing = interaction.grantId ? await provider.Grant.find(interaction.grantId) : undefined; - const grant = - existing?.accountId === user.id && existing.clientId === clientId - ? existing - : new provider.Grant({ accountId: user.id, clientId }); + const grant = existing?.accountId === user.id && existing.clientId === clientId ? existing : new provider.Grant({ accountId: user.id, clientId }); // Entity scopes are both the provider's scopes and the resource's: the grant records them in both forms. const resource = String(interaction.params.resource); grant.addOIDCScope(details.scopes.join(' ')); diff --git a/backend/src/modules/oauth-server/oauth-server-handlers.ts b/backend/src/modules/oauth-server/oauth-server-handlers.ts index ab5892f02..17ae77d9b 100644 --- a/backend/src/modules/oauth-server/oauth-server-handlers.ts +++ b/backend/src/modules/oauth-server/oauth-server-handlers.ts @@ -1,24 +1,14 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import { accessScopes, appConfig } from 'shared'; import type { Env } from '#/core/context'; import { oauthServerRoutes } from '#/modules/oauth-server/oauth-server-routes'; -import { resourceUri } from '#/modules/oauth-server/resources'; +import { protectedResourceMetadata } from '#/modules/oauth-server/resources'; import { defaultHook } from '#/utils/default-hook'; const app = new OpenAPIHono({ defaultHook }); app.openapi(oauthServerRoutes.getApiProtectedResourceMetadata, async (ctx) => { const { tenantId } = ctx.req.valid('param'); - return ctx.json( - { - resource: resourceUri({ face: 'api', tenantId: tenantId.toLowerCase() }), - authorization_servers: [appConfig.oauthUrl], - scopes_supported: [...accessScopes.all], - bearer_methods_supported: ['header'], - resource_documentation: `${appConfig.frontendUrl}/docs`, - }, - 200, - ); + return ctx.json(protectedResourceMetadata({ face: 'api', tenantId: tenantId.toLowerCase() }), 200); }); export const oauthServerHandlers = app; diff --git a/backend/src/modules/oauth-server/oauth-server-mocks.ts b/backend/src/modules/oauth-server/oauth-server-mocks.ts new file mode 100644 index 000000000..96ac37692 --- /dev/null +++ b/backend/src/modules/oauth-server/oauth-server-mocks.ts @@ -0,0 +1,8 @@ +import { mockTenantId, mockUuid, withFakerSeed } from '#/mocks'; +import { protectedResourceMetadata, type ResourceRef } from '#/modules/oauth-server/resources'; + +export const mockProtectedResourceResponse = (face: ResourceRef['face'] = 'api', key = `protectedResource:${face}`) => + withFakerSeed(key, () => { + const tenantId = mockTenantId(); + return protectedResourceMetadata(face === 'mcp' ? { face, tenantId, organizationId: mockUuid() } : { face, tenantId }); + }); diff --git a/backend/src/modules/oauth-server/oauth-server-queries.ts b/backend/src/modules/oauth-server/oauth-server-queries.ts index fc1227380..caa3593b9 100644 --- a/backend/src/modules/oauth-server/oauth-server-queries.ts +++ b/backend/src/modules/oauth-server/oauth-server-queries.ts @@ -1,6 +1,7 @@ import { z } from '@hono/zod-openapi'; import { and, eq, inArray, sql } from 'drizzle-orm'; import type { DbContext } from '#/core/context'; +import { actorsTable } from '#/modules/actors/actors-db'; import { membershipsTable } from '#/modules/memberships/memberships-db'; import { oauthClientsTable } from '#/modules/oauth-server/oauth-clients-db'; import { oidcPayloadsTable } from '#/modules/oauth-server/oidc-payloads-db'; @@ -21,11 +22,7 @@ export async function getConsentTargetNames( ctx: DbContext, { userId, resource }: GetConsentTargetNamesOpts, ): Promise<{ tenant: string | null; organization: string | null }> { - const [tenant] = await ctx.var.db - .select({ name: tenantsTable.name }) - .from(tenantsTable) - .where(eq(tenantsTable.id, resource.tenantId)) - .limit(1); + const [tenant] = await ctx.var.db.select({ name: tenantsTable.name }).from(tenantsTable).where(eq(tenantsTable.id, resource.tenantId)).limit(1); // The resource grammar takes any path segment for the organization; only a uuid can name one. if (resource.face !== 'mcp' || !z.uuid().safeParse(resource.organizationId).success) { return { tenant: tenant?.name ?? null, organization: null }; @@ -33,10 +30,7 @@ export async function getConsentTargetNames( const [organization] = await ctx.var.db .select({ name: organizationsTable.name }) .from(organizationsTable) - .innerJoin( - membershipsTable, - and(eq(membershipsTable.organizationId, organizationsTable.id), eq(membershipsTable.userId, userId)), - ) + .innerJoin(membershipsTable, and(eq(membershipsTable.organizationId, organizationsTable.id), eq(membershipsTable.userId, userId))) .where(and(eq(organizationsTable.id, resource.organizationId), eq(organizationsTable.tenantId, resource.tenantId))) .limit(1); return { tenant: tenant?.name ?? null, organization: organization?.name ?? null }; @@ -56,17 +50,26 @@ export async function findConsentOfUser(ctx: DbContext, { grantId, userId }: { g const [grant] = await ctx.var.db .select({ id: oidcPayloadsTable.id }) .from(oidcPayloadsTable) - .where( - and( - eq(oidcPayloadsTable.type, 'Grant'), - eq(oidcPayloadsTable.id, grantId), - eq(oidcPayloadsTable.accountId, userId), - ), - ) + .where(and(eq(oidcPayloadsTable.type, 'Grant'), eq(oidcPayloadsTable.id, grantId), eq(oidcPayloadsTable.accountId, userId))) .limit(1); return grant; } +/** + * What a person's token rests on at each use: the user's bindings version while the grant it names exists, undefined + * once the grant is gone (revoked, replayed, refused at refresh, or the account deleted). + */ +export async function findLiveGrantBindings(ctx: DbContext, { grantId, userId }: { grantId: string; userId: string }) { + // The text columns take any claim as it came; the stored account id is a user's, so the cast that joins it holds. + const [live] = await ctx.var.db + .select({ bindingsVersion: actorsTable.bindingsVersion }) + .from(oidcPayloadsTable) + .innerJoin(actorsTable, eq(actorsTable.id, sql`${oidcPayloadsTable.accountId}::uuid`)) + .where(and(eq(oidcPayloadsTable.type, 'Grant'), eq(oidcPayloadsTable.id, grantId), eq(oidcPayloadsTable.accountId, userId))) + .limit(1); + return live; +} + interface DeleteProviderSessionsOfUserOpts { userId: string; } @@ -75,13 +78,8 @@ interface DeleteProviderSessionsOfUserOpts { * The authorization server's sessions of a user, in every browser: none answers a client for them any more until they * consent again. Their grants and refresh tokens stay. */ -export async function deleteProviderSessionsOfUser( - ctx: DbContext, - { userId }: DeleteProviderSessionsOfUserOpts, -): Promise { - await ctx.var.db - .delete(oidcPayloadsTable) - .where(and(eq(oidcPayloadsTable.type, 'Session'), eq(oidcPayloadsTable.accountId, userId))); +export async function deleteProviderSessionsOfUser(ctx: DbContext, { userId }: DeleteProviderSessionsOfUserOpts): Promise { + await ctx.var.db.delete(oidcPayloadsTable).where(and(eq(oidcPayloadsTable.type, 'Session'), eq(oidcPayloadsTable.accountId, userId))); } interface DeleteProviderSessionOpts { @@ -91,9 +89,7 @@ interface DeleteProviderSessionOpts { /** One authorization server session. */ export async function deleteProviderSession(ctx: DbContext, { id }: DeleteProviderSessionOpts): Promise { - await ctx.var.db - .delete(oidcPayloadsTable) - .where(and(eq(oidcPayloadsTable.type, 'Session'), eq(oidcPayloadsTable.id, id))); + await ctx.var.db.delete(oidcPayloadsTable).where(and(eq(oidcPayloadsTable.type, 'Session'), eq(oidcPayloadsTable.id, id))); } /** Everything the authorization server holds for these users (grants, codes, refresh tokens, sessions): an account deletion. */ diff --git a/backend/src/modules/oauth-server/oauth-server-routes.ts b/backend/src/modules/oauth-server/oauth-server-routes.ts index f3cd11e94..cbfd38965 100644 --- a/backend/src/modules/oauth-server/oauth-server-routes.ts +++ b/backend/src/modules/oauth-server/oauth-server-routes.ts @@ -1,39 +1,21 @@ -import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, xRoute } from '#/core/x-routes'; import { publicGuard } from '#/middlewares/guard'; -import { errorResponseRefs, tenantOnlyParamSchema } from '#/schemas'; +import { mockProtectedResourceResponse } from '#/modules/oauth-server/oauth-server-mocks'; +import { protectedResourceSchema } from '#/modules/oauth-server/oauth-server-schema'; +import { tenantOnlyParamSchema } from '#/schemas'; -/** RFC 9728 protected resource metadata: what a client reads to find the authorization server of a resource. */ -export const protectedResourceSchema = z - .object({ - resource: z.string(), - authorization_servers: z.array(z.string()), - scopes_supported: z.array(z.string()), - bearer_methods_supported: z.array(z.string()), - resource_documentation: z.string(), - }) - .openapi('ProtectedResourceMetadata'); - -const oauthServerRoutes = { - getApiProtectedResourceMetadata: createXRoute({ - 'x-service': 'oauth', - operationId: 'getApiProtectedResourceMetadata', +const oauthServerRoutes = createXRoutes(['oauth-server', 'cella'], { + getApiProtectedResourceMetadata: xRoute({ method: 'get', path: '/{tenantId}/.well-known/oauth-protected-resource', + xEnabledBy: { service: 'oauth' }, xGuard: [publicGuard], - tags: ['oauth-server', 'cella'], summary: 'Protected resource metadata (API)', description: 'RFC 9728 metadata of this tenant as an API resource: its resource identifier, the authorization server that issues tokens for it, and the scopes it understands.', request: { params: tenantOnlyParamSchema }, - responses: { - 200: { - description: 'Protected resource metadata', - content: { 'application/json': { schema: protectedResourceSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Protected resource metadata', protectedResourceSchema, mockProtectedResourceResponse()) }, }), -}; +}); export { oauthServerRoutes }; diff --git a/backend/src/modules/oauth-server/oauth-server-schema.ts b/backend/src/modules/oauth-server/oauth-server-schema.ts new file mode 100644 index 000000000..89e12b2e9 --- /dev/null +++ b/backend/src/modules/oauth-server/oauth-server-schema.ts @@ -0,0 +1,18 @@ +import { z } from '@hono/zod-openapi'; +import { schemaTags } from '#/core/openapi-helpers'; +import { mockProtectedResourceResponse } from './oauth-server-mocks'; + +/** RFC 9728 protected resource metadata: what a client reads to find the authorization server of a resource. */ +export const protectedResourceSchema = z + .object({ + resource: z.string(), + authorization_servers: z.array(z.string()), + scopes_supported: z.array(z.string()), + bearer_methods_supported: z.array(z.string()), + resource_documentation: z.string(), + }) + .openapi('ProtectedResourceMetadata', { + description: 'RFC 9728 metadata of a protected resource: the authorization servers that issue its tokens and the scopes it accepts.', + example: mockProtectedResourceResponse(), + 'x-tags': schemaTags('data', 'oauth-server', 'cella'), + }); diff --git a/backend/src/modules/oauth-server/oidc-payloads-sweep.ts b/backend/src/modules/oauth-server/oidc-payloads-sweep.ts index 1947c8aaa..4c4b953a5 100644 --- a/backend/src/modules/oauth-server/oidc-payloads-sweep.ts +++ b/backend/src/modules/oauth-server/oidc-payloads-sweep.ts @@ -25,8 +25,4 @@ export async function sweepOidcPayloads(): Promise { } /** Hourly, on the jobs worker; the quarter-hour offset keeps it clear of the digest tick. */ -export const oidcPayloadsSweepJob: BackendJob = { - name: 'oidc-payloads-sweep', - cron: '15 * * * *', - run: () => sweepOidcPayloads(), -}; +export const oidcPayloadsSweepJob: BackendJob = { name: 'oidc-payloads-sweep', cron: '15 * * * *', run: () => sweepOidcPayloads() }; diff --git a/backend/src/modules/oauth-server/provider.ts b/backend/src/modules/oauth-server/provider.ts index 447e13aea..988341be5 100644 --- a/backend/src/modules/oauth-server/provider.ts +++ b/backend/src/modules/oauth-server/provider.ts @@ -48,9 +48,7 @@ function grantableScopes(ctx: object, client: object): readonly AccessScope[] { * Claims this server adds to every access token; the guard reads them to build the actor, and asks the grant policy * about the grant (`gid`) or API key (`key_id`) the token rests on. */ -export type IssuedTokenClaims = - | { actor_kind: 'user'; tenant_id: string; gid: string } - | { actor_kind: 'service'; tenant_id: string; key_id: string }; +export type IssuedTokenClaims = { actor_kind: 'user'; tenant_id: string; gid: string } | { actor_kind: 'service'; tenant_id: string; key_id: string }; /** The code or refresh token a grant is used through at the token endpoint, as `findAccount` receives it. */ type GrantSource = { clientId?: string; grantId?: string; resource?: unknown }; @@ -66,9 +64,7 @@ async function accountMayUseGrant(sub: string, source: GrantSource | undefined): const [user] = await baseDb.select({ id: usersTable.id }).from(usersTable).where(eq(usersTable.id, sub)).limit(1); return !!user; } - const tenantIds = [source.resource] - .flat() - .map((uri) => (typeof uri === 'string' ? parseResource(uri)?.tenantId : null)); + const tenantIds = [source.resource].flat().map((uri) => (typeof uri === 'string' ? parseResource(uri)?.tenantId : null)); if (!tenantIds.every((tenantId): tenantId is string => !!tenantId)) return false; let refusal: string | null = null; @@ -148,14 +144,9 @@ export async function createProvider(): Promise { const resource = parseResource(resourceIndicator); if (!resource) throw new errors.InvalidTarget(); // A service account acts in its own tenant: its token never names another tenant's resource. - if (clientKindOf(client) === 'service' && presentedKeys.get(ctx)?.tenantId !== resource.tenantId) - throw new errors.InvalidTarget(); - return { - scope: grantableScopes(ctx, client).join(' '), - audience: resourceIndicator, - accessTokenFormat: 'jwt', - accessTokenTTL: HOUR, - }; + if (clientKindOf(client) === 'service' && presentedKeys.get(ctx)?.tenantId !== resource.tenantId) throw new errors.InvalidTarget(); + const scope = grantableScopes(ctx, client).join(' '); + return { scope, audience: resourceIndicator, accessTokenFormat: 'jwt', accessTokenTTL: HOUR }; }, }, }, @@ -179,8 +170,7 @@ export async function createProvider(): Promise { // Same origin as the API: the interaction cookie is scoped to this path, and the page under it reads the session. url: (_ctx, interaction) => `/oauth/interaction/${interaction.uid}`, }, - findAccount: async (_ctx, sub, token) => - (await accountMayUseGrant(sub, token)) ? { accountId: sub, claims: async () => ({ sub }) } : undefined, + findAccount: async (_ctx, sub, token) => ((await accountMayUseGrant(sub, token)) ? { accountId: sub, claims: async () => ({ sub }) } : undefined), extraTokenClaims: (ctx, token) => { const aud = Array.isArray(token.aud) ? token.aud[0] : token.aud; const resource = parseResource(aud ?? ''); @@ -199,11 +189,7 @@ export async function createProvider(): Promise { renderError: async (ctx, out, error) => { // A client's own mistake (bad PKCE, expired code, refusal) is request noise; only the server's faults are warnings. const level = ctx.status >= 500 ? 'warn' : 'info'; - log[level]('OAuth server error', { - error: out.error, - description: out.error_description, - ...(level === 'warn' && { err: error }), - }); + log[level]('OAuth server error', { error: out.error, description: out.error_description, ...(level === 'warn' && { err: error }) }); ctx.type = 'json'; ctx.body = out; }, @@ -214,17 +200,11 @@ export async function createProvider(): Promise { // Secrets are never stored in plaintext: a registered app's secret is compared by hash, a service account's client // secret is any of its live secret keys, whose scopes then cap the token (`grantableScopes`). - provider.Client.prototype.compareClientSecret = async function compare( - this: { clientId: string; clientSecret?: string }, - actual: string, - ) { + provider.Client.prototype.compareClientSecret = async function compare(this: { clientId: string; clientSecret?: string }, actual: string) { const presented = hashToken(actual); if (clientKindOf(this) === 'service') { // Read here, not from the cached client: a disabled account stops minting the moment it is disabled. - const found = await findApiKeyWithAccount( - { var: { db: baseDb } }, - { key: { hash: presented }, actorId: this.clientId }, - ); + const found = await findApiKeyWithAccount({ var: { db: baseDb } }, { key: { hash: presented }, actorId: this.clientId }); if (!found || apiKeyRefusal(found.apiKey, found.account)) return false; const { apiKey, account } = found; const ctx = Provider.ctx; diff --git a/backend/src/modules/oauth-server/resources.ts b/backend/src/modules/oauth-server/resources.ts index ad48165d3..be0b18fa4 100644 --- a/backend/src/modules/oauth-server/resources.ts +++ b/backend/src/modules/oauth-server/resources.ts @@ -1,13 +1,11 @@ -import { appConfig } from 'shared'; +import { accessScopes, appConfig } from 'shared'; /** The two audiences a token from this server can carry: the MCP server of an organization, or the REST API of a tenant. */ export type ResourceRef = { face: 'mcp'; tenantId: string; organizationId: string } | { face: 'api'; tenantId: string }; /** RFC 8707 resource identifiers are tenant-qualified (D7), so a token never crosses tenants. */ export function resourceUri(ref: ResourceRef): string { - return ref.face === 'mcp' - ? `${appConfig.mcpUrl}/${ref.tenantId}/${ref.organizationId}/mcp` - : `${appConfig.backendUrl}/t/${ref.tenantId}`; + return ref.face === 'mcp' ? `${appConfig.mcpUrl}/${ref.tenantId}/${ref.organizationId}/mcp` : `${appConfig.backendUrl}/t/${ref.tenantId}`; } const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -27,3 +25,14 @@ export function parseResource(uri: string): ResourceRef | null { export function resourceMetadataUrl(ref: ResourceRef): string { return `${resourceUri(ref)}/.well-known/oauth-protected-resource`; } + +/** RFC 9728: the metadata document served at `resourceMetadataUrl`. */ +export function protectedResourceMetadata(ref: ResourceRef) { + return { + resource: resourceUri(ref), + authorization_servers: [appConfig.oauthUrl], + scopes_supported: [...accessScopes.all], + bearer_methods_supported: ['header'], + resource_documentation: `${appConfig.frontendUrl}/docs`, + }; +} diff --git a/backend/src/modules/oauth-server/revoke-grant.ts b/backend/src/modules/oauth-server/revoke-grant.ts index 08904d1ce..747bb01c0 100644 --- a/backend/src/modules/oauth-server/revoke-grant.ts +++ b/backend/src/modules/oauth-server/revoke-grant.ts @@ -1,5 +1,5 @@ import type { DbContext } from '#/core/context'; -import { type AuthInvalidation, dropCachedAuth, publishAuthInvalidation } from '#/middlewares/guard/invalidate-cache'; +import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; import { deleteConsentWithTokens, deleteGrant } from '#/modules/oauth-server/oauth-server-queries'; interface RevokeGrantOpts { @@ -9,22 +9,15 @@ interface RevokeGrantOpts { } /** - * Deletes a grant, with every token issued under it unless the provider deletes those itself, and stops its access - * tokens: the verdicts on them drop here at once, and in every other process through `auth_invalidate` when the - * delete commits. + * Deletes a grant, with every token issued under it unless the provider deletes those itself. Its access tokens stop at + * their next request in every process: the guards read the grant at every use. * @param ctx - Any context with a database. * @param opts - The grant, and whether its tokens go with it. */ export async function revokeGrant(ctx: DbContext, { grantId, withTokens = true }: RevokeGrantOpts): Promise { - const revoked = await ctx.var.db.transaction(async (tx) => { + const accountId = await ctx.var.db.transaction(async (tx) => { const txCtx = { var: { db: tx } }; - const accountId = withTokens - ? await deleteConsentWithTokens(txCtx, { grantId }) - : await deleteGrant(txCtx, { grantId }); - if (!accountId) return null; - const invalidation: AuthInvalidation = { grant: { accountId, grantId } }; - await publishAuthInvalidation(tx, invalidation); - return invalidation; + return withTokens ? await deleteConsentWithTokens(txCtx, { grantId }) : await deleteGrant(txCtx, { grantId }); }); - if (revoked) dropCachedAuth(revoked); + if (accountId) invalidateCache.grant(accountId, grantId); } diff --git a/backend/src/modules/oauth-server/server.ts b/backend/src/modules/oauth-server/server.ts index 9d9d2ce8b..098093165 100644 --- a/backend/src/modules/oauth-server/server.ts +++ b/backend/src/modules/oauth-server/server.ts @@ -11,17 +11,13 @@ import { baseDb } from '#/db/db'; import { env } from '#/env'; import { appErrorHandler } from '#/lib/error'; import { type HealthComponent, mapDatabaseComponent, rollupStatus } from '#/lib/health-helpers'; -import { authInvalidationHealth } from '#/middlewares/guard/invalidation-listener'; import { limiterScope } from '#/middlewares/rate-limiter/helpers'; import { createInteractionsApp } from '#/modules/oauth-server/interactions'; import { signingKeysTable } from '#/modules/oauth-server/signing-keys-db'; export const OAUTH_MOUNT = '/oauth'; -/** - * The `?depth=full` diagnostics, in the API's component shape: the store answers, a signing key exists and this - * process hears auth invalidations; any of them missing is a 503. - */ +/** The `?depth=full` diagnostics, in the API's component shape: the store answers and a signing key exists, else 503. */ async function probeHealth(): Promise<{ httpStatus: number; body: unknown }> { const components: Record = {}; const startedAt = Date.now(); @@ -29,11 +25,7 @@ async function probeHealth(): Promise<{ httpStatus: number; body: unknown }> { try { await baseDb.execute(sql`select 1`); components.database = mapDatabaseComponent(true, Date.now() - startedAt); - const [key] = await baseDb - .select({ id: signingKeysTable.id }) - .from(signingKeysTable) - .where(eq(signingKeysTable.status, 'current')) - .limit(1); + const [key] = await baseDb.select({ id: signingKeysTable.id }).from(signingKeysTable).where(eq(signingKeysTable.status, 'current')).limit(1); signingKey = key !== undefined; } catch { // The store did not answer, or the key query it reached first did not: reported below. @@ -42,12 +34,8 @@ async function probeHealth(): Promise<{ httpStatus: number; body: unknown }> { components.signingKey = signingKey ? { status: 'healthy', checkedVia: 'local' } : { status: 'unhealthy', checkedVia: 'local', reason: 'signing_key_missing' }; - components.authInvalidation = authInvalidationHealth(); const status = rollupStatus(components, new Set(Object.keys(components))); - return { - httpStatus: status === 'unhealthy' ? 503 : 200, - body: { status, uptime: Math.floor(process.uptime()), components }, - }; + return { httpStatus: status === 'unhealthy' ? 503 : 200, body: { status, uptime: Math.floor(process.uptime()), components } }; } type Listener = (req: IncomingMessage, res: ServerResponse) => void; @@ -82,12 +70,7 @@ export function createOauthListener(provider: Provider): Listener { return (req, res) => { const url = req.url ?? '/'; - if ( - url === '/health' || - url.startsWith('/health?') || - url === `${OAUTH_MOUNT}/health` || - url.startsWith(`${OAUTH_MOUNT}/health?`) - ) { + if (url === '/health' || url.startsWith('/health?') || url === `${OAUTH_MOUNT}/health` || url.startsWith(`${OAUTH_MOUNT}/health?`)) { req.url = url.replace(OAUTH_MOUNT, ''); health(req, res); return; diff --git a/backend/src/modules/oauth-server/verify-access-token.ts b/backend/src/modules/oauth-server/verify-access-token.ts index d74a963f4..b714cd921 100644 --- a/backend/src/modules/oauth-server/verify-access-token.ts +++ b/backend/src/modules/oauth-server/verify-access-token.ts @@ -16,9 +16,7 @@ interface VerifiedToken { } /** A person's token names the grant it was issued under, a service account's the API key it was minted with. */ -export type VerifiedAccessToken = - | (VerifiedToken & { kind: 'user'; grantId: string }) - | (VerifiedToken & { kind: 'service'; keyId: string }); +export type VerifiedAccessToken = (VerifiedToken & { kind: 'user'; grantId: string }) | (VerifiedToken & { kind: 'service'; keyId: string }); /** A bearer value that is a JWT (three segments); this app's opaque keys carry no dots. */ export function bearerJwtFrom(ctx: Context): string | null { @@ -33,31 +31,20 @@ export function bearerJwtFrom(ctx: Context): string | null { * to the route's tenant and organization: the audience must be one of this route's resources (RFC 8707). The token * must name the grant or API key it rests on, which the guard then puts to the grant policy. */ -export async function verifyAccessToken( - jwt: string, - route: { tenantId: string; organizationId?: string }, -): Promise { +export async function verifyAccessToken(jwt: string, route: { tenantId: string; organizationId?: string }): Promise { const audiences = [resourceUri({ face: 'api', tenantId: route.tenantId })]; - if (route.organizationId) - audiences.push(resourceUri({ face: 'mcp', tenantId: route.tenantId, organizationId: route.organizationId })); + if (route.organizationId) audiences.push(resourceUri({ face: 'mcp', tenantId: route.tenantId, organizationId: route.organizationId })); try { - const { payload } = await jwtVerify(jwt, await getPublicJwkSet(), { - issuer: appConfig.oauthUrl, - audience: audiences, - }); + const { payload } = await jwtVerify(jwt, await getPublicJwkSet(), { issuer: appConfig.oauthUrl, audience: audiences }); const claims = payload as typeof payload & Partial<{ actor_kind: IssuedTokenClaims['actor_kind']; tenant_id: string; gid: string; key_id: string }> & { scope?: string; client_id?: string; }; if (claims.sub && claims.tenant_id && claims.client_id) { - const token = { - actorId: claims.sub, - tenantId: claims.tenant_id, - scopes: accessScopes.parse(claims.scope), - clientId: claims.client_id, - }; + const scopes = accessScopes.parse(claims.scope); + const token = { actorId: claims.sub, tenantId: claims.tenant_id, scopes, clientId: claims.client_id }; if (claims.actor_kind === 'user' && claims.gid) return { ...token, kind: 'user', grantId: claims.gid }; if (claims.actor_kind === 'service' && claims.key_id) return { ...token, kind: 'service', keyId: claims.key_id }; } diff --git a/backend/src/modules/oauth-server/worker/oauth-worker-entry.ts b/backend/src/modules/oauth-server/worker/oauth-worker-entry.ts index 38eabac91..620bb278b 100644 --- a/backend/src/modules/oauth-server/worker/oauth-worker-entry.ts +++ b/backend/src/modules/oauth-server/worker/oauth-worker-entry.ts @@ -5,7 +5,6 @@ import { setupGracefulShutdown } from 'shared/utils/worker-lifecycle'; import { env } from '#/env'; import { baseLog } from '#/lib/pino'; import { otel } from '#/lib/tracing'; -import { listenForAuthInvalidation } from '#/middlewares/guard/invalidation-listener'; import { ensureSigningKeys } from '#/modules/oauth-server/keystore'; import { createProvider } from '#/modules/oauth-server/provider'; import { createOauthListener } from '#/modules/oauth-server/server'; @@ -28,20 +27,15 @@ export async function startOauthServer(options: { port?: number; inProcess?: boo } await ensureSigningKeys(); - // Consent reads tenants through the tenant cache, whose entries drop when another process invalidates them. - const stopInvalidationListener = listenForAuthInvalidation(); const provider = await createProvider(); const server: Server = createServer(createOauthListener(provider)); - server.listen(port, '0.0.0.0', () => - baseLog.info(`OAuth server listening on port ${port} for ${appConfig.oauthUrl}`), - ); + server.listen(port, '0.0.0.0', () => baseLog.info(`OAuth server listening on port ${port} for ${appConfig.oauthUrl}`)); setupGracefulShutdown({ name: 'oauth-server', cleanup: async () => { server.close(); - await stopInvalidationListener(); if (!options.inProcess) await otel.shutdown(); }, log: (msg) => baseLog.info(msg), diff --git a/backend/src/modules/organization/helpers/select.ts b/backend/src/modules/organization/helpers/select.ts index 366cbe6d6..62388510a 100644 --- a/backend/src/modules/organization/helpers/select.ts +++ b/backend/src/modules/organization/helpers/select.ts @@ -9,9 +9,7 @@ export const organizationFlagsSelect = sql`${JSON.stringify(a export const setupConfigSelect = sql`${JSON.stringify(appConfig.defaultSetupConfig)}::jsonb || ${organizationsTable.setupConfig}`; /** JS-side equivalent of `organizationFlagsSelect` for rows that skip our select shapes (org-guard fetch, generic channel reads, `.returning()`). */ -export const withOrganizationFlagDefaults = ( - organization: T, -): T => ({ +export const withOrganizationFlagDefaults = (organization: T): T => ({ ...organization, organizationFlags: { ...appConfig.defaultOrganizationFlags, ...organization.organizationFlags }, }); @@ -25,8 +23,6 @@ export const withSetupConfigDefaults = }, ->( +export const withOrganizationDefaults = }>( organization: T, ): T & { setupConfig: OrganizationSetupConfig } => withSetupConfigDefaults(withOrganizationFlagDefaults(organization)); diff --git a/backend/src/modules/organization/operations/create-organizations.ts b/backend/src/modules/organization/operations/create-organizations.ts index 353a174c1..15acf9e86 100644 --- a/backend/src/modules/organization/operations/create-organizations.ts +++ b/backend/src/modules/organization/operations/create-organizations.ts @@ -43,12 +43,7 @@ export async function createOrganizationsOp(ctx: UserContext, rawItems: CreateOr const slugFiltered = filterWithRejection(items, (item) => slugAvailability.get(item.slug) === true, 'slug_exists'); // Clamp to the available slots: the hard 1:1 cap binds system admins too, so no bypass here. - const restrictionFiltered = takeWithRestriction( - slugFiltered.items, - availableSlots, - 'org_limit_reached', - slugFiltered.rejectionState, - ); + const restrictionFiltered = takeWithRestriction(slugFiltered.items, availableSlots, 'org_limit_reached', slugFiltered.rejectionState); const itemsToCreate = restrictionFiltered.items; const rejectionState = restrictionFiltered.rejectionState; @@ -71,22 +66,13 @@ export async function createOrganizationsOp(ctx: UserContext, rawItems: CreateOr })), }); - log.info('Organizations created', { - count: organizationRecords.length, - ids: organizationRecords.map((org) => org.id), - }); + const ids = organizationRecords.map((org) => org.id); + log.info('Organizations created', { count: organizationRecords.length, ids }); - const membershipInserts = organizationRecords.map((org) => ({ - userId: user.id, - createdBy: user.id, - role: 'admin' as const, - entity: org, - })); + const membershipInserts = organizationRecords.map((org) => ({ userId: user.id, createdBy: user.id, role: 'admin' as const, entity: org })); const createdMemberships = await insertMemberships({ var: { db } }, { items: membershipInserts }); - - // Invalidate membership cache so subsequent requests see the new membership - await invalidateCache.user(db, user.id); + invalidateCache.user(user.id); const counts = buildZeroCounts('organization'); diff --git a/backend/src/modules/organization/operations/delete-organizations.ts b/backend/src/modules/organization/operations/delete-organizations.ts index 2f090f104..e6ebdd90c 100644 --- a/backend/src/modules/organization/operations/delete-organizations.ts +++ b/backend/src/modules/organization/operations/delete-organizations.ts @@ -11,7 +11,7 @@ export async function deleteOrganizationsOp(ctx: UserContext, ids: string[], ten await deleteOrganizationsByIds(ctx, { ids: allowedIds }); - for (const id of allowedIds) await invalidateCache.org(ctx.var.db, tenantId, id); + for (const id of allowedIds) invalidateCache.org(tenantId, id); log.info('Organizations deleted', { count: allowedIds.length, ids: allowedIds }); diff --git a/backend/src/modules/organization/operations/get-organization.ts b/backend/src/modules/organization/operations/get-organization.ts index 8e0d4b433..976f92fcb 100644 --- a/backend/src/modules/organization/operations/get-organization.ts +++ b/backend/src/modules/organization/operations/get-organization.ts @@ -17,9 +17,7 @@ export async function getOrganizationOp(ctx: ActorContext, id: string, opts: { b const includeMembership = include.includes('membership'); const [counts, organizationWithAudit] = await Promise.all([ - includeCounts - ? getChannelCounts(ctx, { entityType: organization.entityType, entityId: organization.id }) - : undefined, + includeCounts ? getChannelCounts(ctx, { entityType: organization.entityType, entityId: organization.id }) : undefined, withAuditUser(ctx, organization), ]); diff --git a/backend/src/modules/organization/operations/get-organizations.ts b/backend/src/modules/organization/operations/get-organizations.ts index 774b86d47..a805b261f 100644 --- a/backend/src/modules/organization/operations/get-organizations.ts +++ b/backend/src/modules/organization/operations/get-organizations.ts @@ -32,16 +32,12 @@ export async function getOrganizationsOp(ctx: UserContext, input: GetOrganizatio const targetUserId = relatableUserId ?? user.id; const ofAnotherUser = !!relatableUserId && relatableUserId !== user.id; // Another user's organizations are listed only where the caller is a member too; a system admin sees all of them. - const sharedWithCaller = - ofAnotherUser && !ctx.var.isSystemAdmin ? [...new Set(memberships.map((m) => m.organizationId))] : undefined; + const sharedWithCaller = ofAnotherUser && !ctx.var.isSystemAdmin ? [...new Set(memberships.map((m) => m.organizationId))] : undefined; // The listed user's archive and role are theirs alone: as a filter on another user's list they are refused, never // dropped. The menu-order default names the caller's own menu, so another user's list comes by name. if (ofAnotherUser && (role || excludeArchived)) { - throw new AppError(403, 'forbidden', 'warn', { - entityType: 'organization', - meta: { reason: 'other_user_membership' }, - }); + throw new AppError(403, 'forbidden', 'warn', { entityType: 'organization', meta: { reason: 'other_user_membership' } }); } const includeCounts = include.includes('counts'); diff --git a/backend/src/modules/organization/operations/update-organization.ts b/backend/src/modules/organization/operations/update-organization.ts index e502b56b5..79f218694 100644 --- a/backend/src/modules/organization/operations/update-organization.ts +++ b/backend/src/modules/organization/operations/update-organization.ts @@ -39,19 +39,13 @@ export async function updateOrganizationOp(ctx: ActorContext, id: string, rawInp // Rows store organizationFlags/setupConfig sparse; merge config defaults under the stored bag const updatedOrganizationRecord = withOrganizationDefaults(updatedRecord); - await dispatchMutation(ctx, 'organization.updated', { - before: [withOrganizationDefaults(organization)], - after: [updatedOrganizationRecord], - }); + await dispatchMutation(ctx, 'organization.updated', { before: [withOrganizationDefaults(organization)], after: [updatedOrganizationRecord] }); - await invalidateCache.org(ctx.var.db, organization.tenantId, organization.id); + invalidateCache.org(organization.tenantId, organization.id); log.info('Organization updated', { organizationId: updatedOrganizationRecord.id }); - const counts = await getChannelCounts(ctx, { - entityType: organization.entityType, - entityId: organization.id, - }); + const counts = await getChannelCounts(ctx, { entityType: organization.entityType, entityId: organization.id }); const included = { // A service account's grant is not a membership row; only a user's row is returned. diff --git a/backend/src/modules/organization/organization-mocks.ts b/backend/src/modules/organization/organization-mocks.ts index 1630d9e0b..00001ea0c 100644 --- a/backend/src/modules/organization/organization-mocks.ts +++ b/backend/src/modules/organization/organization-mocks.ts @@ -24,14 +24,7 @@ export const resetOrganizationMockEnforcers = () => { /** Base organization fields shared between insert and response mocks. */ const generateOrganizationBase = (id: string, tenantId: string, name: string, createdAt: string) => { - const base = mockChannelColumns('organization', { - id, - tenantId, - name, - createdAt, - updatedAt: createdAt, - publishedAt: createdAt, - }); + const base = mockChannelColumns('organization', { id, tenantId, name, createdAt, updatedAt: createdAt, publishedAt: createdAt }); const { slug } = base; return { @@ -63,12 +56,7 @@ export const mockOrganization = (): InsertOrganizationModel => { /** Adds API-only fields (included.membership, included.counts) to the base mock. */ export const mockOrganizationResponse = ( key = 'organization:default', -): OrganizationModel & { - included: { - membership: MembershipBaseModel; - counts: ReturnType; - }; -} => +): OrganizationModel & { included: { membership: MembershipBaseModel; counts: ReturnType } } => withFakerSeed(key, () => { const createdAt = mockPastIsoDate(); const organizationId = mockUuid(); @@ -83,13 +71,7 @@ export const mockOrganizationResponse = ( tenantId, }); - return { - ...base, - included: { - membership, - counts: generateMockChannelCounts('organization', `${key}:counts`), - }, - }; + return { ...base, included: { membership, counts: generateMockChannelCounts('organization', `${key}:counts`) } }; }); export const mockPaginatedOrganizationsResponse = (count = 2) => mockPaginated(mockOrganizationResponse, count); diff --git a/backend/src/modules/organization/organization-queries.ts b/backend/src/modules/organization/organization-queries.ts index 4d3d3aba0..eb07d78ba 100644 --- a/backend/src/modules/organization/organization-queries.ts +++ b/backend/src/modules/organization/organization-queries.ts @@ -19,10 +19,7 @@ interface CountOrganizationsByTenantOpts { export const countOrganizationsByTenant = async (ctx: DbContext, { tenantId }: CountOrganizationsByTenantOpts) => { const { db } = ctx.var; - const [result] = await db - .select({ count: sql`count(*)::int` }) - .from(organizationsTable) - .where(eq(organizationsTable.tenantId, tenantId)); + const [result] = await db.select({ count: sql`count(*)::int` }).from(organizationsTable).where(eq(organizationsTable.tenantId, tenantId)); return result?.count ?? 0; }; @@ -51,9 +48,7 @@ export const updateOrganization = async (ctx: ActorContext, { id, values }: Upda const updateData = { ...rest, - ...(organizationFlags && { - organizationFlags: mergeJsonbShallow(organizationsTable.organizationFlags, organizationFlags), - }), + ...(organizationFlags && { organizationFlags: mergeJsonbShallow(organizationsTable.organizationFlags, organizationFlags) }), ...(setupConfig && { setupConfig: mergeJsonbShallow(organizationsTable.setupConfig, setupConfig) }), // For toolsConfig each listed slot key replaces that slot's stored arrangement wholesale ...(toolsConfig && { toolsConfig: mergeJsonbShallow(organizationsTable.toolsConfig, toolsConfig) }), @@ -73,9 +68,7 @@ interface DeleteOrganizationsByIdsOpts { export const deleteOrganizationsByIds = async (ctx: ActorContext, { ids }: DeleteOrganizationsByIdsOpts) => { const { db, tenantId } = ctx.var; - return db - .delete(organizationsTable) - .where(and(inArray(organizationsTable.id, ids), eq(organizationsTable.tenantId, tenantId))); + return db.delete(organizationsTable).where(and(inArray(organizationsTable.id, ids), eq(organizationsTable.tenantId, tenantId))); }; interface FindOrganizationsPaginatedOpts { @@ -95,19 +88,7 @@ interface FindOrganizationsPaginatedOpts { export const findOrganizationsPaginated = async (ctx: DbContext, opts: FindOrganizationsPaginatedOpts) => { const { db } = ctx.var; - const { - isSystemAdmin, - targetUserId, - organizationIds, - q, - sort, - order, - offset, - limit, - excludeArchived, - role, - includeCounts, - } = opts; + const { isSystemAdmin, targetUserId, organizationIds, q, sort, order, offset, limit, excludeArchived, role, includeCounts } = opts; const entityType = 'organization'; @@ -162,10 +143,7 @@ export const findOrganizationsPaginated = async (ctx: DbContext, opts: FindOrgan : db.select(selectShape).from(organizationsTable).innerJoin(membershipsTable, membershipOn).$dynamic(); if (countData) { - query = query.leftJoin( - channelCountersTable, - sql`${organizationsTable.id}::text = ${channelCountersTable.channelKey}`, - ) as typeof query; + query = query.leftJoin(channelCountersTable, sql`${organizationsTable.id}::text = ${channelCountersTable.channelKey}`) as typeof query; } const itemsQuery = query diff --git a/backend/src/modules/organization/organization-routes.ts b/backend/src/modules/organization/organization-routes.ts index c68aad545..028f0866e 100644 --- a/backend/src/modules/organization/organization-routes.ts +++ b/backend/src/modules/organization/organization-routes.ts @@ -1,5 +1,5 @@ -import { createXRoute } from '#/core/x-routes'; -import { actorGuard, crossTenantGuard, relatableGuard, tenantGuard, userGuard } from '#/middlewares/guard'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; +import { actorGuard, relatableGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { insertEntityLock } from '#/middlewares/insert-entity-lock'; import { bulkPointsLimiter, singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; import { @@ -9,142 +9,58 @@ import { organizationUpdateBodySchema, organizationWithMembershipSchema, } from '#/modules/organization/organization-schema'; -import { - batchResponseSchema, - errorResponseRefs, - idsBodySchema, - paginationSchema, - slugIncludeQuerySchema, - tenantIdParamSchema, - tenantOnlyParamSchema, -} from '#/schemas'; -import { - mockBatchOrganizationsResponse, - mockOrganizationResponse, - mockPaginatedOrganizationsResponse, -} from './organization-mocks'; +import { batchResponseSchema, idsBodySchema, paginationSchema, slugIncludeQuerySchema, tenantIdParamSchema, tenantOnlyParamSchema } from '#/schemas'; +import { mockBatchOrganizationsResponse, mockOrganizationResponse, mockPaginatedOrganizationsResponse } from './organization-mocks'; -const organizationRoutes = { - createOrganizations: createXRoute({ - operationId: 'createOrganizations', +const organizationRoutes = createXRoutes(['organizations', 'cella', 'channel'], { + createOrganizations: xRoute({ method: 'post', path: '/{tenantId}/organizations', xGuard: [userGuard, tenantGuard], xRateLimiter: [insertEntityLock, bulkPointsLimiter], - tags: ['organizations', 'cella', 'channel'], summary: 'Create organizations', description: 'Creates one or more new organizations within a tenant.', - request: { - params: tenantOnlyParamSchema, - body: { - required: true, - content: { 'application/json': { schema: organizationCreateBodySchema } }, - }, - }, - responses: { - 201: { - description: 'Organizations were created', - content: { - 'application/json': { - schema: batchResponseSchema(organizationWithMembershipSchema), - example: mockBatchOrganizationsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOnlyParamSchema, body: jsonBody(organizationCreateBodySchema) }, + responses: { 201: json('Organizations were created', batchResponseSchema(organizationWithMembershipSchema), mockBatchOrganizationsResponse()) }, }), - getOrganizations: createXRoute({ - operationId: 'getOrganizations', + getOrganizations: xRoute({ method: 'get', path: '/organizations', - xGuard: [userGuard, crossTenantGuard, relatableGuard], - tags: ['organizations', 'cella', 'channel'], + xGuard: [userGuard, relatableGuard], summary: 'Get list of organizations', description: 'Returns a list of organizations.', request: { query: organizationListQuerySchema }, - responses: { - 200: { - description: 'Organizations', - content: { - 'application/json': { - schema: paginationSchema(organizationSchema), - example: mockPaginatedOrganizationsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Organizations', paginationSchema(organizationSchema), mockPaginatedOrganizationsResponse()) }, }), - getOrganization: createXRoute({ - operationId: 'getOrganization', + getOrganization: xRoute({ method: 'get', path: '/{tenantId}/organizations/{id}', xGuard: [actorGuard, tenantGuard], - tags: ['organizations', 'cella', 'channel'], summary: 'Get organization', description: 'Retrieves an organization by ID within a tenant. Pass ?slug=true to resolve by slug instead.', request: { params: tenantIdParamSchema, query: slugIncludeQuerySchema }, - responses: { - 200: { - description: 'Organization', - content: { 'application/json': { schema: organizationSchema, example: mockOrganizationResponse() } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Organization', organizationSchema, mockOrganizationResponse()) }, }), - updateOrganization: createXRoute({ - operationId: 'updateOrganization', + updateOrganization: xRoute({ method: 'put', path: '/{tenantId}/organizations/{id}', xGuard: [actorGuard, tenantGuard], xRateLimiter: [singlePointsLimiter], - tags: ['organizations', 'cella', 'channel'], summary: 'Update organization', description: 'Updates an organization within a tenant.', - request: { - params: tenantIdParamSchema, - body: { - required: true, - content: { 'application/json': { schema: organizationUpdateBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Organization was updated', - content: { 'application/json': { schema: organizationSchema, example: mockOrganizationResponse() } }, - }, - ...errorResponseRefs, - }, + request: { params: tenantIdParamSchema, body: jsonBody(organizationUpdateBodySchema) }, + responses: { 200: json('Organization was updated', organizationSchema, mockOrganizationResponse()) }, }), - deleteOrganizations: createXRoute({ - operationId: 'deleteOrganizations', + deleteOrganizations: xRoute({ method: 'delete', path: '/{tenantId}/organizations', xGuard: [userGuard, tenantGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['organizations', 'cella', 'channel'], summary: 'Delete organizations', description: 'Deletes one or more organizations by ID within a tenant.', - request: { - params: tenantOnlyParamSchema, - body: { - required: true, - content: { 'application/json': { schema: idsBodySchema() } }, - }, - }, - responses: { - 200: { - description: 'Success', - content: { - 'application/json': { - schema: batchResponseSchema(), - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOnlyParamSchema, body: jsonBody(idsBodySchema()) }, + responses: { 200: json('Success', batchResponseSchema()) }, }), -}; +}); export { organizationRoutes }; diff --git a/backend/src/modules/organization/organization-schema.ts b/backend/src/modules/organization/organization-schema.ts index 92a34828f..ae9cbca7f 100644 --- a/backend/src/modules/organization/organization-schema.ts +++ b/backend/src/modules/organization/organization-schema.ts @@ -62,11 +62,7 @@ export const organizationWithMembershipSchema = organizationSchema.extend({ /** Wire registration: lens-widened schemas bound to the organization entity at runtime. */ export const organizationContract = evolutionContract.channel('organization', { - createItem: z.object({ - id: validTempIdSchema, - name: validNameSchema, - slug: validSlugSchema, - }), + createItem: z.object({ id: validTempIdSchema, name: validNameSchema, slug: validSlugSchema }), updateBody: createInsertSchema(organizationsTable, { slug: validSlugSchema, name: validNameSchema, diff --git a/backend/src/modules/project/helpers/project-membership-workspace.ts b/backend/src/modules/project/helpers/project-membership-workspace.ts index c307701ed..ddf3273f4 100644 --- a/backend/src/modules/project/helpers/project-membership-workspace.ts +++ b/backend/src/modules/project/helpers/project-membership-workspace.ts @@ -1,37 +1,18 @@ import type { DbContext, UserContext } from '#/core/context'; import { AppError } from '#/core/error'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { - deleteProjectMembership, - findMaxDisplayOrder, - insertProjectMembership, -} from '#/modules/project/project-queries'; +import { deleteProjectMembership, findMaxDisplayOrder, insertProjectMembership } from '#/modules/project/project-queries'; import { getValidChannel } from '#/permissions'; -type ProjectMembershipTarget = { - id: string; - entityType: 'project'; -}; +type ProjectMembershipTarget = { id: string; entityType: 'project' }; -type ProjectMembership = UserContext['var']['memberships'][number] & { - channelType: 'project'; - projectId: string; -}; +type ProjectMembership = UserContext['var']['memberships'][number] & { channelType: 'project'; projectId: string }; -type SetProjectMembershipWorkspaceInput = { - membership: ProjectMembership; - workspaceId: string | null; - role?: ProjectMembership['role']; -}; +type SetProjectMembershipWorkspaceInput = { membership: ProjectMembership; workspaceId: string | null; role?: ProjectMembership['role'] }; -type ReplaceProjectMembershipWorkspaceInput = SetProjectMembershipWorkspaceInput & { - createdBy: string; -}; +type ReplaceProjectMembershipWorkspaceInput = SetProjectMembershipWorkspaceInput & { createdBy: string }; -type UpsertProjectMembershipWorkspaceInput = { - project: ProjectMembershipTarget; - workspaceId: string | null; -}; +type UpsertProjectMembershipWorkspaceInput = { project: ProjectMembershipTarget; workspaceId: string | null }; function isProjectMembershipTarget( membership: UserContext['var']['memberships'][number], @@ -47,10 +28,7 @@ export async function resolveProjectWorkspaceId(ctx: UserContext, workspaceId: s // workspace, producing a membership whose organizationId (the project's org) mismatches the // workspace's org and later fails org-scoped reads (e.g. labels) with a spurious 404. if (entity.organizationId !== ctx.var.organization.id) { - throw new AppError(403, 'forbidden', 'warn', { - entityType: 'workspace', - meta: { action: 'assign', reason: 'cross_organization' }, - }); + throw new AppError(403, 'forbidden', 'warn', { entityType: 'workspace', meta: { action: 'assign', reason: 'cross_organization' } }); } return entity.id; } @@ -63,10 +41,7 @@ export function requireCurrentUserProjectMembership(ctx: UserContext, project: P const membership = findCurrentUserProjectMembership(ctx, project); if (!membership) { - throw new AppError(404, 'not_found', 'warn', { - entityType: project.entityType, - meta: { membership: 'current_user', projectId: project.id }, - }); + throw new AppError(404, 'not_found', 'warn', { entityType: project.entityType, meta: { membership: 'current_user', projectId: project.id } }); } return membership; @@ -81,11 +56,7 @@ export async function replaceProjectMembershipWorkspace( return db.transaction(async (tx) => { const txCtx: DbContext = { var: { db: tx } }; const maxOrder = workspaceId - ? await findMaxDisplayOrder(txCtx, { - userId: membership.userId, - channelType: membership.channelType, - workspaceId, - }) + ? await findMaxDisplayOrder(txCtx, { userId: membership.userId, channelType: membership.channelType, workspaceId }) : null; const displayOrder = workspaceId ? (maxOrder ? maxOrder + 1 : 1) : membership.displayOrder; @@ -120,35 +91,20 @@ export async function setCurrentUserProjectMembershipWorkspace( ctx: UserContext, { membership, workspaceId, role }: SetProjectMembershipWorkspaceInput, ) { - const updatedMembership = await replaceProjectMembershipWorkspace(ctx, { - membership, - workspaceId, - createdBy: ctx.var.user.id, - role, - }); + const updatedMembership = await replaceProjectMembershipWorkspace(ctx, { membership, workspaceId, createdBy: ctx.var.user.id, role }); - await invalidateCache.user(ctx.var.db, updatedMembership.userId); + invalidateCache.user(updatedMembership.userId); return updatedMembership; } async function createCurrentUserProjectMembershipInWorkspace( ctx: UserContext, - { - project, - workspaceId, - }: { - project: ProjectMembershipTarget; - workspaceId: string; - }, + { project, workspaceId }: { project: ProjectMembershipTarget; workspaceId: string }, ) { const { user, organization } = ctx.var; - const maxOrder = await findMaxDisplayOrder(ctx, { - userId: user.id, - channelType: project.entityType, - workspaceId, - }); + const maxOrder = await findMaxDisplayOrder(ctx, { userId: user.id, channelType: project.entityType, workspaceId }); const membership = await insertProjectMembership(ctx, { values: { @@ -165,15 +121,12 @@ async function createCurrentUserProjectMembershipInWorkspace( }, }); - await invalidateCache.user(ctx.var.db, membership.userId); + invalidateCache.user(membership.userId); return membership; } -export async function upsertCurrentUserProjectMembershipWorkspace( - ctx: UserContext, - { project, workspaceId }: UpsertProjectMembershipWorkspaceInput, -) { +export async function upsertCurrentUserProjectMembershipWorkspace(ctx: UserContext, { project, workspaceId }: UpsertProjectMembershipWorkspaceInput) { const existingMembership = findCurrentUserProjectMembership(ctx, project); if (!existingMembership) { @@ -181,17 +134,12 @@ export async function upsertCurrentUserProjectMembershipWorkspace( return createCurrentUserProjectMembershipInWorkspace(ctx, { project, workspaceId }); } - throw new AppError(400, 'invalid_request', 'warn', { - message: 'Project membership not found for workspace removal.', - }); + throw new AppError(400, 'invalid_request', 'warn', { message: 'Project membership not found for workspace removal.' }); } return setCurrentUserProjectMembershipWorkspace(ctx, { membership: existingMembership, workspaceId, - role: - existingMembership.role === 'guest' - ? (ctx.var.organization.membership?.role ?? 'member') - : existingMembership.role, + role: existingMembership.role === 'guest' ? (ctx.var.organization.membership?.role ?? 'member') : existingMembership.role, }); } diff --git a/backend/src/modules/project/operations/assign-project-workspace.ts b/backend/src/modules/project/operations/assign-project-workspace.ts index 1f97250a3..3df5e869e 100644 --- a/backend/src/modules/project/operations/assign-project-workspace.ts +++ b/backend/src/modules/project/operations/assign-project-workspace.ts @@ -1,9 +1,6 @@ import type { UserContext } from '#/core/context'; import { toMembershipBase } from '#/modules/memberships/helpers/select'; -import { - resolveProjectWorkspaceId, - upsertCurrentUserProjectMembershipWorkspace, -} from '#/modules/project/helpers/project-membership-workspace'; +import { resolveProjectWorkspaceId, upsertCurrentUserProjectMembershipWorkspace } from '#/modules/project/helpers/project-membership-workspace'; import { withAuditUser } from '#/modules/user/helpers/audit-user'; import { getValidChannel } from '#/permissions'; import { log } from '#/utils/logger'; @@ -11,10 +8,7 @@ import { log } from '#/utils/logger'; export async function assignProjectWorkspaceOp(ctx: UserContext, id: string, workspaceId: string) { const { entity: project } = await getValidChannel(ctx, id, 'project', 'read'); const resolvedWorkspaceId = await resolveProjectWorkspaceId(ctx, workspaceId); - const updatedMembership = await upsertCurrentUserProjectMembershipWorkspace(ctx, { - project, - workspaceId: resolvedWorkspaceId, - }); + const updatedMembership = await upsertCurrentUserProjectMembershipWorkspace(ctx, { project, workspaceId: resolvedWorkspaceId }); log.info('Project workspace assigned', { projectId: project.id, workspaceId: resolvedWorkspaceId }); diff --git a/backend/src/modules/project/operations/create-projects.ts b/backend/src/modules/project/operations/create-projects.ts index e1b8cc0cf..b69ba560d 100644 --- a/backend/src/modules/project/operations/create-projects.ts +++ b/backend/src/modules/project/operations/create-projects.ts @@ -16,15 +16,7 @@ import { canCreateEntity } from '#/permissions/can-create'; import { log } from '#/utils/logger'; import { filterWithRejection, takeWithRestriction } from '#/utils/rejection-utils'; -const defaultTaskStatusCounts = { - accepted: 0, - reviewed: 0, - delivered: 0, - finished: 0, - started: 0, - unstarted: 0, - iced: 0, -}; +const defaultTaskStatusCounts = { accepted: 0, reviewed: 0, delivered: 0, finished: 0, started: 0, unstarted: 0, iced: 0 }; type CreateProjectItem = z.infer[number]; @@ -40,10 +32,7 @@ export async function createProjectsOp(ctx: UserContext, rawItems: CreateProject const resolvedWorkspaceId = await resolveProjectWorkspaceId(ctx, workspaceId); // Check if adding is allowed based on the organization's restrictions - const currentProjectsCount = await getOrganizationEntityCount(ctx, { - organizationId: organization.id, - entityType: 'project', - }); + const currentProjectsCount = await getOrganizationEntityCount(ctx, { organizationId: organization.id, entityType: 'project' }); const projectRestrictions = ctx.var.tenant.restrictions.quotas.project; const availableSlots = projectRestrictions === 0 ? items.length : projectRestrictions - currentProjectsCount; @@ -103,7 +92,7 @@ export async function createProjectsOp(ctx: UserContext, rawItems: CreateProject const createdMemberships = await insertMemberships({ var: { db } }, { items: membershipInserts }); // Invalidate membership cache so subsequent requests see the new membership - await invalidateCache.user(db, user.id); + invalidateCache.user(user.id); // Build counts for response const counts = buildZeroCounts('project'); @@ -116,10 +105,7 @@ export async function createProjectsOp(ctx: UserContext, rawItems: CreateProject const membership = membershipByProjectId.get(project.id)!; return { ...project, - included: { - membership: toMembershipBase(membership), - counts: { ...counts, taskStatusCounts: defaultTaskStatusCounts }, - }, + included: { membership: toMembershipBase(membership), counts: { ...counts, taskStatusCounts: defaultTaskStatusCounts } }, }; }); diff --git a/backend/src/modules/project/operations/delete-projects.ts b/backend/src/modules/project/operations/delete-projects.ts index e1c0ef3a1..f6ce50ba9 100644 --- a/backend/src/modules/project/operations/delete-projects.ts +++ b/backend/src/modules/project/operations/delete-projects.ts @@ -14,7 +14,7 @@ export async function deleteProjectsOp(ctx: UserContext, ids: string[]) { await deleteProjectsByIds(ctx, { ids: allowedIds }); // Invalidate membership cache so deleted memberships are absent for the current user. - await invalidateCache.user(ctx.var.db, ctx.var.user.id); + invalidateCache.user(ctx.var.user.id); log.info('Projects deleted', { count: allowedIds.length, ids: allowedIds }); diff --git a/backend/src/modules/project/operations/get-project.ts b/backend/src/modules/project/operations/get-project.ts index 38c24b1cc..beeebab54 100644 --- a/backend/src/modules/project/operations/get-project.ts +++ b/backend/src/modules/project/operations/get-project.ts @@ -20,10 +20,7 @@ export async function getProjectOp(ctx: UserContext, id: string, opts: { bySlug? withAuditUser(ctx, project, user), ]); - const included: { - counts?: typeof counts & { taskStatusCounts: typeof taskStatusCounts }; - membership?: ReturnType; - } = {}; + const included: { counts?: typeof counts & { taskStatusCounts: typeof taskStatusCounts }; membership?: ReturnType } = {}; if (counts) included.counts = { ...counts, taskStatusCounts }; diff --git a/backend/src/modules/project/operations/get-projects.ts b/backend/src/modules/project/operations/get-projects.ts index 251b3b7dd..567a55c4f 100644 --- a/backend/src/modules/project/operations/get-projects.ts +++ b/backend/src/modules/project/operations/get-projects.ts @@ -28,11 +28,7 @@ export async function getProjectsOp(ctx: UserContext, input: GetProjectsInput) { const includeCounts = include.includes('counts'); const includeMembership = include.includes('membership'); - const { items: projectResults, total } = await findProjectsPaginated(ctx, { - userId: targetUserId, - ...queryParams, - includeCounts, - }); + const { items: projectResults, total } = await findProjectsPaginated(ctx, { userId: targetUserId, ...queryParams, includeCounts }); // Build response with included wrapper for optional data const items = coalesceAuditUsers(projectResults).map((row) => { diff --git a/backend/src/modules/project/operations/move-project-workspace.ts b/backend/src/modules/project/operations/move-project-workspace.ts index 43a5a4b48..8f1efba10 100644 --- a/backend/src/modules/project/operations/move-project-workspace.ts +++ b/backend/src/modules/project/operations/move-project-workspace.ts @@ -13,10 +13,7 @@ export async function moveProjectToWorkspaceOp(ctx: UserContext, id: string, wor const { entity: project } = await getValidChannel(ctx, id, 'project', 'read'); const resolvedWorkspaceId = await resolveProjectWorkspaceId(ctx, workspaceId); const membership = requireCurrentUserProjectMembership(ctx, project); - const updatedMembership = await setCurrentUserProjectMembershipWorkspace(ctx, { - membership, - workspaceId: resolvedWorkspaceId, - }); + const updatedMembership = await setCurrentUserProjectMembershipWorkspace(ctx, { membership, workspaceId: resolvedWorkspaceId }); log.info('Project workspace moved', { projectId: project.id, workspaceId: resolvedWorkspaceId }); diff --git a/backend/src/modules/project/operations/remove-project-workspace.ts b/backend/src/modules/project/operations/remove-project-workspace.ts index 8f255096a..5f6a72002 100644 --- a/backend/src/modules/project/operations/remove-project-workspace.ts +++ b/backend/src/modules/project/operations/remove-project-workspace.ts @@ -11,10 +11,7 @@ import { log } from '#/utils/logger'; export async function removeProjectWorkspaceOp(ctx: UserContext, id: string) { const { entity: project } = await getValidChannel(ctx, id, 'project', 'read'); const membership = requireCurrentUserProjectMembership(ctx, project); - const updatedMembership = await setCurrentUserProjectMembershipWorkspace(ctx, { - membership, - workspaceId: null, - }); + const updatedMembership = await setCurrentUserProjectMembershipWorkspace(ctx, { membership, workspaceId: null }); log.info('Project workspace removed', { projectId: project.id }); diff --git a/backend/src/modules/project/project-db.ts b/backend/src/modules/project/project-db.ts index 11244c25e..3d8bab6ff 100644 --- a/backend/src/modules/project/project-db.ts +++ b/backend/src/modules/project/project-db.ts @@ -6,27 +6,19 @@ import { organizationsTable } from '#/modules/organization/organization-db'; * Projects table is a channel entity table. * Each project belongs to exactly one organization and inherits its tenant (RLS isolation boundary). */ -export const projectsTable = snakeCase.table( - 'projects', - { - ...channelColumns('project'), - organizationId: uuid().notNull(), - }, - (table) => [ - index('projects_name_index').on(table.name.desc()), - index('projects_created_at_index').on(table.createdAt.desc()), - index('projects_tenant_id_index').on(table.tenantId), - index('projects_organization_id_index').on(table.organizationId), - index('projects_created_by_index').on(table.createdBy), - index('projects_updated_by_index').on(table.updatedBy), - // Compound unique for composite FK targets (memberships, products reference this) - unique('projects_tenant_id_unique').on(table.tenantId, table.id), - foreignKey({ - columns: [table.tenantId, table.organizationId], - foreignColumns: [organizationsTable.tenantId, organizationsTable.id], - }).onDelete('cascade'), - ], -); +export const projectsTable = snakeCase.table('projects', { ...channelColumns('project'), organizationId: uuid().notNull() }, (table) => [ + index('projects_name_index').on(table.name.desc()), + index('projects_created_at_index').on(table.createdAt.desc()), + index('projects_tenant_id_index').on(table.tenantId), + index('projects_organization_id_index').on(table.organizationId), + index('projects_created_by_index').on(table.createdBy), + index('projects_updated_by_index').on(table.updatedBy), + // Compound unique for composite FK targets (memberships, products reference this) + unique('projects_tenant_id_unique').on(table.tenantId, table.id), + foreignKey({ columns: [table.tenantId, table.organizationId], foreignColumns: [organizationsTable.tenantId, organizationsTable.id] }).onDelete( + 'cascade', + ), +]); export type ProjectModel = typeof projectsTable.$inferSelect; export type InsertProjectModel = typeof projectsTable.$inferInsert; diff --git a/backend/src/modules/project/project-mocks.ts b/backend/src/modules/project/project-mocks.ts index a68de8120..1865177c3 100644 --- a/backend/src/modules/project/project-mocks.ts +++ b/backend/src/modules/project/project-mocks.ts @@ -1,15 +1,6 @@ import { faker } from '@faker-js/faker'; import { UniqueEnforcer } from 'enforce-unique'; -import { - MOCK_REF_DATE, - mockBatchResponse, - mockChannelColumns, - mockPaginated, - mockPastIsoDate, - mockTenantId, - mockUuid, - withFakerSeed, -} from '#/mocks'; +import { MOCK_REF_DATE, mockBatchResponse, mockChannelColumns, mockPaginated, mockPastIsoDate, mockTenantId, mockUuid, withFakerSeed } from '#/mocks'; import type { MembershipBaseModel } from '#/modules/memberships/helpers/select'; import { mockMembershipBase } from '#/modules/memberships/memberships-mocks'; import type { InsertProjectModel, ProjectModel } from '#/modules/project/project-db'; @@ -25,20 +16,10 @@ const projectName = new UniqueEnforcer(); * @param organizationId - Parent organization ID */ const generateProjectBase = (id: string, name: string, createdAt: string, organizationId: string, tenantId: string) => { - const publicAt = faker.datatype.boolean() - ? faker.date.between({ from: new Date(createdAt), to: MOCK_REF_DATE }).toISOString() - : null; + const publicAt = faker.datatype.boolean() ? faker.date.between({ from: new Date(createdAt), to: MOCK_REF_DATE }).toISOString() : null; return { - ...mockChannelColumns('project', { - id, - name, - createdAt, - updatedAt: createdAt, - tenantId, - publicAt, - channelIds: { organizationId }, - }), + ...mockChannelColumns('project', { id, name, createdAt, updatedAt: createdAt, tenantId, publicAt, channelIds: { organizationId } }), organizationId, }; }; @@ -58,13 +39,7 @@ export const mockProject = (suffix?: string): InsertProjectModel => { * Generates a mock project API response with deterministic seeding. * Adds API-only fields (membership, counts) to the base mock. */ -export const mockProjectResponse = ( - key = 'project:default', -): ProjectModel & { - included: { - membership: MembershipBaseModel; - }; -} => +export const mockProjectResponse = (key = 'project:default'): ProjectModel & { included: { membership: MembershipBaseModel } } => withFakerSeed(key, () => { const createdAt = mockPastIsoDate(); const projectId = mockUuid(); @@ -82,12 +57,7 @@ export const mockProjectResponse = ( tenantId, }); - return { - ...base, - included: { - membership, - }, - }; + return { ...base, included: { membership } }; }); export const mockPaginatedProjectsResponse = (count = 2) => mockPaginated(mockProjectResponse, count); diff --git a/backend/src/modules/project/project-queries.ts b/backend/src/modules/project/project-queries.ts index 882fdd348..bed424d7b 100644 --- a/backend/src/modules/project/project-queries.ts +++ b/backend/src/modules/project/project-queries.ts @@ -46,9 +46,7 @@ interface DeleteProjectsByIdsOpts { export const deleteProjectsByIds = async (ctx: ActorContext, { ids }: DeleteProjectsByIdsOpts) => { const { db } = ctx.var; const { organizationId } = requestScope(ctx); - return db - .delete(projectsTable) - .where(and(inArray(projectsTable.id, ids), eq(projectsTable.organizationId, organizationId))); + return db.delete(projectsTable).where(and(inArray(projectsTable.id, ids), eq(projectsTable.organizationId, organizationId))); }; interface FindMaxDisplayOrderOpts { @@ -58,21 +56,12 @@ interface FindMaxDisplayOrderOpts { } /** Find the max displayOrder for a user's memberships in a workspace. */ -export const findMaxDisplayOrder = async ( - ctx: DbContext, - { userId, channelType, workspaceId }: FindMaxDisplayOrderOpts, -) => { +export const findMaxDisplayOrder = async (ctx: DbContext, { userId, channelType, workspaceId }: FindMaxDisplayOrderOpts) => { const { db } = ctx.var; const [{ maxOrder }] = await db .select({ maxOrder: max(membershipsTable.displayOrder) }) .from(membershipsTable) - .where( - and( - eq(membershipsTable.userId, userId), - eq(membershipsTable.channelType, channelType), - eq(membershipsTable.workspaceId, workspaceId), - ), - ); + .where(and(eq(membershipsTable.userId, userId), eq(membershipsTable.channelType, channelType), eq(membershipsTable.workspaceId, workspaceId))); return maxOrder; }; @@ -83,10 +72,7 @@ interface DeleteProjectMembershipOpts { projectId: string; } -export const deleteProjectMembership = async ( - ctx: DbContext, - { membershipId, userId, channelId, projectId }: DeleteProjectMembershipOpts, -) => { +export const deleteProjectMembership = async (ctx: DbContext, { membershipId, userId, channelId, projectId }: DeleteProjectMembershipOpts) => { const { db } = ctx.var; return db .delete(membershipsTable) @@ -129,8 +115,7 @@ interface FindProjectsPaginatedOpts { /** Get paginated list of projects with total count, membership, optional entity counts. */ export const findProjectsPaginated = async (ctx: DbContext, opts: FindProjectsPaginatedOpts) => { const { db } = ctx.var; - const { userId, q, sort, order, offset, limit, organizationId, workspaceId, excludeArchived, role, includeCounts } = - opts; + const { userId, q, sort, order, offset, limit, organizationId, workspaceId, excludeArchived, role, includeCounts } = opts; const entityType = 'project'; @@ -165,12 +150,7 @@ export const findProjectsPaginated = async (ctx: DbContext, opts: FindProjectsPa sort, order, fallback: ['displayOrder', 'asc'], - columns: { - id: projectsTable.id, - name: projectsTable.name, - createdAt: projectsTable.createdAt, - displayOrder: membershipsTable.displayOrder, - }, + columns: { id: projectsTable.id, name: projectsTable.name, createdAt: projectsTable.createdAt, displayOrder: membershipsTable.displayOrder }, tieBreaker: projectsTable.id, }); @@ -188,10 +168,7 @@ export const findProjectsPaginated = async (ctx: DbContext, opts: FindProjectsPa let query = db.select(selectShape).from(projectsTable).innerJoin(membershipsTable, membershipOn).$dynamic(); if (countData) { - query = query.leftJoin( - channelCountersTable, - sql`${projectsTable.id}::text = ${channelCountersTable.channelKey}`, - ) as typeof query; + query = query.leftJoin(channelCountersTable, sql`${projectsTable.id}::text = ${channelCountersTable.channelKey}`) as typeof query; } const itemsQuery = query diff --git a/backend/src/modules/project/project-routes.ts b/backend/src/modules/project/project-routes.ts index 8a4c080cd..1e374856f 100644 --- a/backend/src/modules/project/project-routes.ts +++ b/backend/src/modules/project/project-routes.ts @@ -1,12 +1,8 @@ import { createXRoute } from '#/core/x-routes'; -import { crossTenantGuard, orgGuard, relatableGuard, tenantGuard, userGuard } from '#/middlewares/guard'; +import { orgGuard, relatableGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { insertEntityLock } from '#/middlewares/insert-entity-lock'; import { bulkPointsLimiter, singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; -import { - mockBatchProjectsResponse, - mockPaginatedProjectsResponse, - mockProjectResponse, -} from '#/modules/project/project-mocks'; +import { mockBatchProjectsResponse, mockPaginatedProjectsResponse, mockProjectResponse } from '#/modules/project/project-mocks'; import { projectCreateBodySchema, projectCreateResponseSchema, @@ -38,8 +34,7 @@ const projectRoutes = { tags: ['projects', 'app', 'channel'], operationId: 'createProjects', summary: 'Create projects', - description: - 'Creates one or more projects within an organization. The current user is assigned as an admin and can invite additional members.', + description: 'Creates one or more projects within an organization. The current user is assigned as an admin and can invite additional members.', request: { params: tenantOrgParamSchema, query: workspaceIdQuerySchema, @@ -48,12 +43,7 @@ const projectRoutes = { responses: { 201: { description: 'Projects created', - content: { - 'application/json': { - schema: projectCreateResponseSchema, - example: mockBatchProjectsResponse(), - }, - }, + content: { 'application/json': { schema: projectCreateResponseSchema, example: mockBatchProjectsResponse() } }, }, ...errorResponseRefs, }, @@ -64,7 +54,7 @@ const projectRoutes = { getProjects: createXRoute({ method: 'get', path: '/projects', - xGuard: [userGuard, crossTenantGuard, relatableGuard], + xGuard: [userGuard, relatableGuard], tags: ['projects', 'app', 'channel'], operationId: 'getProjects', summary: 'Get list of projects', @@ -79,12 +69,7 @@ const projectRoutes = { responses: { 200: { description: 'Projects', - content: { - 'application/json': { - schema: paginationSchema(projectSchema), - example: mockPaginatedProjectsResponse(), - }, - }, + content: { 'application/json': { schema: paginationSchema(projectSchema), example: mockPaginatedProjectsResponse() } }, }, ...errorResponseRefs, }, @@ -100,10 +85,7 @@ const projectRoutes = { operationId: 'getProject', summary: 'Get project', description: 'Retrieves a project by ID. Pass ?slug=true to resolve by slug instead.', - request: { - params: idInTenantOrgParamSchema, - query: slugIncludeQuerySchema, - }, + request: { params: idInTenantOrgParamSchema, query: slugIncludeQuerySchema }, responses: { 200: { description: 'Project', @@ -129,9 +111,7 @@ const projectRoutes = { responses: { 200: { description: 'Project updated', - content: { - 'application/json': { schema: projectSchema, example: mockProjectResponse() }, - }, + content: { 'application/json': { schema: projectSchema, example: mockProjectResponse() } }, }, ...errorResponseRefs, }, @@ -144,21 +124,12 @@ const projectRoutes = { tags: ['projects', 'app', 'channel'], operationId: 'assignProjectWorkspace', summary: 'Assign project to workspace', - description: - "Assigns a project to a workspace using the provided workspaceId. This does not affect the project's ownership or organization.", - request: { - params: idInTenantOrgParamSchema, - query: workspaceIdQuerySchema, - }, + description: "Assigns a project to a workspace using the provided workspaceId. This does not affect the project's ownership or organization.", + request: { params: idInTenantOrgParamSchema, query: workspaceIdQuerySchema }, responses: { 200: { description: 'Project assigned to the new workspace', - content: { - 'application/json': { - schema: projectWithMembershipSchema, - example: mockProjectResponse(), - }, - }, + content: { 'application/json': { schema: projectWithMembershipSchema, example: mockProjectResponse() } }, }, ...errorResponseRefs, }, @@ -171,20 +142,12 @@ const projectRoutes = { tags: ['projects', 'app', 'channel'], operationId: 'removeProjectWorkspace', summary: 'Remove project from workspace', - description: - "Removes the current user's project membership from its assigned workspace without leaving the project.", - request: { - params: idInTenantOrgParamSchema, - }, + description: "Removes the current user's project membership from its assigned workspace without leaving the project.", + request: { params: idInTenantOrgParamSchema }, responses: { 200: { description: 'Project removed from workspace', - content: { - 'application/json': { - schema: projectWithMembershipSchema, - example: mockProjectResponse(), - }, - }, + content: { 'application/json': { schema: projectWithMembershipSchema, example: mockProjectResponse() } }, }, ...errorResponseRefs, }, @@ -202,12 +165,7 @@ const projectRoutes = { responses: { 200: { description: 'Moved project', - content: { - 'application/json': { - schema: projectWithMembershipSchema, - example: mockProjectResponse(), - }, - }, + content: { 'application/json': { schema: projectWithMembershipSchema, example: mockProjectResponse() } }, }, ...errorResponseRefs, }, diff --git a/backend/src/modules/project/project-schema.ts b/backend/src/modules/project/project-schema.ts index ce287b443..47a330c0c 100644 --- a/backend/src/modules/project/project-schema.ts +++ b/backend/src/modules/project/project-schema.ts @@ -53,18 +53,11 @@ export const projectSchema = z 'x-tags': schemaTags('data', 'projects', 'app'), }); -export const projectWithMembershipSchema = projectSchema.extend({ - included: projectIncludedSchema.extend({ membership: membershipBaseSchema }), -}); +export const projectWithMembershipSchema = projectSchema.extend({ included: projectIncludedSchema.extend({ membership: membershipBaseSchema }) }); /** Wire registration: lens-widened schemas + entity-bound runtime seam for project */ export const projectContract = evolutionContract.channel('project', { - createItem: z.object({ - id: validTempIdSchema, - name: validNameSchema, - slug: validSlugSchema, - publicAt: z.string().nullable(), - }), + createItem: z.object({ id: validTempIdSchema, name: validNameSchema, slug: validSlugSchema, publicAt: z.string().nullable() }), updateBody: createInsertSchema(projectsTable, { slug: validSlugSchema, name: validNameSchema, @@ -72,13 +65,7 @@ export const projectContract = evolutionContract.channel('project', { bannerUrl: validCDNUrlSchema.nullable(), publicAt: z.string().nullable(), }) - .pick({ - slug: true, - name: true, - thumbnailUrl: true, - bannerUrl: true, - publicAt: true, - }) + .pick({ slug: true, name: true, thumbnailUrl: true, bannerUrl: true, publicAt: true }) .partial(), }); diff --git a/backend/src/modules/project/public-handlers.ts b/backend/src/modules/project/public-handlers.ts index a971d1f42..a2b5459aa 100644 --- a/backend/src/modules/project/public-handlers.ts +++ b/backend/src/modules/project/public-handlers.ts @@ -15,10 +15,7 @@ app.openapi(publicProjectRoutes.getPublicProject, async (ctx) => { const { slug: bySlug } = ctx.req.valid('query'); const entityType = 'project'; - const project = await resolveEntity( - { var: { db: getAdminDb('public project reads') } }, - { entityType, identifier: id, bySlug }, - ); + const project = await resolveEntity({ var: { db: getAdminDb('public project reads') } }, { entityType, identifier: id, bySlug }); if (!project) throw new AppError(404, 'not_found', 'warn', { entityType }); // Anonymous engine check: readable only via the declared public read grant diff --git a/backend/src/modules/project/public-routes.ts b/backend/src/modules/project/public-routes.ts index 0403afbbb..5d91360aa 100644 --- a/backend/src/modules/project/public-routes.ts +++ b/backend/src/modules/project/public-routes.ts @@ -19,10 +19,7 @@ const publicProjectRoutes = { 200: { description: 'Project without membership public', content: { - 'application/json': { - schema: projectSchema.extend({ membership: z.null() }), - example: { ...mockProjectResponse(), membership: null }, - }, + 'application/json': { schema: projectSchema.extend({ membership: z.null() }), example: { ...mockProjectResponse(), membership: null } }, }, }, ...errorResponseRefs, diff --git a/backend/src/modules/push/push-routes.ts b/backend/src/modules/push/push-routes.ts index 6a26ad1f7..d1500d8dc 100644 --- a/backend/src/modules/push/push-routes.ts +++ b/backend/src/modules/push/push-routes.ts @@ -1,6 +1,5 @@ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { userGuard } from '#/middlewares/guard'; -import { errorResponseRefs } from '#/schemas'; import { deletePushSubscriptionQuerySchema, deletePushSubscriptionResponseSchema, @@ -9,63 +8,36 @@ import { pushVapidResponseSchema, } from './push-schema'; -const pushRoutes = { - getPushVapid: createXRoute({ - operationId: 'getPushVapid', +const pushRoutes = createXRoutes(['push'], { + getPushVapid: xRoute({ method: 'get', path: '/vapid', xGuard: [userGuard], - tags: ['push'], summary: 'Get the Web Push application server key', description: 'Returns the VAPID public key `PushManager.subscribe()` needs, or null when this deployment ' + 'has no push keys configured; the client then offers no push toggle.', - responses: { - 200: { - description: 'VAPID public key', - content: { 'application/json': { schema: pushVapidResponseSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('VAPID public key', pushVapidResponseSchema) }, }), - createPushSubscription: createXRoute({ - operationId: 'createPushSubscription', + createPushSubscription: xRoute({ method: 'post', path: '/subscriptions', xGuard: [userGuard], - tags: ['push'], summary: 'Register a Web Push subscription', description: - 'Stores the browser push subscription for the current user. Upserts by endpoint, so ' + - 're-subscribing after key rotation reclaims the row.', - request: { - body: { required: true, content: { 'application/json': { schema: pushSubscriptionBodySchema } } }, - }, - responses: { - 200: { - description: 'Stored subscription', - content: { 'application/json': { schema: pushSubscriptionResponseSchema } }, - }, - ...errorResponseRefs, - }, + 'Stores the browser push subscription for the current user. Upserts by endpoint, so re-subscribing after key rotation reclaims the row.', + request: { body: jsonBody(pushSubscriptionBodySchema) }, + responses: { 200: json('Stored subscription', pushSubscriptionResponseSchema) }, }), - deletePushSubscription: createXRoute({ - operationId: 'deletePushSubscription', + deletePushSubscription: xRoute({ method: 'delete', path: '/subscriptions', xGuard: [userGuard], - tags: ['push'], summary: 'Remove a Web Push subscription', description: 'Deletes the given endpoint for the current user; an endpoint owned by someone else is a no-op.', request: { query: deletePushSubscriptionQuerySchema }, - responses: { - 200: { - description: 'Number of subscriptions removed', - content: { 'application/json': { schema: deletePushSubscriptionResponseSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Number of subscriptions removed', deletePushSubscriptionResponseSchema) }, }), -}; +}); export { pushRoutes }; diff --git a/backend/src/modules/push/push-schema.ts b/backend/src/modules/push/push-schema.ts index 9d7c3760f..d4c86a801 100644 --- a/backend/src/modules/push/push-schema.ts +++ b/backend/src/modules/push/push-schema.ts @@ -4,26 +4,14 @@ import { z } from '@hono/zod-openapi'; export const pushSubscriptionBodySchema = z.object({ endpoint: z.string().url().max(2048), expirationTime: z.number().nullable().optional(), - keys: z.object({ - p256dh: z.string().min(1).max(512), - auth: z.string().min(1).max(512), - }), + keys: z.object({ p256dh: z.string().min(1).max(512), auth: z.string().min(1).max(512) }), }); -export const pushSubscriptionResponseSchema = z.object({ - id: z.string(), - endpoint: z.string(), -}); +export const pushSubscriptionResponseSchema = z.object({ id: z.string(), endpoint: z.string() }); -export const deletePushSubscriptionQuerySchema = z.object({ - endpoint: z.string().url().max(2048), -}); +export const deletePushSubscriptionQuerySchema = z.object({ endpoint: z.string().url().max(2048) }); -export const deletePushSubscriptionResponseSchema = z.object({ - deleted: z.number().int().min(0), -}); +export const deletePushSubscriptionResponseSchema = z.object({ deleted: z.number().int().min(0) }); /** `publicKey` is null while the deployment has no VAPID keys; the client then hides the toggle. */ -export const pushVapidResponseSchema = z.object({ - publicKey: z.string().nullable(), -}); +export const pushVapidResponseSchema = z.object({ publicKey: z.string().nullable() }); diff --git a/backend/src/modules/push/push-sender.ts b/backend/src/modules/push/push-sender.ts index 77e6a1504..1e34e5c11 100644 --- a/backend/src/modules/push/push-sender.ts +++ b/backend/src/modules/push/push-sender.ts @@ -23,17 +23,12 @@ export interface NotificationPushPayload { const CONCURRENCY = 8; /** Sending needs the flag AND both keys; either alone leaves the module receive-only. */ -export const isPushSendConfigured = (): boolean => - appConfig.has.push && Boolean(env.VAPID_PUBLIC_KEY) && Boolean(env.VAPID_PRIVATE_KEY); +export const isPushSendConfigured = (): boolean => appConfig.has.push && Boolean(env.VAPID_PUBLIC_KEY) && Boolean(env.VAPID_PRIVATE_KEY); let vapidApplied = false; function applyVapidDetails(): void { if (vapidApplied) return; - webpush.setVapidDetails( - env.VAPID_SUBJECT ?? appConfig.frontendUrl, - env.VAPID_PUBLIC_KEY as string, - env.VAPID_PRIVATE_KEY as string, - ); + webpush.setVapidDetails(env.VAPID_SUBJECT ?? appConfig.frontendUrl, env.VAPID_PUBLIC_KEY as string, env.VAPID_PRIVATE_KEY as string); vapidApplied = true; } @@ -54,11 +49,8 @@ export interface PushSendDeps { const defaultDeps: PushSendDeps = { send: async (subscription, payload) => { applyVapidDetails(); - await webpush.sendNotification( - { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }, - payload, - { TTL: 60 * 60 * 24 }, - ); + const pushSubscription = { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } }; + await webpush.sendNotification(pushSubscription, payload, { TTL: 60 * 60 * 24 }); }, findSubscriptions: (userIds) => findSubscriptionsByUserIds(userIds), pruneEndpoints: (endpoints) => deleteSubscriptionsByEndpoints(endpoints), @@ -75,11 +67,7 @@ const defaultDeps: PushSendDeps = { * aborts the remaining batch (back off until the next event); other errors are logged per * endpoint and skipped. */ -export async function sendNotificationPush( - userIds: string[], - payload: NotificationPushPayload, - deps: PushSendDeps = defaultDeps, -): Promise { +export async function sendNotificationPush(userIds: string[], payload: NotificationPushPayload, deps: PushSendDeps = defaultDeps): Promise { try { const offline = userIds.filter((userId) => !deps.isOnline(userId)); if (offline.length === 0) return; diff --git a/backend/src/modules/requests/operations/create-request.ts b/backend/src/modules/requests/operations/create-request.ts index 3231b2fb9..f66d32266 100644 --- a/backend/src/modules/requests/operations/create-request.ts +++ b/backend/src/modules/requests/operations/create-request.ts @@ -10,11 +10,7 @@ import { log } from '#/utils/logger'; import { accountExistsEmail, requestInfoEmail, requestResponseEmail } from '../../../../emails'; /** One account-exists mail per address a day, however often the waitlist form names it. */ -const accountExistsMails = getRateLimiterInstance({ - keyPrefix: 'accountExistsMail', - points: 1, - duration: 60 * 60 * 24, -}); +const accountExistsMails = getRateLimiterInstance({ keyPrefix: 'accountExistsMail', points: 1, duration: 60 * 60 * 24 }); interface CreateRequestInput { email: string; @@ -45,9 +41,7 @@ export async function createRequestOp(ctx: DbContext, input: CreateRequestInput) ); if (mailToday) { mailer - .prepareEmails(accountExistsEmail, { name: existingUser.name }, [ - { email: normalizedEmail, lng: existingUser.language }, - ]) + .prepareEmails(accountExistsEmail, { name: existingUser.name }, [{ email: normalizedEmail, lng: existingUser.language }]) .catch((err) => log.error('Failed to send account-exists email', { err })); } return; diff --git a/backend/src/modules/requests/requests-mocks.ts b/backend/src/modules/requests/requests-mocks.ts index d226fa28e..7a3431d6d 100644 --- a/backend/src/modules/requests/requests-mocks.ts +++ b/backend/src/modules/requests/requests-mocks.ts @@ -18,10 +18,7 @@ export const mockRequest = (key = 'request:default'): RequestModel => export const mockRequestResponse = (key = 'request:default'): RequestResponse => { const { tokenId: _, ...request } = mockRequest(key); - return { - ...request, - wasInvited: false, - }; + return { ...request, wasInvited: false }; }; export const mockPaginatedRequestsResponse = (count = 2) => mockPaginated(mockRequestResponse, count); diff --git a/backend/src/modules/requests/requests-queries.ts b/backend/src/modules/requests/requests-queries.ts index a2466dc4f..056073fa1 100644 --- a/backend/src/modules/requests/requests-queries.ts +++ b/backend/src/modules/requests/requests-queries.ts @@ -45,10 +45,7 @@ export const findRequestsPaginated = async (ctx: DbContext, opts: FindRequestsPa }); const itemsQuery = db - .select({ - ...requestsSelect, - wasInvited: sql`(${requestsTable.tokenId} IS NOT NULL)::boolean`.as('wasInvited'), - }) + .select({ ...requestsSelect, wasInvited: sql`(${requestsTable.tokenId} IS NOT NULL)::boolean`.as('wasInvited') }) .from(requestsTable) .where(filter) .orderBy(...orderBy) diff --git a/backend/src/modules/requests/requests-routes.ts b/backend/src/modules/requests/requests-routes.ts index 1509190ba..00814709d 100644 --- a/backend/src/modules/requests/requests-routes.ts +++ b/backend/src/modules/requests/requests-routes.ts @@ -1,79 +1,43 @@ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { publicGuard, sysAdminGuard, userGuard } from '#/middlewares/guard'; import { isNoBot } from '#/middlewares/is-no-bot'; import { bulkPointsLimiter, spamLimiter } from '#/middlewares/rate-limiter/limiters'; import { requestCreateBodySchema, requestListQuerySchema, requestSchema } from '#/modules/requests/requests-schema'; -import { batchResponseSchema, errorResponseRefs, idsBodySchema, paginationSchema } from '#/schemas'; +import { batchResponseSchema, idsBodySchema, paginationSchema } from '#/schemas'; import { mockPaginatedRequestsResponse } from './requests-mocks'; -const requestRoutes = { - createRequest: createXRoute({ - operationId: 'createRequest', +const requestRoutes = createXRoutes(['requests', 'cella'], { + createRequest: xRoute({ method: 'post', path: '/', xGuard: [publicGuard], xRateLimiter: [spamLimiter], middleware: [isNoBot], - tags: ['requests', 'cella'], summary: 'Create request', description: 'Submits a request: a contact form message, a newsletter signup or a waitlist entry. Every submission gets the same answer: an address that has an account gets an email pointing to sign-in, and a repeat of a waitlist or newsletter signup is dropped.', - request: { - body: { - required: true, - content: { 'application/json': { schema: requestCreateBodySchema } }, - }, - }, - responses: { - 204: { description: 'Request received' }, - ...errorResponseRefs, - }, + request: { body: jsonBody(requestCreateBodySchema) }, + responses: { 204: { description: 'Request received' } }, }), - getRequests: createXRoute({ - operationId: 'getRequests', + getRequests: xRoute({ method: 'get', path: '/', xGuard: [userGuard, sysAdminGuard], - tags: ['requests', 'cella'], summary: 'Get list of requests', description: 'Returns a list of submitted requests across all types: contact form, newsletter, and waitlist.', request: { query: requestListQuerySchema }, - responses: { - 200: { - description: 'Requests', - content: { - 'application/json': { - schema: paginationSchema(requestSchema), - example: mockPaginatedRequestsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Requests', paginationSchema(requestSchema), mockPaginatedRequestsResponse()) }, }), - deleteRequests: createXRoute({ - operationId: 'deleteRequests', + deleteRequests: xRoute({ method: 'delete', path: '/', xGuard: [userGuard, sysAdminGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['requests', 'cella'], summary: 'Delete requests', description: 'Deletes one or more requests from the system by their IDs.', - request: { - body: { - required: true, - content: { 'application/json': { schema: idsBodySchema() } }, - }, - }, - responses: { - 200: { - description: 'Success', - content: { 'application/json': { schema: batchResponseSchema() } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(idsBodySchema()) }, + responses: { 200: json('Success', batchResponseSchema()) }, }), -}; +}); export { requestRoutes }; diff --git a/backend/src/modules/requests/requests-schema.ts b/backend/src/modules/requests/requests-schema.ts index 541cf9f33..44b3c10ec 100644 --- a/backend/src/modules/requests/requests-schema.ts +++ b/backend/src/modules/requests/requests-schema.ts @@ -22,6 +22,4 @@ export const requestCreateBodySchema = z.object({ message: z.string().max(maxLength.field).nullable(), }); -export const requestListQuerySchema = paginationQuerySchema.extend({ - sort: z.enum(['id', 'email', 'type', 'createdAt']).default('createdAt'), -}); +export const requestListQuerySchema = paginationQuerySchema.extend({ sort: z.enum(['id', 'email', 'type', 'createdAt']).default('createdAt') }); diff --git a/backend/src/modules/schema-contracts.test.ts b/backend/src/modules/schema-contracts.test.ts index 470e2f0ed..fd5bdfa88 100644 --- a/backend/src/modules/schema-contracts.test.ts +++ b/backend/src/modules/schema-contracts.test.ts @@ -10,18 +10,12 @@ describe('memberListQuerySchema', () => { const baseQuery = { entityId: firstId, entityType: 'organization' as const }; it('parses a bounded comma-separated UUID list once at the request boundary', () => { - expect(memberListQuerySchema.parse({ ...baseQuery, userIds: `${firstId}, ${secondId}` }).userIds).toEqual([ - firstId, - secondId, - ]); + expect(memberListQuerySchema.parse({ ...baseQuery, userIds: `${firstId}, ${secondId}` }).userIds).toEqual([firstId, secondId]); }); - it.each(['', 'not-an-id', `${firstId},`, Array.from({ length: 51 }, () => firstId).join(',')])( - 'rejects invalid member ID list %s', - (userIds) => { - expect(memberListQuerySchema.safeParse({ ...baseQuery, userIds }).success).toBe(false); - }, - ); + it.each(['', 'not-an-id', `${firstId},`, Array.from({ length: 51 }, () => firstId).join(',')])('rejects invalid member ID list %s', (userIds) => { + expect(memberListQuerySchema.safeParse({ ...baseQuery, userIds }).success).toBe(false); + }); }); describe('sendNewsletterBodySchema', () => { diff --git a/backend/src/modules/seen/operations/get-unseen-counts.ts b/backend/src/modules/seen/operations/get-unseen-counts.ts index ae5fe85f9..ebc3c37a3 100644 --- a/backend/src/modules/seen/operations/get-unseen-counts.ts +++ b/backend/src/modules/seen/operations/get-unseen-counts.ts @@ -2,12 +2,7 @@ import type { SQL } from 'drizzle-orm'; import { hierarchy, type SeenTrackedProductType } from 'shared'; import type { UserContext } from '#/core/context'; import { tenantRead } from '#/db/tenant-context'; -import { - groupingChannelTypes, - homeChannelColumn, - seenWindowMs, - trackedProductTypes, -} from '#/modules/seen/operations/mark-seen'; +import { groupingChannelTypes, homeChannelColumn, seenWindowMs, trackedProductTypes } from '#/modules/seen/operations/mark-seen'; import { findUnseenCountsByUser } from '#/modules/seen/seen-queries'; import { actorFrom } from '#/permissions/access'; import { resolveCollectionReadFilter } from '#/permissions/collection-scope'; @@ -50,12 +45,9 @@ export async function getUnseenCountsOp(ctx: UserContext) { for (const productType of trackedProductTypes) { const readFilter = resolveCollectionReadFilter(memberships, productType, organizationId, actor); - const scopeWhere = buildCollectionReadWhere( - readFilter, - getEntityTable(productType), - homeChannelColumn(productType), - actor, - ); + const entityTable = getEntityTable(productType); + const homeColumn = homeChannelColumn(productType); + const scopeWhere = buildCollectionReadWhere(readFilter, entityTable, homeColumn, actor); if (scopeWhere.kind === 'none') continue; readableTypes.push(productType); if (scopeWhere.kind === 'where') scopeWhereByType[productType] = scopeWhere.where; diff --git a/backend/src/modules/seen/operations/mark-seen.ts b/backend/src/modules/seen/operations/mark-seen.ts index a9ddb3467..45a63a25e 100644 --- a/backend/src/modules/seen/operations/mark-seen.ts +++ b/backend/src/modules/seen/operations/mark-seen.ts @@ -14,11 +14,7 @@ import { buildCollectionReadWhere } from '#/permissions/row-predicates'; import { getEntityTable } from '#/tables'; import { log } from '#/utils/logger'; -type OrgScopedEntityTable = AnyPgTable & { - id: PgColumn; - organizationId: PgColumn; - createdAt: PgColumn; -}; +type OrgScopedEntityTable = AnyPgTable & { id: PgColumn; organizationId: PgColumn; createdAt: PgColumn }; export const trackedProductTypes = appConfig.seenTrackedProductTypes; const trackedProductTypeSet = new Set(trackedProductTypes); @@ -38,9 +34,7 @@ export const homeChannelColumn = (productType: SeenTrackedProductType): PgColumn const table = getEntityTable(productType); const columns = getColumns(table) as Record; const parent = hierarchy.getParent(productType); - const parentColumn = parent - ? columns[appConfig.entityIdColumnKeys[parent as keyof typeof appConfig.entityIdColumnKeys]] - : undefined; + const parentColumn = parent ? columns[appConfig.entityIdColumnKeys[parent as keyof typeof appConfig.entityIdColumnKeys]] : undefined; const column = parentColumn ?? columns.organizationId; if (!column) throw new Error(`[Seen] No sub-context column for "${productType}"`); return column; @@ -55,9 +49,7 @@ export async function markSeenOp(ctx: UserContext, entityIds: string[], productT const user = ctx.var.user; const organization = ctx.var.organization; - log.debug( - `markSeen: ${productType} x${entityIds.length} for org ${organization.id.slice(0, 8)} by ${user.id.slice(0, 8)}`, - ); + log.debug(`markSeen: ${productType} x${entityIds.length} for org ${organization.id.slice(0, 8)} by ${user.id.slice(0, 8)}`); if (!isTrackedProductType(productType)) { log.debug(`markSeen: skipping non-tracked type "${productType}"`); @@ -77,11 +69,7 @@ export async function markSeenOp(ctx: UserContext, entityIds: string[], productT const scopeWhere = buildCollectionReadWhere(readFilter, entityTable, homeChannelColumn(productType), actor); if (scopeWhere.kind === 'none') return { newCount: 0 }; - const filters: SQL[] = [ - inArray(orgTable.id, entityIds), - eq(orgTable.organizationId, organization.id), - gt(seenRecencySql(orgTable), windowCutoff), - ]; + const filters: SQL[] = [inArray(orgTable.id, entityIds), eq(orgTable.organizationId, organization.id), gt(seenRecencySql(orgTable), windowCutoff)]; const { deletedAt } = getColumns(entityTable) as Record; if (deletedAt) filters.push(isNull(deletedAt)); const draftVisible = draftVisibleRowsPredicate(entityTable, user.id); diff --git a/backend/src/modules/seen/seen-by-db.ts b/backend/src/modules/seen/seen-by-db.ts index 99c303a9c..e47d208af 100644 --- a/backend/src/modules/seen/seen-by-db.ts +++ b/backend/src/modules/seen/seen-by-db.ts @@ -27,10 +27,7 @@ export const seenByTable = snakeCase.table( index('seen_by_user_channel_type_index').on(table.userId, table.channelId, table.productType), index('seen_by_product_id_index').on(table.productId), index('seen_by_tenant_id_index').on(table.tenantId), - foreignKey({ - columns: [table.userId], - foreignColumns: [usersTable.id], - }).onDelete('cascade'), + foreignKey({ columns: [table.userId], foreignColumns: [usersTable.id] }).onDelete('cascade'), ], ); diff --git a/backend/src/modules/seen/seen-queries.ts b/backend/src/modules/seen/seen-queries.ts index 2b81f6dc6..63f379a85 100644 --- a/backend/src/modules/seen/seen-queries.ts +++ b/backend/src/modules/seen/seen-queries.ts @@ -6,11 +6,7 @@ import { homeChannelIdSql } from '#/db/utils/home-channel'; import { seenByTable } from '#/modules/seen/seen-by-db'; import { getEntityTable } from '#/tables'; -type OrgScopedEntityTable = AnyPgTable & { - id: PgColumn; - organizationId: PgColumn; - createdAt: PgColumn; -}; +type OrgScopedEntityTable = AnyPgTable & { id: PgColumn; organizationId: PgColumn; createdAt: PgColumn }; /** A row's recency for the seen window: publish time on draft-lifecycle tables, creation time elsewhere. */ export const seenRecencySql = (table: AnyPgTable & { createdAt: PgColumn }): SQL => { @@ -54,11 +50,7 @@ export const findUnseenCountsByUser = async ( if (scopeWhere) filters.push(scopeWhere); const entityRows = await db - .select({ - channelId: channelIdColumn, - productType: sql`${productType}`, - unseenCount: count(), - }) + .select({ channelId: channelIdColumn, productType: sql`${productType}`, unseenCount: count() }) .from(entityTable) .where(and(...filters)) .groupBy(channelIdColumn); diff --git a/backend/src/modules/seen/seen-routes.ts b/backend/src/modules/seen/seen-routes.ts index baa42c6d6..3e9817d81 100644 --- a/backend/src/modules/seen/seen-routes.ts +++ b/backend/src/modules/seen/seen-routes.ts @@ -1,55 +1,33 @@ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { bulkPointsLimiter, syncReadLimiter } from '#/middlewares/rate-limiter/limiters'; import { seenBatchBodySchema, seenBatchResponseSchema, unseenCountsResponseSchema } from '#/modules/seen/seen-schema'; -import { errorResponseRefs, tenantOrgParamSchema } from '#/schemas'; +import { tenantOrgParamSchema } from '#/schemas'; -const seenRoutes = { - markSeen: createXRoute({ - operationId: 'markSeen', +const seenRoutes = createXRoutes(['seen', 'cella'], { + markSeen: xRoute({ method: 'post', path: '/', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['seen', 'cella'], summary: 'Mark entities as seen', description: 'Records that the current user has viewed one or more product entities. ' + 'Deduplicates against existing records. Updates entity view counts for newly seen entities.', - request: { - params: tenantOrgParamSchema, - body: { - required: true, - content: { 'application/json': { schema: seenBatchBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Seen records processed', - content: { 'application/json': { schema: seenBatchResponseSchema } }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, body: jsonBody(seenBatchBodySchema) }, + responses: { 200: json('Seen records processed', seenBatchResponseSchema) }, }), - getUnseenCounts: createXRoute({ - operationId: 'getUnseenCounts', + getUnseenCounts: xRoute({ method: 'get', path: '/counts', xGuard: [userGuard], xRateLimiter: [syncReadLimiter], - tags: ['seen', 'cella'], summary: 'Get unseen counts', description: 'Returns the number of unseen product entities per parent channel entity (e.g., project) and entity type for the current user. ' + 'Computed within the rolling seen window so entities older than seen_by retention do not participate.', - responses: { - 200: { - description: 'Unseen counts per parent channel entity per entity type', - content: { 'application/json': { schema: unseenCountsResponseSchema } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Unseen counts per parent channel entity per entity type', unseenCountsResponseSchema) }, }), -}; +}); export { seenRoutes }; diff --git a/backend/src/modules/seen/seen-schema.ts b/backend/src/modules/seen/seen-schema.ts index 7227f17f5..64ef60288 100644 --- a/backend/src/modules/seen/seen-schema.ts +++ b/backend/src/modules/seen/seen-schema.ts @@ -7,9 +7,7 @@ export const seenBatchBodySchema = z.object({ entityType: productEntityTypeSchema.describe('Entity type for all IDs in this batch'), }); -export const seenBatchResponseSchema = z.object({ - newCount: z.number().int().min(0).describe('Number of entities newly marked as seen (deduped)'), -}); +export const seenBatchResponseSchema = z.object({ newCount: z.number().int().min(0).describe('Number of entities newly marked as seen (deduped)') }); /** Shape: { [channelId]: { [productEntityType]: unseenCount } }; keys are dynamic IDs and type strings. */ export const unseenCountsResponseSchema = z.record(z.string(), z.record(z.string(), z.number().int().min(0))); diff --git a/backend/src/modules/service-accounts/helpers/api-key.ts b/backend/src/modules/service-accounts/helpers/api-key.ts index ddf50c5c8..0f4732d88 100644 --- a/backend/src/modules/service-accounts/helpers/api-key.ts +++ b/backend/src/modules/service-accounts/helpers/api-key.ts @@ -19,19 +19,13 @@ const CHECKSUM_LENGTH = 6; /** The wire tag of each key type: `_sk_…` is a secret key, `_pk_…` a publishable one. */ const wireTags = { secret: 'sk', publishable: 'pk' } as const satisfies Record; type WireTag = (typeof wireTags)[ApiKeyType]; -const typeOfTag = Object.fromEntries(Object.entries(wireTags).map(([type, tag]) => [tag, type])) as Record< - WireTag, - ApiKeyType ->; +const typeOfTag = Object.fromEntries(Object.entries(wireTags).map(([type, tag]) => [tag, type])) as Record; /** `live` keys exist only in production; everything else mints `test` keys. */ type KeyEnv = 'live' | 'test'; /** What a well-formed key of this app says about itself, in the columns the apiKeys row stores. */ -export type ParsedApiKey = Pick & { - type: ApiKeyType; - env: KeyEnv; -}; +export type ParsedApiKey = Pick & { type: ApiKeyType; env: KeyEnv }; const toBase62 = (n: number, length: number): string => { let out = ''; @@ -52,9 +46,7 @@ const randomBase62 = (length: number): string => { export const checksumOf = (body: string): string => toBase62(crc32(body), CHECKSUM_LENGTH); -const keyPattern = new RegExp( - `^${appConfig.slug}_(sk|pk)_(live|test)_([0-9A-Za-z]{${SECRET_LENGTH}})([0-9A-Za-z]{${CHECKSUM_LENGTH}})$`, -); +const keyPattern = new RegExp(`^${appConfig.slug}_(sk|pk)_(live|test)_([0-9A-Za-z]{${SECRET_LENGTH}})([0-9A-Za-z]{${CHECKSUM_LENGTH}})$`); /** * `_sk_live_<32 base62><6 base62 crc32>`: scannable by prefix, checkable offline, dispatched on shape by the diff --git a/backend/src/modules/service-accounts/helpers/insert-service-accounts.ts b/backend/src/modules/service-accounts/helpers/insert-service-accounts.ts deleted file mode 100644 index f6044919f..000000000 --- a/backend/src/modules/service-accounts/helpers/insert-service-accounts.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { generateId } from 'shared/utils/entity-id'; -import type { DbOrTx } from '#/db/db'; -import { insertActors } from '#/modules/actors/helpers/insert-actors'; -import { - type InsertServiceAccountModel, - type ServiceAccountModel, - serviceAccountsTable, -} from '#/modules/service-accounts/service-accounts-db'; - -/** The only way to insert a service account: its `actors` row of kind `service` goes first, in one transaction. */ -export async function insertServiceAccount( - db: DbOrTx, - record: InsertServiceAccountModel, -): Promise { - const id = record.id ?? generateId(); - return db.transaction(async (tx) => { - await insertActors(tx, [id], 'service'); - const [account] = await tx - .insert(serviceAccountsTable) - .values({ ...record, id }) - .returning(); - return account; - }); -} diff --git a/backend/src/modules/service-accounts/helpers/issue-api-key.ts b/backend/src/modules/service-accounts/helpers/issue-api-key.ts deleted file mode 100644 index 79dcd59da..000000000 --- a/backend/src/modules/service-accounts/helpers/issue-api-key.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DbOrTx } from '#/db/db'; -import { - type ApiKeyModel, - apiKeySafeColumns, - apiKeysTable, - type InsertApiKeyModel, -} from '#/modules/service-accounts/api-keys-db'; -import { generateApiKey } from '#/modules/service-accounts/helpers/api-key'; - -type IssueInput = Pick; - -/** Mints a secret key for an actor. Only the hash is stored; the plaintext `secret` is returned once. */ -export async function issueApiKey(db: DbOrTx, input: IssueInput): Promise<{ apiKey: ApiKeyModel; secret: string }> { - const { key, parsed } = generateApiKey('secret'); - const [apiKey] = await db - .insert(apiKeysTable) - .values({ ...input, ...parsed }) - .returning(apiKeySafeColumns); - return { apiKey, secret: key }; -} diff --git a/backend/src/modules/service-accounts/helpers/managed-service-account.ts b/backend/src/modules/service-accounts/helpers/managed-service-account.ts deleted file mode 100644 index 8d1d5e396..000000000 --- a/backend/src/modules/service-accounts/helpers/managed-service-account.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { UserContext } from '#/core/context'; -import { AppError } from '#/core/error'; -import { findServiceAccountInTenant } from '#/modules/service-accounts/service-accounts-queries'; -import { getValidChannel } from '#/permissions'; - -/** Every service-account route is an organization admin's act (D9): the caller must be allowed to update the org. */ -export async function requireOrgAdmin(ctx: UserContext) { - return getValidChannel(ctx, ctx.var.organizationId, 'organization', 'update'); -} - -/** The account the route addresses, for an organization admin: throws 403 without the role, 404 when it is not in the caller's tenant. */ -export async function requireManagedServiceAccount(ctx: UserContext, id: string) { - await requireOrgAdmin(ctx); - const account = await findServiceAccountInTenant(ctx, { id, tenantId: ctx.var.tenantId }); - if (!account) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'serviceAccount' } }); - return account; -} diff --git a/backend/src/modules/service-accounts/operations/create-api-key.ts b/backend/src/modules/service-accounts/operations/create-api-key.ts index b4dfe6c05..a7cd7c7c6 100644 --- a/backend/src/modules/service-accounts/operations/create-api-key.ts +++ b/backend/src/modules/service-accounts/operations/create-api-key.ts @@ -1,11 +1,16 @@ import type { UserContext } from '#/core/context'; import { AppError } from '#/core/error'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { issueApiKey } from '#/modules/service-accounts/helpers/issue-api-key'; -import { requireManagedServiceAccount } from '#/modules/service-accounts/helpers/managed-service-account'; -import { countLiveApiKeys, scheduleApiKeyExpiry } from '#/modules/service-accounts/service-accounts-queries'; +import { generateApiKey } from '#/modules/service-accounts/helpers/api-key'; +import { + countLiveApiKeys, + findServiceAccountInTenant, + insertApiKey, + scheduleApiKeyExpiry, +} from '#/modules/service-accounts/service-accounts-queries'; import type { CreateApiKeyInput } from '#/modules/service-accounts/service-accounts-schema'; import { assertTenantQuota } from '#/modules/tenants/tenant-restrictions'; +import { getValidChannel } from '#/permissions'; import { log } from '#/utils/logger'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -15,12 +20,16 @@ const DAY_MS = 24 * 60 * 60 * 1000; * key first. The plaintext is in the response once and nowhere else. */ export async function createApiKeyOp(ctx: UserContext, serviceAccountId: string, input: CreateApiKeyInput) { - const account = await requireManagedServiceAccount(ctx, serviceAccountId); - assertTenantQuota(ctx, 'apiKey', await countLiveApiKeys(ctx, { tenantId: ctx.var.tenantId })); + const { tenantId } = ctx.var; + await getValidChannel(ctx, ctx.var.organizationId, 'organization', 'update'); + const account = await findServiceAccountInTenant(ctx, { id: serviceAccountId, tenantId }); + if (!account) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'serviceAccount' } }); + assertTenantQuota(ctx, 'apiKey', await countLiveApiKeys(ctx, { tenantId })); // The predecessor is checked before the new key exists, and both writes land or neither does: a bad `rollFrom` // never leaves an orphan live key whose plaintext nobody received. - const issued = await ctx.var.db.transaction(async (tx) => { + const { key: secret, parsed } = generateApiKey('secret'); + const apiKey = await ctx.var.db.transaction(async (tx) => { const txCtx = { var: { db: tx } }; if (input.rollFrom) { const expiresAt = new Date(Date.now() + input.rollOverlapDays * DAY_MS).toISOString(); @@ -28,18 +37,20 @@ export async function createApiKeyOp(ctx: UserContext, serviceAccountId: string, if (!rolled) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'apiKey' } }); log.info('ApiKey rolled', { from: input.rollFrom, overlapEnd: expiresAt }); } - const key = await issueApiKey(tx, { - actorId: account.id, - tenantId: ctx.var.tenantId, - name: input.name, - scopes: input.scopes ?? null, - expiresAt: input.expiresAt, - createdBy: ctx.var.actor.id, + return insertApiKey(txCtx, { + values: { + actorId: account.id, + tenantId, + name: input.name, + scopes: input.scopes ?? null, + expiresAt: input.expiresAt, + createdBy: ctx.var.actor.id, + ...parsed, + }, }); - await invalidateCache.serviceAccount(tx, account); - return key; }); + invalidateCache.serviceAccount(account); - log.info('ApiKey issued', { keyId: issued.apiKey.id, serviceAccountId: account.id }); - return { ...issued.apiKey, secret: issued.secret }; + log.info('ApiKey issued', { keyId: apiKey.id, serviceAccountId: account.id }); + return { ...apiKey, secret }; } diff --git a/backend/src/modules/service-accounts/operations/create-service-account.ts b/backend/src/modules/service-accounts/operations/create-service-account.ts index 63297a78d..bdc3de601 100644 --- a/backend/src/modules/service-accounts/operations/create-service-account.ts +++ b/backend/src/modules/service-accounts/operations/create-service-account.ts @@ -1,12 +1,11 @@ import { type EntityRole, hierarchy } from 'shared'; import type { UserContext } from '#/core/context'; import { AppError } from '#/core/error'; -import { insertServiceAccount } from '#/modules/service-accounts/helpers/insert-service-accounts'; -import { issueApiKey } from '#/modules/service-accounts/helpers/issue-api-key'; -import { requireOrgAdmin } from '#/modules/service-accounts/helpers/managed-service-account'; -import { countServiceAccounts } from '#/modules/service-accounts/service-accounts-queries'; +import { generateApiKey } from '#/modules/service-accounts/helpers/api-key'; +import { countServiceAccounts, insertApiKey, insertServiceAccount } from '#/modules/service-accounts/service-accounts-queries'; import type { CreateServiceAccountInput } from '#/modules/service-accounts/service-accounts-schema'; import { assertTenantQuota } from '#/modules/tenants/tenant-restrictions'; +import { getValidChannel } from '#/permissions'; import { log } from '#/utils/logger'; /** @@ -17,9 +16,9 @@ export async function createServiceAccountOp(ctx: UserContext, input: CreateServ const { db, tenantId, organizationId, isSystemAdmin } = ctx.var; const creatorId = ctx.var.actor.id; - const { membership } = await requireOrgAdmin(ctx); - // Roles are listed most-privileged first; a lower index is a higher role. - // fork: widened, since the membership's role type also spans channel roles the organization does not declare (`guest`) + const { membership } = await getValidChannel(ctx, organizationId, 'organization', 'update'); + // Roles are listed most-privileged first; a lower index is a higher role. Widened: a membership's role type also + // spans channel roles the organization does not declare. const roles: readonly EntityRole[] = hierarchy.getRoles('organization'); const creatorRank = isSystemAdmin ? 0 : membership ? roles.indexOf(membership.role) : -1; if (creatorRank < 0 || roles.indexOf(input.role) < creatorRank) { @@ -28,30 +27,34 @@ export async function createServiceAccountOp(ctx: UserContext, input: CreateServ assertTenantQuota(ctx, 'serviceAccount', await countServiceAccounts(ctx, { tenantId })); - // Account and first key land together: a failed key issue never leaves a keyless account behind. - const { serviceAccount, issued } = await db.transaction(async (tx) => { - const serviceAccount = await insertServiceAccount(tx, { - tenantId, - name: input.name, - bindings: [{ channelType: 'organization', channelId: organizationId, organizationId, role: input.role }], - createdBy: creatorId, + // Account and first key land together: a failed key insert never leaves a keyless account behind. + const { serviceAccount, apiKey } = await db.transaction(async (tx) => { + const txCtx = { var: { db: tx } }; + const serviceAccount = await insertServiceAccount(txCtx, { + values: { + tenantId, + name: input.name, + bindings: [{ channelType: 'organization', channelId: organizationId, organizationId, role: input.role }], + createdBy: creatorId, + }, }); - const issued = input.key - ? await issueApiKey(tx, { - actorId: serviceAccount.id, - tenantId, - name: input.key.name, - scopes: input.key.scopes ?? null, - expiresAt: input.key.expiresAt, - createdBy: creatorId, - }) - : null; - return { serviceAccount, issued }; + if (!input.key) return { serviceAccount, apiKey: null }; + + const { key: secret, parsed } = generateApiKey('secret'); + const apiKey = await insertApiKey(txCtx, { + values: { + actorId: serviceAccount.id, + tenantId, + name: input.key.name, + scopes: input.key.scopes ?? null, + expiresAt: input.key.expiresAt, + createdBy: creatorId, + ...parsed, + }, + }); + return { serviceAccount, apiKey: { ...apiKey, secret } }; }); - log.info('Service account created', { serviceAccountId: serviceAccount.id, withKey: issued !== null }); - return { - serviceAccount, - ...(issued && { apiKey: { ...issued.apiKey, secret: issued.secret } }), - }; + log.info('Service account created', { serviceAccountId: serviceAccount.id, withKey: apiKey !== null }); + return { serviceAccount, ...(apiKey && { apiKey }) }; } diff --git a/backend/src/modules/service-accounts/operations/get-api-keys.ts b/backend/src/modules/service-accounts/operations/get-api-keys.ts index b87d0074c..644a3cf84 100644 --- a/backend/src/modules/service-accounts/operations/get-api-keys.ts +++ b/backend/src/modules/service-accounts/operations/get-api-keys.ts @@ -1,8 +1,11 @@ import type { UserContext } from '#/core/context'; -import { requireManagedServiceAccount } from '#/modules/service-accounts/helpers/managed-service-account'; -import { findApiKeysByActor } from '#/modules/service-accounts/service-accounts-queries'; +import { AppError } from '#/core/error'; +import { findApiKeysByActor, findServiceAccountInTenant } from '#/modules/service-accounts/service-accounts-queries'; +import { getValidChannel } from '#/permissions'; export async function getApiKeysOp(ctx: UserContext, serviceAccountId: string) { - const account = await requireManagedServiceAccount(ctx, serviceAccountId); + await getValidChannel(ctx, ctx.var.organizationId, 'organization', 'update'); + const account = await findServiceAccountInTenant(ctx, { id: serviceAccountId, tenantId: ctx.var.tenantId }); + if (!account) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'serviceAccount' } }); return { items: await findApiKeysByActor(ctx, { actorId: account.id }) }; } diff --git a/backend/src/modules/service-accounts/operations/get-service-accounts.ts b/backend/src/modules/service-accounts/operations/get-service-accounts.ts index dfe231c02..558eaa104 100644 --- a/backend/src/modules/service-accounts/operations/get-service-accounts.ts +++ b/backend/src/modules/service-accounts/operations/get-service-accounts.ts @@ -1,9 +1,9 @@ import type { UserContext } from '#/core/context'; -import { requireOrgAdmin } from '#/modules/service-accounts/helpers/managed-service-account'; import { listServiceAccounts } from '#/modules/service-accounts/service-accounts-queries'; import type { ServiceAccountListQuery } from '#/modules/service-accounts/service-accounts-schema'; +import { getValidChannel } from '#/permissions'; export async function getServiceAccountsOp(ctx: UserContext, input: ServiceAccountListQuery) { - await requireOrgAdmin(ctx); + await getValidChannel(ctx, ctx.var.organizationId, 'organization', 'update'); return listServiceAccounts(ctx, { ...input, tenantId: ctx.var.tenantId }); } diff --git a/backend/src/modules/service-accounts/operations/revoke-api-key.ts b/backend/src/modules/service-accounts/operations/revoke-api-key.ts index 2fb2901fc..200c9df62 100644 --- a/backend/src/modules/service-accounts/operations/revoke-api-key.ts +++ b/backend/src/modules/service-accounts/operations/revoke-api-key.ts @@ -1,23 +1,19 @@ import type { UserContext } from '#/core/context'; import { AppError } from '#/core/error'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { requireManagedServiceAccount } from '#/modules/service-accounts/helpers/managed-service-account'; -import { revokeApiKey } from '#/modules/service-accounts/service-accounts-queries'; +import { findServiceAccountInTenant, revokeApiKey } from '#/modules/service-accounts/service-accounts-queries'; +import { getValidChannel } from '#/permissions'; import { getIsoDate } from '#/utils/iso-date'; import { log } from '#/utils/logger'; /** The row stays for the audit trail; the key and the tokens minted with it stop in every process with the commit. */ export async function revokeApiKeyOp(ctx: UserContext, serviceAccountId: string, keyId: string) { - const account = await requireManagedServiceAccount(ctx, serviceAccountId); - const revoked = await ctx.var.db.transaction(async (tx) => { - const revoked = await revokeApiKey( - { var: { db: tx } }, - { actorId: account.id, id: keyId, revokedAt: getIsoDate(), revokedBy: ctx.var.actor.id }, - ); - if (!revoked) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'apiKey' } }); - await invalidateCache.serviceAccount(tx, account); - return revoked; - }); + await getValidChannel(ctx, ctx.var.organizationId, 'organization', 'update'); + const account = await findServiceAccountInTenant(ctx, { id: serviceAccountId, tenantId: ctx.var.tenantId }); + if (!account) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'serviceAccount' } }); + const revoked = await revokeApiKey(ctx, { actorId: account.id, id: keyId, revokedAt: getIsoDate(), revokedBy: ctx.var.actor.id }); + if (!revoked) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'apiKey' } }); + invalidateCache.serviceAccount(account); log.info('ApiKey revoked', { keyId, serviceAccountId: account.id }); return revoked; } diff --git a/backend/src/modules/service-accounts/operations/update-service-account.ts b/backend/src/modules/service-accounts/operations/update-service-account.ts index 2f1c9cc9b..7ea2dc2f5 100644 --- a/backend/src/modules/service-accounts/operations/update-service-account.ts +++ b/backend/src/modules/service-accounts/operations/update-service-account.ts @@ -1,9 +1,9 @@ -import { eq } from 'drizzle-orm'; import type { UserContext } from '#/core/context'; +import { AppError } from '#/core/error'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { requireManagedServiceAccount } from '#/modules/service-accounts/helpers/managed-service-account'; -import { serviceAccountsTable } from '#/modules/service-accounts/service-accounts-db'; +import { updateServiceAccount } from '#/modules/service-accounts/service-accounts-queries'; import type { UpdateServiceAccountInput } from '#/modules/service-accounts/service-accounts-schema'; +import { getValidChannel } from '#/permissions'; import { getIsoDate } from '#/utils/iso-date'; import { log } from '#/utils/logger'; @@ -12,16 +12,14 @@ import { log } from '#/utils/logger'; * every process with the commit: the account's keys and tokens, and for an installed app its users' tokens in the tenant. */ export async function updateServiceAccountOp(ctx: UserContext, id: string, input: UpdateServiceAccountInput) { - const account = await requireManagedServiceAccount(ctx, id); - const updated = await ctx.var.db.transaction(async (tx) => { - const [updated] = await tx - .update(serviceAccountsTable) - .set({ ...input, updatedAt: getIsoDate(), updatedBy: ctx.var.actor.id }) - .where(eq(serviceAccountsTable.id, account.id)) - .returning(); - await invalidateCache.serviceAccount(tx, updated); - return updated; + await getValidChannel(ctx, ctx.var.organizationId, 'organization', 'update'); + const updated = await updateServiceAccount(ctx, { + id, + tenantId: ctx.var.tenantId, + values: { ...input, updatedAt: getIsoDate(), updatedBy: ctx.var.actor.id }, }); + if (!updated) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'serviceAccount' } }); + invalidateCache.serviceAccount(updated); log.info('Service account updated', { serviceAccountId: id, status: updated.status }); return updated; } diff --git a/backend/src/modules/service-accounts/service-accounts-mocks.ts b/backend/src/modules/service-accounts/service-accounts-mocks.ts index 5670748ef..7b5b5be40 100644 --- a/backend/src/modules/service-accounts/service-accounts-mocks.ts +++ b/backend/src/modules/service-accounts/service-accounts-mocks.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { appConfig } from 'shared'; +import { appConfig, hierarchy } from 'shared'; import { mockPaginated, mockPastIsoDate, mockTenantId, mockUuid, withFakerSeed } from '#/mocks'; import type { ApiKeyModel } from '#/modules/service-accounts/api-keys-db'; import { checksumOf } from '#/modules/service-accounts/helpers/api-key'; @@ -14,7 +14,14 @@ export const mockServiceAccountResponse = (key = 'serviceAccount:default'): Serv tenantId: mockTenantId(), name: `${faker.hacker.noun()} bot`, status: 'active', - bindings: [{ channelType: 'organization', channelId: organizationId, organizationId, role: 'member' }], + bindings: [ + { + channelType: 'organization', + channelId: organizationId, + organizationId, + role: hierarchy.getLeastPrivilegedRole('organization'), + }, + ], oauthClientId: null, createdBy: mockUuid(), createdAt, @@ -43,9 +50,6 @@ export const mockApiKeyResponse = (key = 'apiKey:default'): ApiKeyModel => createdAt: mockPastIsoDate(), })); -export const mockCreatedApiKeyResponse = (key = 'createdApiKey:default') => ({ - ...mockApiKeyResponse(key), - secret: exampleSecret, -}); +export const mockCreatedApiKeyResponse = (key = 'createdApiKey:default') => ({ ...mockApiKeyResponse(key), secret: exampleSecret }); export const mockPaginatedServiceAccountsResponse = (count = 2) => mockPaginated(mockServiceAccountResponse, count); diff --git a/backend/src/modules/service-accounts/service-accounts-queries.ts b/backend/src/modules/service-accounts/service-accounts-queries.ts index c3eba6af8..7f1fd0d8c 100644 --- a/backend/src/modules/service-accounts/service-accounts-queries.ts +++ b/backend/src/modules/service-accounts/service-accounts-queries.ts @@ -1,8 +1,10 @@ import { and, count, desc, eq, gt, ilike, isNull, or, type SQL, sql } from 'drizzle-orm'; +import { generateId } from 'shared/utils/entity-id'; import type { DbContext } from '#/core/context'; import { type ListTotalSource, resolveListTotal } from '#/db/utils/list-total'; -import { apiKeySafeColumns, apiKeysTable } from '#/modules/service-accounts/api-keys-db'; -import { serviceAccountsTable } from '#/modules/service-accounts/service-accounts-db'; +import { insertActors } from '#/modules/actors/actors-queries'; +import { type ApiKeyModel, apiKeySafeColumns, apiKeysTable, type InsertApiKeyModel } from '#/modules/service-accounts/api-keys-db'; +import { type InsertServiceAccountModel, type ServiceAccountModel, serviceAccountsTable } from '#/modules/service-accounts/service-accounts-db'; import { prepareStringForILikeFilter } from '#/utils/sql'; interface InTenantOpts { @@ -50,6 +52,38 @@ export async function listServiceAccounts(ctx: DbContext, { tenantId, q, offset, return resolveListTotal(itemsQuery, totalSource); } +interface InsertServiceAccountOpts { + values: InsertServiceAccountModel; +} + +/** The only way to insert a service account: its `actors` row of kind `service` goes first, in one transaction. */ +export async function insertServiceAccount(ctx: DbContext, { values }: InsertServiceAccountOpts): Promise { + const id = values.id ?? generateId(); + return ctx.var.db.transaction(async (tx) => { + await insertActors({ var: { db: tx } }, { ids: [id], kind: 'service' }); + const [account] = await tx + .insert(serviceAccountsTable) + .values({ ...values, id }) + .returning(); + return account; + }); +} + +interface UpdateServiceAccountOpts extends InTenantOpts { + id: string; + values: Partial>; +} + +/** The updated account, or undefined when no such account exists in the tenant. */ +export async function updateServiceAccount(ctx: DbContext, { id, tenantId, values }: UpdateServiceAccountOpts) { + const [account] = await ctx.var.db + .update(serviceAccountsTable) + .set(values) + .where(and(eq(serviceAccountsTable.id, id), eq(serviceAccountsTable.tenantId, tenantId))) + .returning(); + return account; +} + /** Accounts are disabled, never deleted (D18); only active ones count against the quota. */ export async function countServiceAccounts(ctx: DbContext, { tenantId }: InTenantOpts): Promise { const [{ value }] = await ctx.var.db @@ -101,12 +135,22 @@ export async function findApiKeyWithAccount(ctx: DbContext, { key, actorId }: Fi return row; } -export async function findApiKeysByActor(ctx: DbContext, { actorId }: { actorId: string }) { - return ctx.var.db - .select(apiKeySafeColumns) - .from(apiKeysTable) - .where(eq(apiKeysTable.actorId, actorId)) - .orderBy(desc(apiKeysTable.createdAt)); +interface FindApiKeysByActorOpts { + actorId: string; +} + +export async function findApiKeysByActor(ctx: DbContext, { actorId }: FindApiKeysByActorOpts) { + return ctx.var.db.select(apiKeySafeColumns).from(apiKeysTable).where(eq(apiKeysTable.actorId, actorId)).orderBy(desc(apiKeysTable.createdAt)); +} + +interface InsertApiKeyOpts { + /** Prefix, last four and hash of a generated key (`generateApiKey`); the plaintext is never stored. */ + values: InsertApiKeyModel; +} + +export async function insertApiKey(ctx: DbContext, { values }: InsertApiKeyOpts): Promise { + const [apiKey] = await ctx.var.db.insert(apiKeysTable).values(values).returning(apiKeySafeColumns); + return apiKey; } interface ScheduleApiKeyExpiryOpts { diff --git a/backend/src/modules/service-accounts/service-accounts-routes.ts b/backend/src/modules/service-accounts/service-accounts-routes.ts index 20a2f4e79..f1cd95a02 100644 --- a/backend/src/modules/service-accounts/service-accounts-routes.ts +++ b/backend/src/modules/service-accounts/service-accounts-routes.ts @@ -1,7 +1,7 @@ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { orgGuard, stepUpGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; -import { errorResponseRefs, idInTenantOrgParamSchema, paginationSchema, tenantOrgParamSchema } from '#/schemas'; +import { idInTenantOrgParamSchema, paginationSchema, tenantOrgParamSchema } from '#/schemas'; import { mockApiKeyResponse, mockCreatedApiKeyResponse, @@ -21,135 +21,74 @@ import { updateServiceAccountBodySchema, } from './service-accounts-schema'; -/** All routes are user-only: creating and managing machine actors is a human act (D9). */ -export const serviceAccountRoutes = { - createServiceAccount: createXRoute({ - operationId: 'createServiceAccount', +/** + * All routes are user-only: creating and managing machine actors is a human act (D9). Each operation first checks + * that the caller may update the organization. + */ +export const serviceAccountRoutes = createXRoutes(['service-accounts', 'cella'], { + createServiceAccount: xRoute({ method: 'post', path: '/', xGuard: [userGuard, tenantGuard, orgGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['service-accounts', 'cella'], summary: 'Create service account', description: 'Creates a machine actor bound to this organization at the given role (capped at your own), optionally issuing its first API key in the same call.', - request: { - params: tenantOrgParamSchema, - body: { required: true, content: { 'application/json': { schema: createServiceAccountBodySchema } } }, - }, + request: { params: tenantOrgParamSchema, body: jsonBody(createServiceAccountBodySchema) }, responses: { - 201: { - description: 'Service account was created', - content: { - 'application/json': { - schema: createServiceAccountResponseSchema, - example: { serviceAccount: mockServiceAccountResponse(), apiKey: mockCreatedApiKeyResponse() }, - }, - }, - }, - ...errorResponseRefs, + 201: json('Service account was created', createServiceAccountResponseSchema, { + serviceAccount: mockServiceAccountResponse(), + apiKey: mockCreatedApiKeyResponse(), + }), }, }), - getServiceAccounts: createXRoute({ - operationId: 'getServiceAccounts', + getServiceAccounts: xRoute({ method: 'get', path: '/', xGuard: [userGuard, tenantGuard, orgGuard], - tags: ['service-accounts', 'cella'], summary: 'Get service accounts', description: 'Lists the service accounts of this organization.', request: { params: tenantOrgParamSchema, query: serviceAccountListQuerySchema }, - responses: { - 200: { - description: 'Service accounts', - content: { - 'application/json': { - schema: paginationSchema(serviceAccountSchema), - example: mockPaginatedServiceAccountsResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Service accounts', paginationSchema(serviceAccountSchema), mockPaginatedServiceAccountsResponse()) }, }), - updateServiceAccount: createXRoute({ - operationId: 'updateServiceAccount', + updateServiceAccount: xRoute({ method: 'put', path: '/{id}', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [singlePointsLimiter], - tags: ['service-accounts', 'cella'], summary: 'Update service account', description: 'Renames, disables or re-enables a service account. Accounts are never deleted.', - request: { - params: idInTenantOrgParamSchema, - body: { required: true, content: { 'application/json': { schema: updateServiceAccountBodySchema } } }, - }, - responses: { - 200: { - description: 'Service account was updated', - content: { 'application/json': { schema: serviceAccountSchema, example: mockServiceAccountResponse() } }, - }, - ...errorResponseRefs, - }, + request: { params: idInTenantOrgParamSchema, body: jsonBody(updateServiceAccountBodySchema) }, + responses: { 200: json('Service account was updated', serviceAccountSchema, mockServiceAccountResponse()) }, }), - getApiKeys: createXRoute({ - operationId: 'getApiKeys', + getApiKeys: xRoute({ method: 'get', path: '/{id}/keys', xGuard: [userGuard, tenantGuard, orgGuard], - tags: ['service-accounts', 'cella'], summary: 'Get API keys', description: 'Lists the API keys of a service account. Secrets are never returned here.', request: { params: idInTenantOrgParamSchema }, - responses: { - 200: { - description: 'API keys', - content: { - 'application/json': { schema: apiKeysResponseSchema, example: { items: [mockApiKeyResponse()] } }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('API keys', apiKeysResponseSchema, { items: [mockApiKeyResponse()] }) }, }), - createApiKey: createXRoute({ - operationId: 'createApiKey', + createApiKey: xRoute({ method: 'post', path: '/{id}/keys', xGuard: [userGuard, tenantGuard, orgGuard, stepUpGuard], xRateLimiter: [singlePointsLimiter], - tags: ['service-accounts', 'cella'], summary: 'Create API key', description: 'Issues an API key for a service account; the plaintext is returned once. With `rollFrom`, the previous key keeps working for the overlap window.', - request: { - params: idInTenantOrgParamSchema, - body: { required: true, content: { 'application/json': { schema: createApiKeyBodySchema } } }, - }, - responses: { - 201: { - description: 'API key was issued', - content: { 'application/json': { schema: createdApiKeySchema, example: mockCreatedApiKeyResponse() } }, - }, - ...errorResponseRefs, - }, + request: { params: idInTenantOrgParamSchema, body: jsonBody(createApiKeyBodySchema) }, + responses: { 201: json('API key was issued', createdApiKeySchema, mockCreatedApiKeyResponse()) }, }), - revokeApiKey: createXRoute({ - operationId: 'revokeApiKey', + revokeApiKey: xRoute({ method: 'delete', path: '/{id}/keys/{keyId}', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [singlePointsLimiter], - tags: ['service-accounts', 'cella'], summary: 'Revoke API key', description: 'Revokes an API key immediately. The row stays for the audit trail.', request: { params: apiKeyParamSchema }, - responses: { - 200: { - description: 'API key was revoked', - content: { 'application/json': { schema: apiKeySchema, example: mockApiKeyResponse() } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('API key was revoked', apiKeySchema, mockApiKeyResponse()) }, }), -}; +}); diff --git a/backend/src/modules/service-accounts/service-accounts-schema.ts b/backend/src/modules/service-accounts/service-accounts-schema.ts index 0dea56a86..d4096dc0f 100644 --- a/backend/src/modules/service-accounts/service-accounts-schema.ts +++ b/backend/src/modules/service-accounts/service-accounts-schema.ts @@ -17,7 +17,7 @@ const roleBindingSchema = z.object({ channelType: z.enum(appConfig.channelEntityTypes), channelId: validIdSchema, organizationId: validIdSchema, - // fork: a binding carries its channel's role, and raak's channels declare roles the organization does not (`guest`) + // A binding carries its channel's role, and a channel may declare roles the organization does not. role: z.enum(roles.all), }); @@ -26,15 +26,11 @@ export const apiKeyParamSchema = idInTenantOrgParamSchema.extend({ keyId: validI /** `createdBy` / `updatedBy` stay actor ids: the audit-user hydration resolves users only (service badge is a follow-up). */ export const serviceAccountSchema = z - .object({ - ...createSelectSchema(serviceAccountsTable).shape, - status: z.enum(serviceAccountStatuses), - bindings: z.array(roleBindingSchema), - }) + .object({ ...createSelectSchema(serviceAccountsTable).shape, status: z.enum(serviceAccountStatuses), bindings: z.array(roleBindingSchema) }) .openapi('ServiceAccount', { description: 'The actor an API key runs as, with its role bindings.', example: mockServiceAccountResponse(), - 'x-tags': schemaTags('service-accounts', 'cella'), + 'x-tags': schemaTags('data', 'service-accounts', 'cella'), }); export const apiKeySchema = createSelectSchema(apiKeysTable) @@ -42,17 +38,13 @@ export const apiKeySchema = createSelectSchema(apiKeysTable) .openapi('ApiKey', { description: 'An API key of a service account; the secret is never returned after creation.', example: mockApiKeyResponse(), - 'x-tags': schemaTags('service-accounts', 'cella'), + 'x-tags': schemaTags('data', 'service-accounts', 'cella'), }); /** Returned once, at creation or roll: the only time the plaintext key exists outside the caller. */ export const createdApiKeySchema = apiKeySchema .extend({ secret: z.string().describe('The plaintext API key; store it now, it is not shown again.') }) - .openapi('CreatedApiKey', { - description: 'A newly issued API key with its plaintext secret.', - example: mockCreatedApiKeyResponse(), - 'x-tags': schemaTags('service-accounts', 'cella'), - }); + .openapi({ description: 'A newly issued API key with its plaintext secret.', example: mockCreatedApiKeyResponse() }); const apiKeyInputSchema = z.object({ name: validNameSchema, @@ -76,15 +68,9 @@ export const createServiceAccountBodySchema = z.object({ key: apiKeyInputSchema.optional(), }); -export const updateServiceAccountBodySchema = z.object({ - name: validNameSchema.optional(), - status: z.enum(serviceAccountStatuses).optional(), -}); +export const updateServiceAccountBodySchema = z.object({ name: validNameSchema.optional(), status: z.enum(serviceAccountStatuses).optional() }); -export const createServiceAccountResponseSchema = z.object({ - serviceAccount: serviceAccountSchema, - apiKey: createdApiKeySchema.optional(), -}); +export const createServiceAccountResponseSchema = z.object({ serviceAccount: serviceAccountSchema, apiKey: createdApiKeySchema.optional() }); export const apiKeysResponseSchema = z.object({ items: z.array(apiKeySchema) }); diff --git a/backend/src/modules/system/helpers/get-signed-src.ts b/backend/src/modules/system/helpers/get-signed-src.ts index 234048c2c..783200c0c 100644 --- a/backend/src/modules/system/helpers/get-signed-src.ts +++ b/backend/src/modules/system/helpers/get-signed-src.ts @@ -12,10 +12,7 @@ export const replaceSignedSrcs = async (content: string): Promise => { await Promise.all( Array.from(new Set(srcs)).map(async (src) => { try { - const signed = await getSignedUrlFromKey(src, { - publicBucket: true, - bucketName: appConfig.s3.publicBucket, - }); + const signed = await getSignedUrlFromKey(src, { publicBucket: true, bucketName: appConfig.s3.publicBucket }); replacements.set(src, signed); } catch { // fallback to original if signing fails diff --git a/backend/src/modules/system/operations/create-invite.ts b/backend/src/modules/system/operations/create-invite.ts index bb85e16f6..cf5844d72 100644 --- a/backend/src/modules/system/operations/create-invite.ts +++ b/backend/src/modules/system/operations/create-invite.ts @@ -67,10 +67,8 @@ export async function createInviteOp(ctx: UserContext, emails: string[]) { // No account holds these addresses (a verified one is rejected above) and no organization is involved: the mail // goes out in the app's language. - await sendInvitationMails(ctx, { - sender: user, - invited: issued.map(({ token, rawToken }) => ({ email: token.email, rawToken })), - }); + const invited = issued.map(({ token, rawToken }) => ({ email: token.email, rawToken })); + await sendInvitationMails(ctx, { sender: user, invited }); log.info('Users invited on system level', { count: issued.length }); diff --git a/backend/src/modules/system/operations/update-user.ts b/backend/src/modules/system/operations/update-user.ts index 3d1fb70c8..e6d190208 100644 --- a/backend/src/modules/system/operations/update-user.ts +++ b/backend/src/modules/system/operations/update-user.ts @@ -37,11 +37,8 @@ export async function updateUserOp(ctx: UserContext, id: string, input: UpdateUs updatedAt: getIsoDate(), updatedBy: user.id, }; - const updatedUser = await ctx.var.db.transaction(async (tx) => { - const updated = await updateUser({ var: { db: tx } }, { id: targetUser.id, values }); - await invalidateCache.user(tx, updated.id); - return updated; - }); + const updatedUser = await updateUser(ctx, { id: targetUser.id, values }); + invalidateCache.user(updatedUser.id); log.info('User updated', { userId: updatedUser.id }); // Re-select to include the user_counters subqueries diff --git a/backend/src/modules/system/system-listeners.ts b/backend/src/modules/system/system-listeners.ts index 80a853f85..db188163f 100644 --- a/backend/src/modules/system/system-listeners.ts +++ b/backend/src/modules/system/system-listeners.ts @@ -1,36 +1,28 @@ -import { appConfig } from 'shared'; import { baseDb } from '#/db/db'; import { type ActivityEvent, activityBus, getEventData } from '#/lib/activity-bus'; -import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { sendSecurityInboxEmail } from '#/modules/auth/general/helpers/send-account-security-email'; import { findUserById } from '#/modules/user/user-queries'; +import { utcStamp } from '#/utils/iso-date'; import { log } from '#/utils/logger'; -const securityEmailType = { - create: 'system-role-granted', - update: 'system-role-changed', - delete: 'system-role-revoked', -} as const; +const securityEmailType = { create: 'system-role-granted', update: 'system-role-changed', delete: 'system-role-revoked' } as const; /** - * Every CDC-observed system-role change drops the user's cached sessions in every process, so the role (and the - * impersonations it backs) holds only while it is granted, and notifies the security contact. + * Every CDC-observed system-role change notifies the security contact. The session listeners drop the user's cached + * sessions on the same event, so the role (and the impersonations it backs) holds only while granted. */ const notifySystemRoleChange = async (event: ActivityEvent) => { const systemRole = getEventData(event, 'system_role'); if (!systemRole) return; try { - // The role row is committed by the time CDC reports it: the message goes out on the pool. - await invalidateCache.user(baseDb, systemRole.userId); - // On delete the user may already be cascade-deleted; fall back to the raw id const user = await findUserById({ var: { db: baseDb } }, { id: systemRole.userId }); - sendAccountSecurityEmail({ email: appConfig.securityEmail, name: 'Security' }, securityEmailType[event.action], { + sendSecurityInboxEmail(securityEmailType[event.action], { role: systemRole.role, userEmail: user?.email ?? systemRole.userId, - timestamp: `${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`, + timestamp: utcStamp(), }); } catch (error) { log.error('Failed to handle a system role change', { error, activityId: event.id }); diff --git a/backend/src/modules/system/system-mocks.ts b/backend/src/modules/system/system-mocks.ts index cd1ec9001..b68a1c0de 100644 --- a/backend/src/modules/system/system-mocks.ts +++ b/backend/src/modules/system/system-mocks.ts @@ -2,27 +2,13 @@ import { mockPastIsoDate, mockUuid, withFakerSeed } from '#/mocks'; import type { SystemRoleModel } from '#/modules/system/system-roles-db'; export const mockSystemRoleBase = (key = 'system-role:base') => - withFakerSeed(key, () => ({ - id: mockUuid(), - userId: mockUuid(), - role: 'admin' as const, - })); + withFakerSeed(key, () => ({ id: mockUuid(), userId: mockUuid(), role: 'admin' as const })); export const mockSystemRoleResponse = (key = 'system-role:default'): SystemRoleModel => withFakerSeed(key, () => { const createdAt = mockPastIsoDate(); - return { - id: mockUuid(), - userId: mockUuid(), - role: 'admin' as const, - createdAt, - updatedAt: createdAt, - }; + return { id: mockUuid(), userId: mockUuid(), role: 'admin' as const, createdAt, updatedAt: createdAt }; }); -export const mockSystemInviteResponse = () => ({ - data: [] as never[], - rejectedIds: [] as string[], - invitesSentCount: 2, -}); +export const mockSystemInviteResponse = () => ({ data: [] as never[], rejectedIds: [] as string[], invitesSentCount: 2 }); diff --git a/backend/src/modules/system/system-queries.ts b/backend/src/modules/system/system-queries.ts index a4549ce89..5ac99d55c 100644 --- a/backend/src/modules/system/system-queries.ts +++ b/backend/src/modules/system/system-queries.ts @@ -48,18 +48,10 @@ interface FindNewsletterRecipientsOpts { roles: EntityRole[]; } -export const findNewsletterRecipients = async ( - ctx: DbContext, - { organizationIds, roles }: FindNewsletterRecipientsOpts, -) => { +export const findNewsletterRecipients = async (ctx: DbContext, { organizationIds, roles }: FindNewsletterRecipientsOpts) => { const { db } = ctx.var; return db - .selectDistinct({ - userId: usersTable.id, - email: usersTable.email, - name: usersTable.name, - orgName: organizationsTable.name, - }) + .selectDistinct({ userId: usersTable.id, email: usersTable.email, name: usersTable.name, orgName: organizationsTable.name }) .from(membershipsTable) .innerJoin(usersTable, eq(usersTable.id, membershipsTable.userId)) .innerJoin(organizationsTable, eq(organizationsTable.id, membershipsTable.organizationId)) diff --git a/backend/src/modules/system/system-routes.ts b/backend/src/modules/system/system-routes.ts index bda2d964c..73bc3784a 100644 --- a/backend/src/modules/system/system-routes.ts +++ b/backend/src/modules/system/system-routes.ts @@ -1,120 +1,58 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { sysAdminGuard, userGuard } from '#/middlewares/guard'; import { bulkPointsLimiter, singlePointsLimiter, spamLimiter } from '#/middlewares/rate-limiter/limiters'; import { inviteBodySchema, sendNewsletterBodySchema } from '#/modules/system/system-schema'; import { mockUserResponse } from '#/modules/user/user-mocks'; -import { - batchResponseSchema, - booleanTransformSchema, - entityIdParamSchema, - errorResponseRefs, - idsBodySchema, -} from '#/schemas'; +import { batchResponseSchema, booleanTransformSchema, entityIdParamSchema, idsBodySchema } from '#/schemas'; import { userSchema, userUpdateBodySchema } from '../user/user-schema'; import { mockSystemInviteResponse } from './system-mocks'; -const systemRoutes = { - createInvite: createXRoute({ +const systemInviteResponseSchema = batchResponseSchema().extend({ invitesSentCount: z.number() }); + +const systemRoutes = createXRoutes(['system', 'cella'], { + createInvite: xRoute({ operationId: 'systemInvite', method: 'post', path: '/invite', xGuard: [userGuard, sysAdminGuard], xRateLimiter: [spamLimiter, bulkPointsLimiter], - tags: ['system', 'cella'], summary: 'Invite to system', - description: - 'Invites one or more users to the system via email. Can be used to onboard system level users or admins.', - request: { - body: { - required: true, - content: { 'application/json': { schema: inviteBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Invitations are sent', - content: { - 'application/json': { - schema: batchResponseSchema().extend({ invitesSentCount: z.number() }), - example: mockSystemInviteResponse(), - }, - }, - }, - ...errorResponseRefs, - }, + description: 'Invites one or more users to the system via email. Can be used to onboard system level users or admins.', + request: { body: jsonBody(inviteBodySchema) }, + responses: { 200: json('Invitations are sent', systemInviteResponseSchema, mockSystemInviteResponse()) }, }), - deleteUsers: createXRoute({ - operationId: 'deleteUsers', + deleteUsers: xRoute({ method: 'delete', path: '/', xGuard: [userGuard, sysAdminGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['system', 'cella'], summary: 'Delete users', description: "Deletes one or more users from the system based on a list of IDs. This also removes the user's memberships (cascade) and sets references to the user to null where applicable.", - request: { - body: { - required: true, - content: { 'application/json': { schema: idsBodySchema() } }, - }, - }, - responses: { - 200: { - description: 'Success', - content: { 'application/json': { schema: batchResponseSchema() } }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(idsBodySchema()) }, + responses: { 200: json('Success', batchResponseSchema()) }, }), - updateUser: createXRoute({ - operationId: 'updateUser', + updateUser: xRoute({ method: 'put', path: '/{id}', xGuard: [userGuard, sysAdminGuard], xRateLimiter: [singlePointsLimiter], - tags: ['system', 'cella'], summary: 'Update user', description: 'Updates a user identified by ID.', - request: { - params: entityIdParamSchema, - body: { - required: true, - content: { 'application/json': { schema: userUpdateBodySchema } }, - }, - }, - responses: { - 200: { - description: 'User', - content: { 'application/json': { schema: userSchema, example: mockUserResponse() } }, - }, - ...errorResponseRefs, - }, + request: { params: entityIdParamSchema, body: jsonBody(userUpdateBodySchema) }, + responses: { 200: json('User', userSchema, mockUserResponse()) }, }), - sendNewsletter: createXRoute({ - operationId: 'sendNewsletter', + sendNewsletter: xRoute({ method: 'post', path: '/newsletter', xGuard: [userGuard, sysAdminGuard], xRateLimiter: [singlePointsLimiter], - tags: ['system', 'cella'], summary: 'Newsletter to members', description: 'Sends a newsletter to members of one or more specified organizations.', - request: { - query: z.object({ toSelf: booleanTransformSchema }), - body: { - required: true, - content: { 'application/json': { schema: sendNewsletterBodySchema } }, - }, - }, - responses: { - 204: { - description: 'Newsletter sent', - }, - ...errorResponseRefs, - }, + request: { query: z.object({ toSelf: booleanTransformSchema }), body: jsonBody(sendNewsletterBodySchema) }, + responses: { 204: { description: 'Newsletter sent' } }, }), -}; +}); export { systemRoutes }; diff --git a/backend/src/modules/system/system-schema.ts b/backend/src/modules/system/system-schema.ts index cf71c3047..f1e0b6b36 100644 --- a/backend/src/modules/system/system-schema.ts +++ b/backend/src/modules/system/system-schema.ts @@ -6,9 +6,7 @@ import { systemRolesTable } from '#/modules/system/system-roles-db'; import { maxLength, validEmailSchema, validUuidSchema } from '#/schemas'; import { mockSystemRoleBase, mockSystemRoleResponse } from './system-mocks'; -export const inviteBodySchema = z.object({ - emails: validEmailSchema.array().min(1).max(50), -}); +export const inviteBodySchema = z.object({ emails: validEmailSchema.array().min(1).max(50) }); export const sendNewsletterBodySchema = z.object({ // An empty scope is allowed for the explicit toSelf preview mode. @@ -33,13 +31,8 @@ export const systemRoleSchema = z.object(systemRoleSelectSchema.shape).openapi(' 'x-tags': schemaTags('data', 'system', 'cella'), }); -export const systemRoleBaseSchema = systemRoleSelectSchema - .omit({ - createdAt: true, - updatedAt: true, - }) - .openapi('SystemRoleBase', { - description: 'Core fields for a system role assignment.', - example: mockSystemRoleBase(), - 'x-tags': schemaTags('base', 'system', 'cella'), - }); +export const systemRoleBaseSchema = systemRoleSelectSchema.omit({ createdAt: true, updatedAt: true }).openapi('SystemRoleBase', { + description: 'Core fields for a system role assignment.', + example: mockSystemRoleBase(), + 'x-tags': schemaTags('base', 'system', 'cella'), +}); diff --git a/backend/src/modules/task/helpers/block-operations/form-block.ts b/backend/src/modules/task/helpers/block-operations/form-block.ts index 32983da7b..011b5cf77 100644 --- a/backend/src/modules/task/helpers/block-operations/form-block.ts +++ b/backend/src/modules/task/helpers/block-operations/form-block.ts @@ -35,11 +35,7 @@ export const formFileBlocks = async (attachments: (typeof attachmentsTable.$infe return result.flat() as Block[]; }; -const typeMap: Record = { - audio: 'audio', - image: 'image', - video: 'video', -}; +const typeMap: Record = { audio: 'audio', image: 'image', video: 'video' }; const formFileBlock = (id: string, type: 'file' | 'image' | 'video' | 'audio', name: string, url: string) => ({ id, @@ -52,10 +48,7 @@ const formFileBlock = (id: string, type: 'file' | 'image' | 'video' | 'audio', n attachmentId: id, caption: '', ...(type !== 'file' && { showPreview: true }), - ...((type === 'video' || type === 'image') && { - textAlignment: 'left', - previewWidth: 512, - }), + ...((type === 'video' || type === 'image') && { textAlignment: 'left', previewWidth: 512 }), }, children: [], }); diff --git a/backend/src/modules/task/helpers/canvas.ts b/backend/src/modules/task/helpers/canvas.ts index ab1224399..cb7894d9d 100644 --- a/backend/src/modules/task/helpers/canvas.ts +++ b/backend/src/modules/task/helpers/canvas.ts @@ -2,21 +2,11 @@ import { type CanvasRenderingContext2D, createCanvas, loadImage } from '@napi-rs const fontFamily = 'Lucida Sans Unicode'; -const fontSizes = { - heading: 80, - author: 40, -}; - -const fontStyles = { - heading: `900 ${fontSizes.heading}px ${fontFamily}`, - author: `700 ${fontSizes.author}px ${fontFamily}`, -}; - -const colors = { - primary: '#ffd166', - secondary: 'white', - base: '#560bad', -}; +const fontSizes = { heading: 80, author: 40 }; + +const fontStyles = { heading: `900 ${fontSizes.heading}px ${fontFamily}`, author: `700 ${fontSizes.author}px ${fontFamily}` }; + +const colors = { primary: '#ffd166', secondary: 'white', base: '#560bad' }; const avatarSize = 80; const avatarBorder = 5; @@ -28,14 +18,7 @@ const space = 40; const canvas = createCanvas(1200, 630); const ctx = canvas.getContext('2d'); -function wrapText( - context: CanvasRenderingContext2D, - text: string, - x: number, - y: number, - maxWidth: number, - lineHeight: number, -) { +function wrapText(context: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number) { const words = text.split(' '); let line = ''; diff --git a/backend/src/modules/task/helpers/description.ts b/backend/src/modules/task/helpers/description.ts index 37ea09d8c..da25a1956 100644 --- a/backend/src/modules/task/helpers/description.ts +++ b/backend/src/modules/task/helpers/description.ts @@ -35,16 +35,11 @@ export type DerivedDescriptionProps = { * extraction and HTML summary conversion are backend-only. Collected attachment ids * are narrowed to UUID shape so they can safely enter the uuid[] host column. */ -export const deriveDescriptionProps = async ( - description: string, - preParsed?: ParsedBlock[], -): Promise => { +export const deriveDescriptionProps = async (description: string, preParsed?: ParsedBlock[]): Promise => { const blocks: ParsedBlock[] = preParsed ?? (description ? JSON.parse(description) : []); // attachmentCount is a client-side presentation stat (attachments.length); the row persists none. - const { attachmentCount: _attachmentCount, ...counts } = blocks.length - ? countDescriptionBlocks(blocks) - : emptyDescriptionCounts(); + const { attachmentCount: _attachmentCount, ...counts } = blocks.length ? countDescriptionBlocks(blocks) : emptyDescriptionCounts(); counts.attachments = counts.attachments.filter((id) => validUuidSchema.safeParse(id).success); const result: DerivedDescriptionProps = { summary: '', summaryLength: 0, keywords: '', ...counts }; diff --git a/backend/src/modules/task/helpers/get-tasks.ts b/backend/src/modules/task/helpers/get-tasks.ts index cc6b99c4c..5ed380687 100644 --- a/backend/src/modules/task/helpers/get-tasks.ts +++ b/backend/src/modules/task/helpers/get-tasks.ts @@ -20,22 +20,14 @@ type QueryInfo = z.infer; /** * Get list of tasks for a project, with filtering, sorting, and pagination. */ -export const getTasks = async ( - ctx: ActorContext, - projectIds: string[], - queryInfo: QueryInfo, - opts?: { publicOnly?: boolean }, -) => { +export const getTasks = async (ctx: ActorContext, projectIds: string[], queryInfo: QueryInfo, opts?: { publicOnly?: boolean }) => { const { q, sort, order, acceptedCutOff, matchMode, limit, offset, seqCursor } = queryInfo; // Highlight-mode clients ('=' prefix) fetch unfiltered; stripping here keeps stray marked // queries behaving like their plain form. const { effectiveQ: trimmedQuery } = parseSearchQuery(q); // Get users and labels data in parallel - const [tasksUsers, tasksLabels] = await Promise.all([ - findProjectMembers(ctx, { projectIds }), - findLabelsByProjects(ctx, { projectIds }), - ]); + const [tasksUsers, tasksLabels] = await Promise.all([findProjectMembers(ctx, { projectIds }), findLabelsByProjects(ctx, { projectIds })]); const tasksSearchFilters: SQL[] = []; @@ -92,13 +84,7 @@ export const getTasks = async ( ); // Non-delta reads fetch the page and its exact COUNT(*) in parallel; delta reads skip the count. - const { items: tasks, total } = await findTasksPaginated(ctx, { - filters, - orderBy, - limit, - offset, - isDelta: !!seqCursor, - }); + const { items: tasks, total } = await findTasksPaginated(ctx, { filters, orderBy, limit, offset, isDelta: !!seqCursor }); const items = hydrateTasks(tasks, tasksUsers, tasksLabels); diff --git a/backend/src/modules/task/helpers/hydrate-task.ts b/backend/src/modules/task/helpers/hydrate-task.ts index ab281972f..4a3f922a1 100644 --- a/backend/src/modules/task/helpers/hydrate-task.ts +++ b/backend/src/modules/task/helpers/hydrate-task.ts @@ -15,17 +15,13 @@ type Labels = z.infer; type ReturnTask = z.infer; /** Map task DB models to hydrated task responses with user and label data. */ -const mapTask = ( - task: TaskModel, - userMap: Map, - labelMap: Map, -): ReturnTask => { +const mapTask = (task: TaskModel, userMap: Map, labelMap: Map): ReturnTask => { const taskLabels = task.labels as string[]; const taskAssignedTo = [...new Set(task.assignedTo as string[])]; const labels = taskLabels.map((id) => labelMap.get(id)).filter(Boolean) as Labels[]; - const assignedTo = (taskAssignedTo.map((id) => userMap.get(id)).filter(Boolean) as UserMinimalBaseSchemas[]).sort( - (a, b) => a.name.localeCompare(b.name, appConfig.defaultLanguage), + const assignedTo = (taskAssignedTo.map((id) => userMap.get(id)).filter(Boolean) as UserMinimalBaseSchemas[]).sort((a, b) => + a.name.localeCompare(b.name, appConfig.defaultLanguage), ); return { @@ -53,9 +49,7 @@ export const hydrateTask = (task: TaskModel, members: UserMinimalBaseSchemas[], /** Fetch users and labels referenced by one or more tasks. */ export const getTaskRelations = async (ctx: ActorContext, { tasks }: { tasks: TaskModel[] }) => { - const userIds = Array.from( - new Set(tasks.flatMap((t) => [t.createdBy, t.updatedBy, ...t.assignedTo].filter((u) => u !== null))), - ); + const userIds = Array.from(new Set(tasks.flatMap((t) => [t.createdBy, t.updatedBy, ...t.assignedTo].filter((u) => u !== null)))); const labelIds = Array.from(new Set(tasks.flatMap((t) => [...t.labels, t.primaryLabelId]))); return findTaskRelations(ctx, { userIds, labelIds }); }; diff --git a/backend/src/modules/task/operations/create-tasks.ts b/backend/src/modules/task/operations/create-tasks.ts index 76f4357d6..c03ca0457 100644 --- a/backend/src/modules/task/operations/create-tasks.ts +++ b/backend/src/modules/task/operations/create-tasks.ts @@ -20,10 +20,7 @@ import { log } from '#/utils/logger'; type CreateTasksInput = z.infer; type ReturnTask = Awaited>[number]; -export async function createTasksOp( - ctx: OrgContext, - rawInput: CreateTasksInput, -): Promise<{ data: ReturnTask[]; rejectedIds: string[] }> { +export async function createTasksOp(ctx: OrgContext, rawInput: CreateTasksInput): Promise<{ data: ReturnTask[]; rejectedIds: string[] }> { // Lens seam: canonicalize old-shape field names before any body access const input = rawInput.map((item) => taskContract.normalizeCreateItem(item)); const organization = ctx.var.organization; @@ -42,10 +39,7 @@ export async function createTasksOp( } // Check restriction limits. Concurrent requests may slightly overshoot. - const currentTasksCount = await getOrganizationEntityCount(ctx, { - organizationId: organization.id, - entityType: 'task', - }); + const currentTasksCount = await getOrganizationEntityCount(ctx, { organizationId: organization.id, entityType: 'task' }); if (taskRestrictions !== 0 && currentTasksCount + input.length > taskRestrictions) { throw new AppError(429, 'restrict_by_org', 'warn', { entityType: 'task' }); @@ -73,10 +67,7 @@ export async function createTasksOp( ? (taskInfo.primaryLabelId as string) : projectPrimaries[0]?.id; if (!primaryLabelId) { - throw new AppError(400, 'invalid_request', 'warn', { - entityType: 'task', - meta: { reason: 'Project has no primary labels' }, - }); + throw new AppError(400, 'invalid_request', 'warn', { entityType: 'task', meta: { reason: 'Project has no primary labels' } }); } const task = { diff --git a/backend/src/modules/task/operations/get-tasks.ts b/backend/src/modules/task/operations/get-tasks.ts index 346b9d530..834ff307f 100644 --- a/backend/src/modules/task/operations/get-tasks.ts +++ b/backend/src/modules/task/operations/get-tasks.ts @@ -29,13 +29,7 @@ export async function getTasksOp(ctx: OrgContext, input: GetTasksInput): Promise } // Scope to the caller's readable projects; undefined means org-wide (all readable projects). - const { homeChannelIds: projectIds } = resolveCollectionReadFilter( - ctx.var.actor.bindings, - 'task', - organizationId, - actorFrom(ctx), - requested, - ); + const { homeChannelIds: projectIds } = resolveCollectionReadFilter(ctx.var.actor.bindings, 'task', organizationId, actorFrom(ctx), requested); // Tasks always require an explicit project scope (no org-wide aggregate read). if (!projectIds || projectIds.length === 0) { diff --git a/backend/src/modules/task/operations/update-task.ts b/backend/src/modules/task/operations/update-task.ts index 1a0c3a000..9af49728d 100644 --- a/backend/src/modules/task/operations/update-task.ts +++ b/backend/src/modules/task/operations/update-task.ts @@ -4,11 +4,7 @@ import { AppError } from '#/core/error'; import { tenantContext } from '#/db/tenant-context'; import { dispatchMutation } from '#/lib/mutation-bus'; import { findLabelSlugById, findLivePrimaryLabels } from '#/modules/label/helpers/primary-labels'; -import { - type DerivedDescriptionProps, - deriveDescriptionProps, - type ParsedBlock, -} from '#/modules/task/helpers/description'; +import { type DerivedDescriptionProps, deriveDescriptionProps, type ParsedBlock } from '#/modules/task/helpers/description'; import { getTaskRelations, hydrateTask, hydrateTaskLite } from '#/modules/task/helpers/hydrate-task'; import type { InsertTaskModel } from '#/modules/task/task-db'; import { filterExistingAttachmentIds, findProjectMemberUserIds, updateTask } from '#/modules/task/task-queries'; @@ -25,7 +21,7 @@ type UpdateTaskContext = { var: ActorContext['var'] & Partial = { - ...resolved.values, - updatedAt: getIsoDate(), - updatedBy: ctx.var.actor.id, - stx: resolved.stx, - }; + const updateValues: Partial = { ...resolved.values, updatedAt: getIsoDate(), updatedBy: ctx.var.actor.id, stx: resolved.stx }; if (resolved.values.status !== undefined && resolved.values.status !== entity.status) { updateValues.statusChangedAt = getIsoDate(); @@ -87,10 +76,7 @@ export async function updateTaskOp( if ('projectId' in resolved.values && resolved.values.projectId !== entity.projectId) { const newProjectId = resolved.values.projectId as string; const userIdsToCheck = (entity.assignedTo as string[]).filter(Boolean); - const projectMembers = await findProjectMemberUserIds(txCtx, { - projectId: newProjectId, - userIds: userIdsToCheck, - }); + const projectMembers = await findProjectMemberUserIds(txCtx, { projectId: newProjectId, userIds: userIdsToCheck }); const memberSet = new Set(projectMembers.map(({ userId }) => userId)); // Remove assignees not in the target project @@ -105,15 +91,9 @@ export async function updateTaskOp( const targetPrimaries = await findLivePrimaryLabels(txCtx, { projectIds: [newProjectId] }); const requestedId = resolved.values.primaryLabelId as string | undefined; const currentSlug = await findLabelSlugById(txCtx, entity.primaryLabelId); - const target = - targetPrimaries.find((l) => l.id === requestedId) ?? - targetPrimaries.find((l) => l.slug === currentSlug) ?? - targetPrimaries[0]; + const target = targetPrimaries.find((l) => l.id === requestedId) ?? targetPrimaries.find((l) => l.slug === currentSlug) ?? targetPrimaries[0]; if (!target) { - throw new AppError(400, 'invalid_request', 'warn', { - entityType: 'task', - meta: { reason: 'Target project has no primary labels' }, - }); + throw new AppError(400, 'invalid_request', 'warn', { entityType: 'task', meta: { reason: 'Target project has no primary labels' } }); } updateValues.primaryLabelId = target.id; } else if ('primaryLabelId' in resolved.values) { @@ -130,9 +110,7 @@ export async function updateTaskOp( if (resolved.values.description !== undefined && derivedDescription) { // Drop ids that don't resolve to a live in-org attachment row (doctored or stale // block props must never enter the owned-embedding host array). - derivedDescription.attachments = await filterExistingAttachmentIds(txCtx, { - ids: derivedDescription.attachments, - }); + derivedDescription.attachments = await filterExistingAttachmentIds(txCtx, { ids: derivedDescription.attachments }); Object.assign(updateValues, derivedDescription); } diff --git a/backend/src/modules/task/public-handlers.ts b/backend/src/modules/task/public-handlers.ts index 27017a05e..bd7b72d6b 100644 --- a/backend/src/modules/task/public-handlers.ts +++ b/backend/src/modules/task/public-handlers.ts @@ -18,10 +18,7 @@ app.openapi(publicTaskRoutes.getPublicTask, async (ctx) => { // Validate request if (!id) throw new AppError(404, 'not_found', 'warn'); - const mainTask = await resolveEntity( - { var: { db: getAdminDb('public task reads') } }, - { entityType: 'task', identifier: id }, - ); + const mainTask = await resolveEntity({ var: { db: getAdminDb('public task reads') } }, { entityType: 'task', identifier: id }); if (!mainTask) throw new AppError(404, 'not_found', 'warn', { entityType: 'task' }); // Drafts are never publicly readable: they read as absent to non-authors (the anonymous caller). @@ -37,11 +34,7 @@ app.openapi(publicTaskRoutes.getPublicTask, async (ctx) => { // Relation reads are request-scoped, so carry the task's own tenant and organization. const publicCtx = { - var: { - db: getAdminDb('public task reads'), - tenantId: mainTask.tenantId, - organizationId: mainTask.organizationId, - }, + var: { db: getAdminDb('public task reads'), tenantId: mainTask.tenantId, organizationId: mainTask.organizationId }, } as ActorContext; const [users, labels] = await getTaskRelations(publicCtx, { tasks: [mainTask] }); @@ -55,18 +48,11 @@ app.openapi(publicTaskRoutes.getPublicTasks, async (ctx) => { // Public reads intentionally bypass tenant status checks from tenantGuard. Resolve the project // for org scoping only; the project's own publicAt does not gate the list. - const project = await resolveEntity( - { var: { db: getAdminDb('public task reads') } }, - { entityType: 'project', identifier: projectId }, - ); + const project = await resolveEntity({ var: { db: getAdminDb('public task reads') } }, { entityType: 'project', identifier: projectId }); if (!project) throw new AppError(404, 'not_found', 'warn', { entityType: 'project' }); const publicCtx = { - var: { - db: getAdminDb('public task reads'), - tenantId: project.tenantId, - organizationId: project.organizationId, - }, + var: { db: getAdminDb('public task reads'), tenantId: project.tenantId, organizationId: project.organizationId }, } as ActorContext; const response = await getTasks(publicCtx, [project.id], queryInfo, { publicOnly: true }); return ctx.json(response, 200); diff --git a/backend/src/modules/task/public-routes.ts b/backend/src/modules/task/public-routes.ts index 4b5d508ac..08108b9c4 100644 --- a/backend/src/modules/task/public-routes.ts +++ b/backend/src/modules/task/public-routes.ts @@ -32,18 +32,11 @@ const publicTaskRoutes = { operationId: 'getPublicTasks', summary: 'Get public tasks', description: 'Returns a list of public tasks associated with a specific project. For publicly shared boards.', - request: { - query: taskListQueryBaseSchema.omit({ workspaceId: true }).extend({ projectId: z.string().max(maxLength.id) }), - }, + request: { query: taskListQueryBaseSchema.omit({ workspaceId: true }).extend({ projectId: z.string().max(maxLength.id) }) }, responses: { 200: { description: 'Tasks', - content: { - 'application/json': { - schema: paginationSchema(taskSchema), - example: mockTasksResponse(), - }, - }, + content: { 'application/json': { schema: paginationSchema(taskSchema), example: mockTasksResponse() } }, }, ...errorResponseRefs, }, diff --git a/backend/src/modules/task/redirect-handlers.ts b/backend/src/modules/task/redirect-handlers.ts index 8681bde2a..698efd058 100644 --- a/backend/src/modules/task/redirect-handlers.ts +++ b/backend/src/modules/task/redirect-handlers.ts @@ -27,11 +27,7 @@ const app = new OpenAPIHono({ defaultHook }); /** Display name of a task's primary label (task type); falls back to 'Task'. */ const getTaskType = async (primaryLabelId: string) => { - const [label] = await db() - .select({ name: labelsTable.name }) - .from(labelsTable) - .where(eq(labelsTable.id, primaryLabelId)) - .limit(1); + const [label] = await db().select({ name: labelsTable.name }).from(labelsTable).where(eq(labelsTable.id, primaryLabelId)).limit(1); return label?.name ?? 'Task'; }; @@ -99,18 +95,10 @@ app.openapi(taskRedirectRoutes.getTaskCover, async (ctx) => { .where(eq(usersTable.id, task.createdBy as string)); } - const png = await generateCover({ - title: task.summary, - avatarUrl: createdByUser?.thumbnailUrl || '', - name: createdByUser?.name || '', - }); + const png = await generateCover({ title: task.summary, avatarUrl: createdByUser?.thumbnailUrl || '', name: createdByUser?.name || '' }); return new Response(Buffer.from(png), { - headers: { - 'Content-Type': 'image/png', - 'Content-Length': String(png.byteLength), - 'Cache-Control': 'public, max-age=3600, immutable', - }, + headers: { 'Content-Type': 'image/png', 'Content-Length': String(png.byteLength), 'Cache-Control': 'public, max-age=3600, immutable' }, }); }); @@ -123,11 +111,7 @@ app.openapi(taskRedirectRoutes.redirectToTask, async (ctx) => { .leftJoin(usersTable, eq(usersTable.id, tasksTable.createdBy)) .where(and(eq(tasksTable.id, id), isNull(tasksTable.deletedAt))) .limit(1); - if (!taskRecord) - throw new AppError(404, 'not_found', 'warn', { - entityType: 'task', - willRedirect: true, - }); + if (!taskRecord) throw new AppError(404, 'not_found', 'warn', { entityType: 'task', willRedirect: true }); const { task, createdBy } = taskRecord; // Find a project to show the task in. @@ -138,11 +122,7 @@ app.openapi(taskRedirectRoutes.redirectToTask, async (ctx) => { .limit(1); // No matching project found - if (!project) - throw new AppError(404, 'not_found', 'warn', { - entityType: 'project', - willRedirect: true, - }); + if (!project) throw new AppError(404, 'not_found', 'warn', { entityType: 'project', willRedirect: true }); const url = new URL(`${appConfig.frontendUrl}/t/${id}`); const redirectUrl = url.toString(); @@ -154,11 +134,7 @@ app.openapi(taskRedirectRoutes.redirectToTask, async (ctx) => { const now = new Date(); const sameYear = createdAtDate.getFullYear() === now.getFullYear(); - const formattedDate = createdAtDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - ...(sameYear ? {} : { year: 'numeric' }), - }); + const formattedDate = createdAtDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) }); const taskType = await getTaskType(task.primaryLabelId); const taskTitle = `${taskType} in ${project.name || 'Project'} - ${formattedDate}${createdBy ? ` by ${createdBy.name}` : ''}`; diff --git a/backend/src/modules/task/redirect-routes.ts b/backend/src/modules/task/redirect-routes.ts index f85956b88..fd42d669f 100644 --- a/backend/src/modules/task/redirect-routes.ts +++ b/backend/src/modules/task/redirect-routes.ts @@ -52,15 +52,8 @@ const taskRedirectRoutes = { operationId: 'getTaskCover', summary: 'Get task cover', description: 'Retrieves the cover image for a task by ID.', - request: { - params: z.object({ - id: validIdSchema, - }), - }, - responses: { - 200: { description: 'Success' }, - ...errorResponseRefs, - }, + request: { params: z.object({ id: validIdSchema }) }, + responses: { 200: { description: 'Success' }, ...errorResponseRefs }, }), }; diff --git a/backend/src/modules/task/task-db.ts b/backend/src/modules/task/task-db.ts index c63ad98bf..72c849461 100644 --- a/backend/src/modules/task/task-db.ts +++ b/backend/src/modules/task/task-db.ts @@ -1,20 +1,9 @@ import { sql } from 'drizzle-orm'; -import { - boolean, - doublePrecision, - foreignKey, - index, - integer, - snakeCase, - text, - timestamp, - uuid, - varchar, -} from 'drizzle-orm/pg-core'; +import { boolean, doublePrecision, foreignKey, index, integer, snakeCase, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; import { tenantSelectPolicy, writeThroughPolicies } from '#/db/rls-helpers'; import { channelRelationColumns, channelRelationIndexes } from '#/db/utils/channel-relation-columns'; import { maxLength } from '#/db/utils/constraints'; -import { mentionableColumns, productColumns } from '#/db/utils/product-columns'; +import { productColumns } from '#/db/utils/product-columns'; import { organizationsTable } from '#/modules/organization/organization-db'; /** @@ -36,7 +25,6 @@ export const tasksTable = snakeCase.table( statusChangedAt: timestamp({ mode: 'string' }).defaultNow().notNull(), labels: text().array().notNull().default(sql`'{}'::text[]`), assignedTo: text().array().notNull().default(sql`'{}'::text[]`), - ...mentionableColumns, checkboxCount: integer().default(0).notNull(), checkedCount: integer().default(0).notNull(), // Derived from description media blocks (attachmentId props). Owned-lifecycle @@ -58,10 +46,9 @@ export const tasksTable = snakeCase.table( index('idx_tasks_assigned_to_gin').using('gin', table.assignedTo), // Backs the CDC refcount check: "which live tasks still reference attachment X?" index('idx_tasks_attachments_gin').using('gin', table.attachments), - foreignKey({ - columns: [table.tenantId, table.organizationId], - foreignColumns: [organizationsTable.tenantId, organizationsTable.id], - }).onDelete('cascade'), + foreignKey({ columns: [table.tenantId, table.organizationId], foreignColumns: [organizationsTable.tenantId, organizationsTable.id] }).onDelete( + 'cascade', + ), tenantSelectPolicy('tasks', table), ...writeThroughPolicies('tasks'), ], diff --git a/backend/src/modules/task/task-mocks.ts b/backend/src/modules/task/task-mocks.ts index c8fa69a17..68c8b8068 100644 --- a/backend/src/modules/task/task-mocks.ts +++ b/backend/src/modules/task/task-mocks.ts @@ -29,10 +29,8 @@ export const mockTask = (key = 'task:default'): TaskModel => const summary = faker.lorem.sentence({ min: 5, max: 15 }); const checkboxCount = faker.number.int({ min: 0, max: 10 }); const publicAt = - faker.helpers.maybe( - () => faker.date.between({ from: new Date(base.createdAt), to: MOCK_REF_DATE }).toISOString(), - { probability: 0.3 }, - ) ?? null; + faker.helpers.maybe(() => faker.date.between({ from: new Date(base.createdAt), to: MOCK_REF_DATE }).toISOString(), { probability: 0.3 }) ?? + null; return { ...base, @@ -49,7 +47,6 @@ export const mockTask = (key = 'task:default'): TaskModel => attachments: faker.helpers.multiple(() => mockUuid(), { count: { min: 0, max: 3 } }), labels: faker.helpers.multiple(() => mockUuid(), { count: { min: 0, max: 3 } }), assignedTo: faker.helpers.multiple(() => mockUuid(), { count: { min: 0, max: 2 } }), - mentions: [], publicAt, // Channel entity columns ...channelIds, @@ -58,15 +55,7 @@ export const mockTask = (key = 'task:default'): TaskModel => const mockEmbeddedLabel = (id: string, key: string) => { const label = mockLabel(key); - return { - id, - name: label.name, - slug: label.slug, - color: label.color, - mode: label.mode, - icon: label.icon, - projectId: label.projectId, - }; + return { id, name: label.name, slug: label.slug, color: label.color, mode: label.mode, icon: label.icon, projectId: label.projectId }; }; /** Task wire response with stored relation IDs hydrated to embedded users and labels. */ @@ -77,9 +66,7 @@ export const mockTaskResponse = (key = 'task:default') => { ...task, labels: task.labels.map((id, index) => mockEmbeddedLabel(id, `${key}:label:${index}`)), primaryLabel: mockEmbeddedLabel(task.primaryLabelId, `${key}:primary-label`), - assignedTo: task.assignedTo.map((id, index) => ({ - ...mockUserMinimalBase(`${key}:assigned-to:${index}`, id), - })), + assignedTo: task.assignedTo.map((id, index) => ({ ...mockUserMinimalBase(`${key}:assigned-to:${index}`, id) })), ...mockAuditUsers(task, key), }; }; diff --git a/backend/src/modules/task/task-queries.ts b/backend/src/modules/task/task-queries.ts index 86f794888..8ac3ddbe4 100644 --- a/backend/src/modules/task/task-queries.ts +++ b/backend/src/modules/task/task-queries.ts @@ -63,9 +63,7 @@ export const findProjectsByWorkspace = async (ctx: ActorContext, { workspaceId } if (actor.kind !== 'user') return []; const { organizationId } = requestScope(ctx, 'project'); return db - .select({ - ...getColumns(projectsTable), - }) + .select({ ...getColumns(projectsTable) }) .from(projectsTable) .innerJoin( membershipsTable, @@ -100,10 +98,7 @@ interface FindProjectMemberUserIdsOpts { userIds: string[]; } -export const findProjectMemberUserIds = async ( - ctx: ActorContext, - { projectId, userIds }: FindProjectMemberUserIdsOpts, -) => { +export const findProjectMemberUserIds = async (ctx: ActorContext, { projectId, userIds }: FindProjectMemberUserIdsOpts) => { const { db } = ctx.var; const { organizationId } = requestScope(ctx); return db @@ -130,10 +125,7 @@ export const findProjectMembers = async (ctx: ActorContext, { projectIds }: Find return db .selectDistinct({ ...userMinimalBaseSelect, entityType: sql<'user'>`'user'` }) .from(usersTable) - .innerJoin( - membershipsTable, - and(eq(membershipsTable.organizationId, organizationId), inArray(membershipsTable.projectId, projectIds)), - ) + .innerJoin(membershipsTable, and(eq(membershipsTable.organizationId, organizationId), inArray(membershipsTable.projectId, projectIds))) .where(eq(usersTable.id, membershipsTable.userId)) .orderBy(asc(usersTable.name)); }; @@ -190,10 +182,7 @@ interface FindTasksPaginatedOpts { * COUNT(*) is skipped entirely; tasks are always project-scoped and thus never org-wide * counter-eligible, so non-delta reads resolve `total` via the exact COUNT(*). */ -export const findTasksPaginated = async ( - ctx: DbContext, - { filters, orderBy, limit, offset, isDelta }: FindTasksPaginatedOpts, -) => { +export const findTasksPaginated = async (ctx: DbContext, { filters, orderBy, limit, offset, isDelta }: FindTasksPaginatedOpts) => { const { db } = ctx.var; const itemsQuery = db .select() @@ -223,10 +212,7 @@ interface CountTasksByStatusOpts { export const countTasksByStatus = async (ctx: ActorContext, { projectId }: CountTasksByStatusOpts) => { const { db } = ctx.var; return db - .select({ - status: tasksTable.status, - count: count(), - }) + .select({ status: tasksTable.status, count: count() }) .from(tasksTable) .where(and(eq(tasksTable.projectId, projectId), requestScopeWhere(ctx, tasksTable, 'task'))) .groupBy(tasksTable.status); @@ -246,13 +232,7 @@ export const filterExistingAttachmentIds = async (ctx: ActorContext, { ids }: Fi const rows = await db .select({ id: attachmentsTable.id }) .from(attachmentsTable) - .where( - and( - inArray(attachmentsTable.id, ids), - requestScopeWhere(ctx, attachmentsTable, 'attachment'), - isNull(attachmentsTable.deletedAt), - ), - ); + .where(and(inArray(attachmentsTable.id, ids), requestScopeWhere(ctx, attachmentsTable, 'attachment'), isNull(attachmentsTable.deletedAt))); const found = new Set(rows.map((row) => row.id)); return ids.filter((id) => found.has(id)); }; diff --git a/backend/src/modules/task/task-routes.ts b/backend/src/modules/task/task-routes.ts index 974b715cf..ce8ac37b3 100644 --- a/backend/src/modules/task/task-routes.ts +++ b/backend/src/modules/task/task-routes.ts @@ -2,10 +2,6 @@ import { createXRoute } from '#/core/x-routes'; import { orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { productCache } from '#/middlewares/product-cache'; import { bulkPointsLimiter, singlePointsLimiter, syncReadLimiter } from '#/middlewares/rate-limiter/limiters'; -import { createTasksOp } from '#/modules/task/operations/create-tasks'; -import { getTaskOp } from '#/modules/task/operations/get-task'; -import { getTasksOp } from '#/modules/task/operations/get-tasks'; -import { updateTaskOp } from '#/modules/task/operations/update-task'; import { mockBatchTasksResponse, mockTaskResponse, mockTasksResponse } from '#/modules/task/task-mocks'; import { taskCreateManyStxBodySchema, @@ -34,17 +30,14 @@ const taskRoutes = { path: '/', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [bulkPointsLimiter], - tags: ['tasks', 'app', 'product'], - summary: 'Create tasks', - description: 'Creates one or more tasks within a project.', - 'x-tool': { - enabled: true, + xTool: { description: 'Create one or more tasks in a project. Requires project ID, task name, and status.', approvalRequired: true, - category: 'tasks', entity: 'task', - execute: (ctx, { body }) => createTasksOp(ctx, body), }, + tags: ['tasks', 'app', 'product'], + summary: 'Create tasks', + description: 'Creates one or more tasks within a project.', request: { params: tenantOrgParamSchema, body: { required: true, content: { 'application/json': { schema: taskCreateManyStxBodySchema } } }, @@ -71,31 +64,19 @@ const taskRoutes = { xGuard: [userGuard, tenantGuard, orgGuard], // Sync-driven read backpressure on the delta path (template pattern for app product lists) xRateLimiter: [syncReadLimiter], - tags: ['tasks', 'app', 'product'], - summary: 'Get list of tasks', - description: 'Returns a list of tasks within one or more specified projects.', - 'x-tool': { - enabled: true, - description: - 'Search tasks by keyword, status, label, or project. Returns matching task summaries with status and assignees.', + xTool: { + description: 'Search tasks by keyword, status, label, or project. Returns matching task summaries with status and assignees.', approvalRequired: false, - category: 'tasks', entity: 'task', - execute: (ctx, { query }) => getTasksOp(ctx, query), - }, - request: { - params: tenantOrgParamSchema, - query: taskListQuerySchema, }, + tags: ['tasks', 'app', 'product'], + summary: 'Get list of tasks', + description: 'Returns a list of tasks within one or more specified projects.', + request: { params: tenantOrgParamSchema, query: taskListQuerySchema }, responses: { 200: { description: 'Tasks', - content: { - 'application/json': { - schema: paginationSchema(taskSchema), - example: mockTasksResponse(), - }, - }, + content: { 'application/json': { schema: paginationSchema(taskSchema), example: mockTasksResponse() } }, }, ...errorResponseRefs, }, @@ -106,17 +87,10 @@ const taskRoutes = { path: '/{id}', xGuard: [userGuard, tenantGuard, orgGuard], xCache: [productCache('task')], + xTool: { description: 'Get full task details including description, labels, and assignees.', approvalRequired: false, entity: 'task' }, tags: ['tasks', 'app', 'product'], summary: 'Get task', description: 'Retrieves a task by its ID.', - 'x-tool': { - enabled: true, - description: 'Get full task details including description, labels, and assignees.', - approvalRequired: false, - category: 'tasks', - entity: 'task', - execute: (ctx, { params }) => getTaskOp(ctx, params.id), - }, request: { params: idInTenantOrgParamSchema }, responses: { 200: { @@ -132,19 +106,14 @@ const taskRoutes = { path: '/{id}', xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [singlePointsLimiter], - tags: ['tasks', 'app', 'product'], - summary: 'Update task', - description: 'Updates a task by ID.', - 'x-tool': { - enabled: true, + xTool: { description: 'Update task fields: summary, status, labels, assignees, description, or move to another project.', approvalRequired: true, - category: 'tasks', entity: 'task', - // The transaction is server-built, so field timestamps come from the server clock. - execute: (ctx, { params, query, body }) => - updateTaskOp(ctx, params.id, body, { fullResponse: query.fullResponse, serverOrigin: true }), }, + tags: ['tasks', 'app', 'product'], + summary: 'Update task', + description: 'Updates a task by ID.', request: { params: idInTenantOrgParamSchema, query: fullResponseQuerySchema, diff --git a/backend/src/modules/task/task-schema.test.ts b/backend/src/modules/task/task-schema.test.ts index bd5a3b1d0..b9f032374 100644 --- a/backend/src/modules/task/task-schema.test.ts +++ b/backend/src/modules/task/task-schema.test.ts @@ -6,18 +6,11 @@ import { taskCreateManyStxBodySchema, taskSchema, taskUpdateStxBodySchema } from const firstId = '00000000-0000-4000-8000-000000000001'; const secondId = '00000000-0000-4000-8000-000000000002'; const hlc = '100:0001:aaaaa'; -const stx = (fieldTimestamps: Record = {}) => ({ - mutationId: firstId, - sourceId: 'task-schema-test', - fieldTimestamps, -}); +const stx = (fieldTimestamps: Record = {}) => ({ mutationId: firstId, sourceId: 'task-schema-test', fieldTimestamps }); describe('task mutation schemas', () => { it('accepts only declared task statuses on update', () => { - const validUpdate = { - ops: { status: TaskStatus.Started }, - stx: stx({ status: hlc }), - }; + const validUpdate = { ops: { status: TaskStatus.Started }, stx: stx({ status: hlc }) }; expect(taskUpdateStxBodySchema.safeParse(validUpdate).success).toBe(true); expect(taskUpdateStxBodySchema.safeParse({ ...validUpdate, ops: { status: 999 } }).success).toBe(false); }); diff --git a/backend/src/modules/task/task-schema.ts b/backend/src/modules/task/task-schema.ts index 131199205..1f6c70709 100644 --- a/backend/src/modules/task/task-schema.ts +++ b/backend/src/modules/task/task-schema.ts @@ -7,14 +7,7 @@ import { labelEmbeddedSchema } from '#/modules/label/label-schema'; import { tasksTable } from '#/modules/task/task-db'; import { mockTaskResponse } from '#/modules/task/task-mocks'; import { TaskStatus } from '#/modules/task/task-properties'; -import { - batchResponseSchema, - maxLength, - paginationQuerySchema, - stxBaseSchema, - validIdSchema, - validUuidSchema, -} from '#/schemas'; +import { batchResponseSchema, maxLength, paginationQuerySchema, stxBaseSchema, validIdSchema, validUuidSchema } from '#/schemas'; import { nullableUserMinimalBaseSchema, userMinimalBaseSchema } from '#/schemas/minimal-base'; const taskRelationIdsSchema = validUuidSchema @@ -23,20 +16,12 @@ const taskRelationIdsSchema = validUuidSchema .refine((ids) => new Set(ids).size === ids.length, 'Relation IDs must be unique'); const taskRelationDeltaSchema = arrayDeltaSchema(validUuidSchema); -const taskInsertSchema = createInsertSchema(tasksTable, { - description: z.string().max(maxLength.html).nullable(), -}); +const taskInsertSchema = createInsertSchema(tasksTable, { description: z.string().max(maxLength.html).nullable() }); const taskSelectSchema = createSelectSchema(tasksTable); export const taskSchema = z .object({ - ...taskSelectSchema.omit({ - labels: true, - createdBy: true, - assignedTo: true, - updatedBy: true, - stx: true, - }).shape, + ...taskSelectSchema.omit({ labels: true, createdBy: true, assignedTo: true, updatedBy: true, stx: true }).shape, labels: z.array(labelEmbeddedSchema), // Hydrated from primaryLabelId; null only when the referenced row is missing from the relation set primaryLabel: labelEmbeddedSchema.nullable(), @@ -52,23 +37,17 @@ export const taskSchema = z 'x-tags': schemaTags('data', 'tasks', 'app'), }); -const taskCreateSchema = taskInsertSchema - .pick({ - name: true, - description: true, - projectId: true, - }) - .extend({ - id: validUuidSchema, - status: z.enum(TaskStatus), - // Optional on the wire: the server falls back to the project's default primary label - primaryLabelId: validUuidSchema.optional(), - displayOrder: z.number().optional(), - labels: taskRelationIdsSchema.optional(), - assignedTo: taskRelationIdsSchema.optional(), - // Client sets publicity per task (stamped from the project's publicAt on create); omitted -> private. - publicAt: z.string().nullable().optional(), - }); +const taskCreateSchema = taskInsertSchema.pick({ name: true, description: true, projectId: true }).extend({ + id: validUuidSchema, + status: z.enum(TaskStatus), + // Optional on the wire: the server falls back to the project's default primary label + primaryLabelId: validUuidSchema.optional(), + displayOrder: z.number().optional(), + labels: taskRelationIdsSchema.optional(), + assignedTo: taskRelationIdsSchema.optional(), + // Client sets publicity per task (stamped from the project's publicAt on create); omitted -> private. + publicAt: z.string().nullable().optional(), +}); /** Wire registration: lens-widened schemas + entity-bound runtime seams for task */ export const taskContract = evolutionContract.product('task', { diff --git a/backend/src/modules/tenants/operations/get-tenants.ts b/backend/src/modules/tenants/operations/get-tenants.ts index 608105947..2a526c8ef 100644 --- a/backend/src/modules/tenants/operations/get-tenants.ts +++ b/backend/src/modules/tenants/operations/get-tenants.ts @@ -20,7 +20,5 @@ export async function getTenantsOp(ctx: UserContext, input: GetTenantsInput) { conditions.push(eq(tenantsTable.status, status)); } - const { items, total } = await findTenantsPaginated(ctx, { filters: conditions, sort, order, limit, offset }); - - return { items, total }; + return findTenantsPaginated(ctx, { filters: conditions, sort, order, limit, offset }); } diff --git a/backend/src/modules/tenants/operations/self-create-tenant.ts b/backend/src/modules/tenants/operations/self-create-tenant.ts index 2885c05fb..07b24e415 100644 --- a/backend/src/modules/tenants/operations/self-create-tenant.ts +++ b/backend/src/modules/tenants/operations/self-create-tenant.ts @@ -1,9 +1,10 @@ import { and, eq, notInArray } from 'drizzle-orm'; import type { UserContext } from '#/core/context'; +import { AppError } from '#/core/error'; import { organizationsTable } from '#/modules/organization/organization-db'; import { createTenantForUser } from '#/modules/tenants/tenant-service'; import { tenantsTable } from '#/modules/tenants/tenants-db'; -import { countDomainsByTenant } from '#/modules/tenants/tenants-queries'; +import { findTenant } from '#/modules/tenants/tenants-queries'; interface SelfCreateTenantInput { name: string; @@ -16,23 +17,14 @@ export async function selfCreateTenantOp(ctx: UserContext, input: SelfCreateTena // A user may own several tenants, each holding exactly one org. Reuse an orphan tenant (created by // this user with no org yet) so retries do not pile up empty tenants; organizations.tenant_id is NOT NULL. const tenantsWithOrg = db.select({ tenantId: organizationsTable.tenantId }).from(organizationsTable); - const [orphanTenant] = await db - .select() - .from(tenantsTable) - .where(and(eq(tenantsTable.createdBy, user.id), notInArray(tenantsTable.id, tenantsWithOrg))) - .limit(1); - - if (orphanTenant) { - const domainsCount = await countDomainsByTenant(ctx, { targetTenantId: orphanTenant.id }); - return { ...orphanTenant, domainsCount }; - } - - const tenant = await createTenantForUser(db, { - name: input.name, - createdBy: user.id, - userEmail: user.email, + const orphanTenant = await findTenant(ctx, { + where: and(eq(tenantsTable.createdBy, user.id), notInArray(tenantsTable.id, tenantsWithOrg)), }); + if (orphanTenant) return orphanTenant; + + const { id } = await createTenantForUser(db, { name: input.name, createdBy: user.id, userEmail: user.email }); - const domainsCount = await countDomainsByTenant(ctx, { targetTenantId: tenant.id }); - return { ...tenant, domainsCount }; + const tenant = await findTenant(ctx, { where: eq(tenantsTable.id, id) }); + if (!tenant) throw new AppError(500, 'server_error', 'error', { meta: { reason: 'created_tenant_not_found', tenantId: id } }); + return tenant; } diff --git a/backend/src/modules/tenants/operations/update-tenant.ts b/backend/src/modules/tenants/operations/update-tenant.ts index e30c6b638..e9bbd308a 100644 --- a/backend/src/modules/tenants/operations/update-tenant.ts +++ b/backend/src/modules/tenants/operations/update-tenant.ts @@ -1,26 +1,29 @@ import type { z } from '@hono/zod-openapi'; +import { eq } from 'drizzle-orm'; import type { UserContext } from '#/core/context'; import { AppError } from '#/core/error'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { countDomainsByTenant, findTenantById, updateTenant } from '#/modules/tenants/tenants-queries'; +import { normalizeRestrictions } from '#/modules/tenants/tenant-restrictions'; +import { tenantsTable } from '#/modules/tenants/tenants-db'; +import { findTenant, updateTenant } from '#/modules/tenants/tenants-queries'; import type { updateTenantBodySchema } from '#/modules/tenants/tenants-schema'; import { log } from '#/utils/logger'; type UpdateTenantInput = z.infer; export async function updateTenantOp(ctx: UserContext, tenantId: string, updates: UpdateTenantInput) { - const existing = await findTenantById(ctx, { targetTenantId: tenantId }); + const existing = await findTenant(ctx, { where: eq(tenantsTable.id, tenantId) }); if (!existing) throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'tenant' } }); const { restrictions: restrictionsUpdate, ...otherUpdates } = updates; - // Deep-merge restrictions so partial updates don't clobber existing values + // Deep-merge restrictions so partial updates don't clobber existing values; `existing` already has every field + const current = existing.restrictions; const mergedRestrictions = restrictionsUpdate ? { - quotas: { ...existing.restrictions.quotas, ...restrictionsUpdate.quotas }, - rateLimits: { ...existing.restrictions.rateLimits, ...restrictionsUpdate.rateLimits }, - allowUnregisteredClients: - restrictionsUpdate.allowUnregisteredClients ?? existing.restrictions.allowUnregisteredClients, + quotas: { ...current.quotas, ...restrictionsUpdate.quotas }, + rateLimits: { ...current.rateLimits, ...restrictionsUpdate.rateLimits }, + allowUnregisteredClients: restrictionsUpdate.allowUnregisteredClients ?? current.allowUnregisteredClients, } : undefined; @@ -29,12 +32,12 @@ export async function updateTenantOp(ctx: UserContext, tenantId: string, updates ...(mergedRestrictions ? { restrictions: mergedRestrictions } : {}), updatedAt: new Date().toISOString(), }; - const tenant = await updateTenant(ctx, { targetTenantId: tenantId, values }); + const updated = await updateTenant(ctx, { targetTenantId: tenantId, values }); - await invalidateCache.tenant(ctx.var.db, tenantId); + invalidateCache.tenant(tenantId); log.info('Tenant updated', { tenantId, updates }); - const domainsCount = await countDomainsByTenant(ctx, { targetTenantId: tenantId }); - return { ...tenant, domainsCount }; + // An update leaves the tenant's domains and organization as they were. + return { ...existing, ...updated, restrictions: normalizeRestrictions(updated.restrictions) }; } diff --git a/backend/src/modules/tenants/tenant-restrictions.ts b/backend/src/modules/tenants/tenant-restrictions.ts index c95c56a4c..3424de140 100644 --- a/backend/src/modules/tenants/tenant-restrictions.ts +++ b/backend/src/modules/tenants/tenant-restrictions.ts @@ -33,9 +33,7 @@ export const defaultRestrictions = (): Restrictions => { return { quotas, - rateLimits: { - apiPointsPerHour: appConfig.defaultRestrictions.rateLimits.apiPointsPerHour, - }, + rateLimits: { apiPointsPerHour: appConfig.defaultRestrictions.rateLimits.apiPointsPerHour }, allowUnregisteredClients: true, }; }; diff --git a/backend/src/modules/tenants/tenant-service.ts b/backend/src/modules/tenants/tenant-service.ts index b1cc26609..02f2b09fd 100644 --- a/backend/src/modules/tenants/tenant-service.ts +++ b/backend/src/modules/tenants/tenant-service.ts @@ -1,9 +1,9 @@ import { eq } from 'drizzle-orm'; -import { appConfig } from 'shared'; import type { DbOrTx } from '#/db/db'; -import { sendAccountSecurityEmail } from '#/modules/auth/general/helpers/send-account-security-email'; +import { sendSecurityInboxEmail } from '#/modules/auth/general/helpers/send-account-security-email'; import { domainsTable } from '#/modules/domains/domains-db'; import { type TenantModel, tenantsTable } from '#/modules/tenants/tenants-db'; +import { utcStamp } from '#/utils/iso-date'; import { log } from '#/utils/logger'; /** Creates a tenant with an associated domain claim, for self-serve creation during org onboarding. */ @@ -26,11 +26,7 @@ export async function createTenantForUser( log.info('Tenant auto-created', { tenantId: tenant.id, name, createdBy }); // Fire-and-forget security notification to sysadmin - sendAccountSecurityEmail({ email: appConfig.securityEmail, name: 'Security' }, 'tenant-created', { - tenantName: name, - userEmail, - timestamp: `${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`, - }); + sendSecurityInboxEmail('tenant-created', { tenantName: name, userEmail, timestamp: utcStamp() }); return tenant; } diff --git a/backend/src/modules/tenants/tenants-mocks.ts b/backend/src/modules/tenants/tenants-mocks.ts new file mode 100644 index 000000000..ddb4f2dde --- /dev/null +++ b/backend/src/modules/tenants/tenants-mocks.ts @@ -0,0 +1,30 @@ +import { faker } from '@faker-js/faker'; +import { mockPaginated, mockTenantId, mockTimestamps, mockUuid, withFakerSeed } from '#/mocks'; +import { defaultRestrictions } from '#/modules/tenants/tenant-restrictions'; + +export const mockTenantResponse = (key = 'tenant:default') => + withFakerSeed(key, () => { + const name = faker.company.name(); + return { + id: mockTenantId(), + name, + status: 'active' as const, + restrictions: defaultRestrictions(), + authStrategies: [], + createdBy: mockUuid(), + subscriptionId: null, + subscriptionStatus: 'none' as const, + subscriptionPlan: null, + ...mockTimestamps(), + domainsCount: 0, + organization: { + id: mockUuid(), + name, + slug: faker.helpers.slugify(name).toLowerCase(), + thumbnailUrl: null, + entityType: 'organization' as const, + }, + }; + }); + +export const mockPaginatedTenantsResponse = (count = 2) => mockPaginated(mockTenantResponse, count); diff --git a/backend/src/modules/tenants/tenants-queries.ts b/backend/src/modules/tenants/tenants-queries.ts index 6ec115631..4eae9d602 100644 --- a/backend/src/modules/tenants/tenants-queries.ts +++ b/backend/src/modules/tenants/tenants-queries.ts @@ -3,10 +3,72 @@ import type { DbContext } from '#/core/context'; import { resolveListTotal } from '#/db/utils/list-total'; import { domainsTable } from '#/modules/domains/domains-db'; import { organizationsTable } from '#/modules/organization/organization-db'; +import { normalizeRestrictions } from '#/modules/tenants/tenant-restrictions'; import { tenantsTable } from '#/modules/tenants/tenants-db'; import { getOrderColumns } from '#/utils/order-column'; import { pick } from '#/utils/pick'; +/** Tenant columns a response carries; subscriptionData stays server-side. */ +const tenantColumns = pick(tenantsTable, [ + 'id', + 'name', + 'status', + 'restrictions', + 'authStrategies', + 'createdBy', + 'subscriptionId', + 'subscriptionStatus', + 'subscriptionPlan', + 'createdAt', + 'updatedAt', +]); + +/** Tenant rows joined with their domains count and the organization each holds (organizations.tenant_id is unique). */ +const selectTenants = (ctx: DbContext) => { + const { db } = ctx.var; + + const domainsCountSq = db + .select({ tenantId: domainsTable.tenantId, count: count().as('domains_count') }) + .from(domainsTable) + .groupBy(domainsTable.tenantId) + .as('domains_count_sq'); + + return db + .select({ + ...tenantColumns, + domainsCount: sql`coalesce(${domainsCountSq.count}, 0)`.mapWith(Number), + organizationId: organizationsTable.id, + organizationName: organizationsTable.name, + organizationSlug: organizationsTable.slug, + organizationThumbnailUrl: organizationsTable.thumbnailUrl, + }) + .from(tenantsTable) + .leftJoin(domainsCountSq, eq(tenantsTable.id, domainsCountSq.tenantId)) + .leftJoin(organizationsTable, eq(organizationsTable.tenantId, tenantsTable.id)) + .$dynamic(); +}; + +type TenantRow = Awaited>[number]; + +/** + * Folds the flat org columns into `organization` (null for an orphan tenant) and gives a stored row the + * restriction fields it predates, so one such row cannot fail a whole list. + */ +const toTenant = ({ organizationId, organizationName, organizationSlug, organizationThumbnailUrl, ...tenant }: TenantRow) => ({ + ...tenant, + restrictions: normalizeRestrictions(tenant.restrictions), + organization: organizationId + ? { + id: organizationId, + // Non-null within this branch: the left join returns them on the same row as the id. + name: organizationName as string, + slug: organizationSlug as string, + thumbnailUrl: organizationThumbnailUrl, + entityType: 'organization' as const, + } + : null, +}); + interface FindTenantsPaginatedOpts { filters: SQL[]; sort?: 'name' | 'createdAt'; @@ -28,59 +90,14 @@ export const findTenantsPaginated = async (ctx: DbContext, opts: FindTenantsPagi tieBreaker: tenantsTable.id, }); - const domainsCountSq = db - .select({ tenantId: domainsTable.tenantId, count: count().as('domains_count') }) - .from(domainsTable) - .groupBy(domainsTable.tenantId) - .as('domains_count_sq'); - - const itemsQuery = db - .select({ - id: tenantsTable.id, - name: tenantsTable.name, - status: tenantsTable.status, - restrictions: tenantsTable.restrictions, - authStrategies: tenantsTable.authStrategies, - createdBy: tenantsTable.createdBy, - subscriptionId: tenantsTable.subscriptionId, - subscriptionStatus: tenantsTable.subscriptionStatus, - subscriptionPlan: tenantsTable.subscriptionPlan, - subscriptionData: tenantsTable.subscriptionData, - domainsCount: sql`coalesce(${domainsCountSq.count}, 0)`.mapWith(Number), - createdAt: tenantsTable.createdAt, - updatedAt: tenantsTable.updatedAt, - // 1 tenant = 1 organization, so this left join yields at most one org row (null for an orphan tenant). - organizationId: organizationsTable.id, - organizationName: organizationsTable.name, - organizationSlug: organizationsTable.slug, - organizationThumbnailUrl: organizationsTable.thumbnailUrl, - }) - .from(tenantsTable) - .leftJoin(domainsCountSq, eq(tenantsTable.id, domainsCountSq.tenantId)) - .leftJoin(organizationsTable, eq(organizationsTable.tenantId, tenantsTable.id)) + const itemsQuery = selectTenants(ctx) .where(whereClause) .orderBy(...orderBy) .limit(limit) - .offset(offset); + .offset(offset) + .then((rows) => rows.map(toTenant)); - // Fold the flat org columns into a nested `organization` object (null for orphan tenants). - const nestedItemsQuery = itemsQuery.then((rows) => - rows.map(({ organizationId, organizationName, organizationSlug, organizationThumbnailUrl, ...tenant }) => ({ - ...tenant, - organization: organizationId - ? { - id: organizationId, - // Non-null within this branch: the left join returns them on the same row as the id. - name: organizationName as string, - slug: organizationSlug as string, - thumbnailUrl: organizationThumbnailUrl, - entityType: 'organization' as const, - } - : null, - })), - ); - - return resolveListTotal(nestedItemsQuery, { + return resolveListTotal(itemsQuery, { kind: 'exact', getTotal: async () => { const [{ total }] = await db.select({ total: count() }).from(tenantsTable).where(whereClause); @@ -89,14 +106,10 @@ export const findTenantsPaginated = async (ctx: DbContext, opts: FindTenantsPagi }); }; -interface FindTenantByIdOpts { - targetTenantId: string; -} - -export const findTenantById = async (ctx: DbContext, { targetTenantId }: FindTenantByIdOpts) => { - const { db } = ctx.var; - const [tenant] = await db.select().from(tenantsTable).where(eq(tenantsTable.id, targetTenantId)).limit(1); - return tenant; +/** The first tenant matching `where`, in its response shape; undefined when none matches. */ +export const findTenant = async (ctx: DbContext, { where }: { where: SQL | undefined }) => { + const [row] = await selectTenants(ctx).where(where).limit(1); + return row ? toTenant(row) : undefined; }; interface UpdateTenantOpts { @@ -106,19 +119,6 @@ interface UpdateTenantOpts { export const updateTenant = async (ctx: DbContext, { targetTenantId, values }: UpdateTenantOpts) => { const { db } = ctx.var; - const [updated] = await db.update(tenantsTable).set(values).where(eq(tenantsTable.id, targetTenantId)).returning(); + const [updated] = await db.update(tenantsTable).set(values).where(eq(tenantsTable.id, targetTenantId)).returning(tenantColumns); return updated; }; - -interface CountDomainsByTenantOpts { - targetTenantId: string; -} - -export const countDomainsByTenant = async (ctx: DbContext, { targetTenantId }: CountDomainsByTenantOpts) => { - const { db } = ctx.var; - const [{ domainsCount }] = await db - .select({ domainsCount: count() }) - .from(domainsTable) - .where(eq(domainsTable.tenantId, targetTenantId)); - return domainsCount; -}; diff --git a/backend/src/modules/tenants/tenants-routes.ts b/backend/src/modules/tenants/tenants-routes.ts index 80f8f0a0a..6714c662b 100644 --- a/backend/src/modules/tenants/tenants-routes.ts +++ b/backend/src/modules/tenants/tenants-routes.ts @@ -3,96 +3,44 @@ * @see cella/ARCHITECTURE.md */ -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, jsonBody, xRoute } from '#/core/x-routes'; import { sysAdminGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; -import { errorResponseRefs, paginationSchema, tenantOnlyParamSchema } from '#/schemas'; -import { - selfCreateTenantBodySchema, - tenantListQuerySchema, - tenantSchema, - tenantWithOrganizationSchema, - updateTenantBodySchema, -} from './tenants-schema'; +import { paginationSchema, tenantOnlyParamSchema } from '#/schemas'; +import { mockPaginatedTenantsResponse, mockTenantResponse } from './tenants-mocks'; +import { selfCreateTenantBodySchema, tenantListQuerySchema, tenantSchema, updateTenantBodySchema } from './tenants-schema'; -export const tenantRoutes = { - getTenants: createXRoute({ - operationId: 'getTenants', +export const tenantRoutes = createXRoutes(['tenants', 'cella'], { + getTenants: xRoute({ method: 'get', path: '/', xGuard: [userGuard, sysAdminGuard], - tags: ['tenants', 'cella'], summary: 'Get list of tenants', description: 'Returns a paginated list of tenants. System admin access required.', request: { query: tenantListQuerySchema }, - responses: { - 200: { - description: 'Tenants list', - content: { - 'application/json': { - schema: paginationSchema(tenantWithOrganizationSchema), - }, - }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('Tenants list', paginationSchema(tenantSchema), mockPaginatedTenantsResponse()) }, }), - selfCreateTenant: createXRoute({ - operationId: 'selfCreateTenant', + selfCreateTenant: xRoute({ method: 'post', path: '/self', xGuard: [userGuard], xRateLimiter: [singlePointsLimiter], - tags: ['tenants', 'cella'], summary: 'Create a tenant for yourself', description: 'Creates a new tenant (workspace) for the authenticated user. A user may own multiple tenants; an org-less tenant from a prior failed attempt is reused instead of creating a duplicate.', - request: { - body: { - required: true, - content: { 'application/json': { schema: selfCreateTenantBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Created tenant', - content: { - 'application/json': { - schema: tenantSchema, - }, - }, - }, - ...errorResponseRefs, - }, + request: { body: jsonBody(selfCreateTenantBodySchema) }, + responses: { 200: json('Created tenant', tenantSchema, mockTenantResponse()) }, }), - updateTenant: createXRoute({ - operationId: 'updateTenant', + updateTenant: xRoute({ method: 'put', path: '/{tenantId}', xGuard: [userGuard, sysAdminGuard], xRateLimiter: [singlePointsLimiter], - tags: ['tenants', 'cella'], summary: 'Update a tenant', description: 'Updates a tenant by ID. System admin access required.', - request: { - params: tenantOnlyParamSchema, - body: { - required: true, - content: { 'application/json': { schema: updateTenantBodySchema } }, - }, - }, - responses: { - 200: { - description: 'Updated tenant', - content: { - 'application/json': { - schema: tenantSchema, - }, - }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOnlyParamSchema, body: jsonBody(updateTenantBodySchema) }, + responses: { 200: json('Updated tenant', tenantSchema, mockTenantResponse()) }, }), -}; +}); diff --git a/backend/src/modules/tenants/tenants-schema.ts b/backend/src/modules/tenants/tenants-schema.ts index b7caafd47..a9b252fc8 100644 --- a/backend/src/modules/tenants/tenants-schema.ts +++ b/backend/src/modules/tenants/tenants-schema.ts @@ -3,7 +3,8 @@ import { schemaTags } from '#/core/openapi-helpers'; import { createInsertSchema, createSelectSchema } from '#/db/utils/drizzle-schema'; import { authStrategiesEnum } from '#/modules/auth/sessions-db'; import { subscriptionStatusValues, tenantStatusValues, tenantsTable } from '#/modules/tenants/tenants-db'; -import { nullableOrganizationMinimalBaseSchema, paginationQuerySchema, validNameSchema } from '#/schemas'; +import { minimalBaseSchema, paginationQuerySchema, validNameSchema } from '#/schemas'; +import { mockTenantResponse } from './tenants-mocks'; export type TenantStatus = (typeof tenantStatusValues)[number]; @@ -15,9 +16,7 @@ const rateLimitsSchema = z.object({ .number() .int() .min(0) - .describe( - 'Max API points per hour per user within this tenant (0 = no tenant limit; the global safety ceiling still applies)', - ), + .describe('Max API points per hour per user within this tenant (0 = no tenant limit; the global safety ceiling still applies)'), }); const quotasSchema = z.record(z.string(), z.number().int().min(0)).describe('Entity quotas (0 = unlimited)'); @@ -27,9 +26,7 @@ const restrictionsSchema = z.object({ rateLimits: rateLimitsSchema, allowUnregisteredClients: z .boolean() - .describe( - 'Whether members may consent to OAuth clients that have no registration (AI clients using a Client ID Metadata Document)', - ), + .describe('Whether members may consent to OAuth clients that have no registration (AI clients using a Client ID Metadata Document)'), }); export const tenantSchema = z @@ -39,34 +36,21 @@ export const tenantSchema = z authStrategies: z.array(z.enum(authStrategiesEnum)), }).omit({ subscriptionData: true }).shape, domainsCount: z.number().int().describe('Number of domains claimed by this tenant'), + organization: minimalBaseSchema('organization').nullable().describe('The organization this tenant holds, or null if none'), }) .openapi('Tenant', { description: 'A tenant representing an isolated data partition for multi-tenancy.', + example: mockTenantResponse(), 'x-tags': schemaTags('data', 'tenants', 'cella'), }); -export const tenantWithOrganizationSchema = tenantSchema - .extend({ - organization: nullableOrganizationMinimalBaseSchema.describe('The organization this tenant holds, or null if none'), - }) - .openapi('TenantWithOrganization', { - description: 'A tenant together with the single organization it holds.', - 'x-tags': schemaTags('data', 'tenants', 'cella'), - }); - -export const selfCreateTenantBodySchema = createInsertSchema(tenantsTable, { - name: validNameSchema, -}).pick({ name: true }); +export const selfCreateTenantBodySchema = createInsertSchema(tenantsTable, { name: validNameSchema }).pick({ name: true }); const partialRestrictionsSchema = z .object({ quotas: quotasSchema.optional(), allowUnregisteredClients: z.boolean().optional(), - rateLimits: z - .object({ - apiPointsPerHour: z.number().int().min(0).optional(), - }) - .optional(), + rateLimits: z.object({ apiPointsPerHour: z.number().int().min(0).optional() }).optional(), }) .describe('Partial restrictions override'); @@ -77,18 +61,9 @@ export const updateTenantBodySchema = createInsertSchema(tenantsTable, { // Allowed sign-in strategies for the tenant's members (empty = all enabled); tenantGuard enforcement waits on the SSO build. authStrategies: z.array(z.enum(authStrategiesEnum)), }) - .pick({ - name: true, - status: true, - subscriptionId: true, - subscriptionStatus: true, - subscriptionPlan: true, - authStrategies: true, - }) + .pick({ name: true, status: true, subscriptionId: true, subscriptionStatus: true, subscriptionPlan: true, authStrategies: true }) .partial() - .extend({ - restrictions: partialRestrictionsSchema.optional(), - }); + .extend({ restrictions: partialRestrictionsSchema.optional() }); export const tenantListQuerySchema = paginationQuerySchema.extend({ sort: z.enum(['createdAt', 'name']).default('createdAt'), diff --git a/backend/src/modules/user/helpers/audit-user.ts b/backend/src/modules/user/helpers/audit-user.ts index 16e005ceb..9a7383529 100644 --- a/backend/src/modules/user/helpers/audit-user.ts +++ b/backend/src/modules/user/helpers/audit-user.ts @@ -23,20 +23,14 @@ const buildAuditUserSelect = (aliasedTable: typeof createdByUser | typeof update entityType: sql<'user'>`'user'`, }); -export const auditUserSelect = { - createdBy: buildAuditUserSelect(createdByUser), - updatedBy: buildAuditUserSelect(updatedByUser), -}; +export const auditUserSelect = { createdBy: buildAuditUserSelect(createdByUser), updatedBy: buildAuditUserSelect(updatedByUser) }; /** Accepts both nullable (LEFT JOIN) and non-nullable shapes for audit user fields. */ type LooseAuditUser = { [K in keyof UserMinimalBase]: UserMinimalBase[K] | null }; type RawAuditRow = { createdBy: LooseAuditUser; updatedBy: LooseAuditUser }; /** Entity with audit user fields resolved to full objects (or null). */ -type WithAuditUsers = Omit & { - createdBy: UserMinimalBase | null; - updatedBy: UserMinimalBase | null; -}; +type WithAuditUsers = Omit & { createdBy: UserMinimalBase | null; updatedBy: UserMinimalBase | null }; export function coalesceAuditUsers(rows: T[]): WithAuditUsers[] { return rows.map(({ createdBy, updatedBy, ...rest }) => ({ @@ -46,16 +40,12 @@ export function coalesceAuditUsers(rows: T[]): WithAuditU })); } -export const toUserMinimalBase = ( - user: Pick, -): UserMinimalBase => ({ +export const toUserMinimalBase = (user: Pick): UserMinimalBase => ({ ...user, entityType: 'user', }); -type KnownUsersInput = - | Map - | { id: string; name: string; slug: string; thumbnailUrl: string | null }; +type KnownUsersInput = Map | { id: string; name: string; slug: string; thumbnailUrl: string | null }; /** * Populates createdBy/updatedBy string IDs with UserMinimalBase objects. The columns hold any actor id; a diff --git a/backend/src/modules/user/helpers/delete-accounts.ts b/backend/src/modules/user/helpers/delete-accounts.ts index c556f4559..af9d25ae4 100644 --- a/backend/src/modules/user/helpers/delete-accounts.ts +++ b/backend/src/modules/user/helpers/delete-accounts.ts @@ -1,6 +1,6 @@ import type { DbContext } from '#/core/context'; import type { ActorId } from '#/db/utils/ids'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; +import { revokeSessions } from '#/modules/auth/general/helpers/revoke-sessions'; import { deleteConsentsOfUsers } from '#/modules/oauth-server/oauth-server-queries'; import { deleteUsersByIds } from '#/modules/system/system-queries'; @@ -21,5 +21,5 @@ export async function deleteAccounts(ctx: DbContext, { userIds, by }: DeleteAcco // CASCADE SET NULL on createdBy/updatedBy propagates to product entities. await deleteUsersByIds(ctx, { ids: userIds }); await deleteConsentsOfUsers(ctx, { userIds }); - for (const userId of userIds) await endSessions(ctx, { userId, all: true, reason: 'user_deleted', by }); + for (const userId of userIds) await revokeSessions(ctx, { userId, all: true, reason: 'user_deleted', by }); } diff --git a/backend/src/modules/user/helpers/insert-users.ts b/backend/src/modules/user/helpers/insert-users.ts deleted file mode 100644 index 13be16e9f..000000000 --- a/backend/src/modules/user/helpers/insert-users.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { generateId } from 'shared/utils/entity-id'; -import type { DbOrTx } from '#/db/db'; -import { deleteDanglingActors, insertActors } from '#/modules/actors/helpers/insert-actors'; -import { type InsertUserModel, type UserModel, usersTable } from '#/modules/user/user-db'; - -interface InsertUsersOptions { - /** Skip rows that already exist (seed re-runs); a skipped user leaves no actor behind. */ - onConflictDoNothing?: boolean; -} - -/** - * The only way to insert users: the `actors` row of kind `user` goes first, in one transaction, so a failed user - * insert (taken email, slug race) leaves no orphan actor and a missed call site fails on the foreign key. - */ -export async function insertUsers( - db: DbOrTx, - records: InsertUserModel[], - { onConflictDoNothing = false }: InsertUsersOptions = {}, -): Promise { - if (records.length === 0) return []; - const withIds = records.map((record) => ({ ...record, id: record.id ?? generateId() })); - - return db.transaction(async (tx) => { - const actorIds = await insertActors( - tx, - withIds.map(({ id }) => id), - 'user', - { onConflictDoNothing }, - ); - const userInsert = tx.insert(usersTable).values(withIds).returning(); - const users = onConflictDoNothing ? await userInsert.onConflictDoNothing() : await userInsert; - - // Only actors this call created and whose user row was skipped; an id that already existed keeps its user. - if (onConflictDoNothing && users.length < withIds.length) { - const inserted = new Set(users.map((user) => user.id)); - await deleteDanglingActors( - tx, - actorIds.filter((id) => !inserted.has(id)), - ); - } - return users; - }); -} diff --git a/backend/src/modules/user/helpers/select.ts b/backend/src/modules/user/helpers/select.ts index 22032f541..ee258299c 100644 --- a/backend/src/modules/user/helpers/select.ts +++ b/backend/src/modules/user/helpers/select.ts @@ -7,11 +7,7 @@ import { userBaseSchema } from '#/schemas/user-schema-base'; import { pick } from '#/utils/pick'; /** User with timestamps from the user_counters table. */ -export type UserWithCounters = UserModel & { - lastSeenAt: string | null; - lastStartedAt: string | null; - lastSignInAt: string | null; -}; +export type UserWithCounters = UserModel & { lastSeenAt: string | null; lastStartedAt: string | null; lastSignInAt: string | null }; /** Merges userFlags with the defaults; timestamps come from user_counters subqueries to avoid CDC noise. */ export const userSelect = (() => { diff --git a/backend/src/modules/user/operations/get-user.ts b/backend/src/modules/user/operations/get-user.ts index 905fdf4ac..e9642c239 100644 --- a/backend/src/modules/user/operations/get-user.ts +++ b/backend/src/modules/user/operations/get-user.ts @@ -33,8 +33,7 @@ export async function getUserOp(ctx: UserContext, relatableUserId: string, opts: const targetUser = await findUserByFilters(ctx, { filters }); - if (!targetUser) - throw new AppError(404, 'not_found', 'warn', { entityType: 'user', meta: { user: relatableUserId } }); + if (!targetUser) throw new AppError(404, 'not_found', 'warn', { entityType: 'user', meta: { user: relatableUserId } }); return targetUser; } diff --git a/backend/src/modules/user/operations/get-users.ts b/backend/src/modules/user/operations/get-users.ts index b4becd585..655027376 100644 --- a/backend/src/modules/user/operations/get-users.ts +++ b/backend/src/modules/user/operations/get-users.ts @@ -31,12 +31,8 @@ export async function getUsersOp(ctx: UserContext, input: GetUsersInput) { if (!isSystemAdmin) filters.push(sharesOrgFilter({ var: { db } }, { myOrgIds })); if (role) filters.push(eq(systemRolesTable.role, role)); if (q) { - filters.push( - or( - ilike(usersTable.name, prepareStringForILikeFilter(q)), - ilike(usersTable.email, prepareStringForILikeFilter(q)), - )!, - ); + const pattern = prepareStringForILikeFilter(q); + filters.push(or(ilike(usersTable.name, pattern), ilike(usersTable.email, pattern))!); } const { items, total } = await findUsersPaginated(ctx, { filters, sort, order, limit, offset }); diff --git a/backend/src/modules/user/user-db.ts b/backend/src/modules/user/user-db.ts index 36c348dc1..eeae3eff5 100644 --- a/backend/src/modules/user/user-db.ts +++ b/backend/src/modules/user/user-db.ts @@ -46,10 +46,7 @@ export const usersTable = snakeCase.table( index('users_name_index').on(table.name.desc()), index('users_email_index').on(table.email.desc()), index('users_created_at_index').on(table.createdAt.desc()), - foreignKey({ - columns: [table.updatedBy], - foreignColumns: [table.id], - }), + foreignKey({ columns: [table.updatedBy], foreignColumns: [table.id] }), ], ); diff --git a/backend/src/modules/user/user-mocks.ts b/backend/src/modules/user/user-mocks.ts index 0d4ab86e1..6425791a8 100644 --- a/backend/src/modules/user/user-mocks.ts +++ b/backend/src/modules/user/user-mocks.ts @@ -49,18 +49,12 @@ const generateUser = ({ email: emailOverride, enforceUnique = false }: MockUserO }; /** Generates a full insertable user while enforcing unique email and slug values. */ -export const mockUser = (overrides: Pick = {}): InsertUserModel => - generateUser({ ...overrides, enforceUnique: true }); +export const mockUser = (overrides: Pick = {}): InsertUserModel => generateUser({ ...overrides, enforceUnique: true }); export const mockUserResponse = (key = 'user:default'): UserWithCounters => withFakerSeed(key, () => { const user = generateUser(); - return { - ...user, - lastStartedAt: user.createdAt, - lastSignInAt: user.createdAt, - lastSeenAt: user.createdAt, - }; + return { ...user, lastStartedAt: user.createdAt, lastSignInAt: user.createdAt, lastSeenAt: user.createdAt }; }); export interface UserListItem extends UserWithCounters { @@ -93,10 +87,5 @@ export const mockAdmin = (id: string | undefined, email: string): InsertUserMode }; export const mockEmail = (user: UserModel): InsertEmailModel => { - return { - email: user.email, - userId: user.id, - verified: true, - verifiedAt: mockPastIsoDate(), - }; + return { email: user.email, userId: user.id, verified: true, verifiedAt: mockPastIsoDate() }; }; diff --git a/backend/src/modules/user/user-queries.ts b/backend/src/modules/user/user-queries.ts index 6ca8635b2..992a5324e 100644 --- a/backend/src/modules/user/user-queries.ts +++ b/backend/src/modules/user/user-queries.ts @@ -1,11 +1,13 @@ import { and, count, eq, type SQL, sql } from 'drizzle-orm'; +import { generateId } from 'shared/utils/entity-id'; import type { DbContext } from '#/core/context'; import { resolveListTotal } from '#/db/utils/list-total'; +import { deleteDanglingActors, insertActors } from '#/modules/actors/actors-queries'; import { systemRolesTable } from '#/modules/system/system-roles-db'; import { emailsTable } from '#/modules/user/emails-db'; import { memberSelect, userSelect } from '#/modules/user/helpers/select'; import { userCountersTable } from '#/modules/user/user-counters-db'; -import { usersTable } from '#/modules/user/user-db'; +import { type InsertUserModel, type UserModel, usersTable } from '#/modules/user/user-db'; import { getOrderColumns } from '#/utils/order-column'; interface FindUsersPaginatedOpts { @@ -95,3 +97,47 @@ export const findUserByFilters = async (ctx: DbContext, { filters }: FindUserByF .limit(1); return user; }; + +interface FindUserForUpdateOpts { + id: string; +} + +/** Locks the user's row for the rest of the transaction; returns the MFA switch the factor rules read under that lock. */ +export const findUserForUpdate = async (ctx: DbContext, { id }: FindUserForUpdateOpts) => { + const [user] = await ctx.var.db + .select({ id: usersTable.id, mfaRequired: usersTable.mfaRequired }) + .from(usersTable) + .where(eq(usersTable.id, id)) + .for('update'); + return user; +}; + +interface InsertUsersOpts { + users: InsertUserModel[]; + /** Skip rows that already exist (seed re-runs); a skipped user leaves no actor behind. */ + onConflictDoNothing?: boolean; +} + +/** + * The only way to insert users: the `actors` row of kind `user` goes first, in one transaction, so a failed user + * insert (taken email, slug race) leaves no orphan actor and a missed call site fails on the foreign key. + */ +export const insertUsers = async (ctx: DbContext, { users, onConflictDoNothing = false }: InsertUsersOpts): Promise => { + if (users.length === 0) return []; + const withIds = users.map((user) => ({ ...user, id: user.id ?? generateId() })); + + return ctx.var.db.transaction(async (tx) => { + const txCtx = { var: { db: tx } }; + const ids = withIds.map(({ id }) => id); + const actorIds = await insertActors(txCtx, { ids, kind: 'user', onConflictDoNothing }); + const userInsert = tx.insert(usersTable).values(withIds).returning(); + const inserted = onConflictDoNothing ? await userInsert.onConflictDoNothing() : await userInsert; + + // Only actors this call created and whose user row was skipped; an id that already existed keeps its user. + if (onConflictDoNothing && inserted.length < withIds.length) { + const insertedIds = new Set(inserted.map((user) => user.id)); + await deleteDanglingActors(txCtx, { ids: actorIds.filter((id) => !insertedIds.has(id)) }); + } + return inserted; + }); +}; diff --git a/backend/src/modules/user/user-routes.ts b/backend/src/modules/user/user-routes.ts index 14795a535..e6bbeac33 100644 --- a/backend/src/modules/user/user-routes.ts +++ b/backend/src/modules/user/user-routes.ts @@ -1,57 +1,41 @@ -import { createXRoute } from '#/core/x-routes'; -import { crossTenantGuard, relatableGuard, userGuard } from '#/middlewares/guard'; +import { createXRoutes, json, xRoute } from '#/core/x-routes'; +import { relatableGuard, userGuard } from '#/middlewares/guard'; import { systemRoleBaseSchema } from '#/modules/system/system-schema'; import { memberUserSchema, userListQuerySchema } from '#/modules/user/user-schema'; -import { errorResponseRefs, paginationSchema, relatableUserIdParamSchema, slugQuerySchema } from '#/schemas'; +import { paginationSchema, relatableUserIdParamSchema, slugQuerySchema } from '#/schemas'; import { mockPaginatedUsersResponse, mockUserResponse } from './user-mocks'; -const userRoutes = { - getUsers: createXRoute({ - operationId: 'getUsers', +const userRoutes = createXRoutes(['users', 'cella'], { + getUsers: xRoute({ method: 'get', path: '/users', - xGuard: [userGuard, crossTenantGuard], - tags: ['users', 'cella'], + xGuard: [userGuard], summary: 'Get list of users', - description: - 'Returns a list of users. Only system admins receive the system `role`, and only they may filter or sort by it.', + description: 'Returns a list of users. Only system admins receive the system `role`, and only they may filter or sort by it.', request: { query: userListQuerySchema }, responses: { - 200: { - description: 'Users', - content: { - 'application/json': { - schema: paginationSchema( - memberUserSchema.extend({ - // Absent for other callers: the field would list the system admins. - role: systemRoleBaseSchema.shape.role.nullable().optional(), - }), - ), - example: mockPaginatedUsersResponse(), - }, - }, - }, - ...errorResponseRefs, + 200: json( + 'Users', + paginationSchema( + memberUserSchema.extend({ + // Absent for other callers: the field would list the system admins. + role: systemRoleBaseSchema.shape.role.nullable().optional(), + }), + ), + mockPaginatedUsersResponse(), + ), }, }), - getUser: createXRoute({ - operationId: 'getUser', + getUser: xRoute({ method: 'get', path: '/users/{relatableUserId}', - xGuard: [userGuard, crossTenantGuard, relatableGuard], - tags: ['users', 'cella'], + xGuard: [userGuard, relatableGuard], summary: 'Get user', description: 'Retrieves a user by ID. The requesting user must share at least one organization membership. Pass ?slug=true to resolve by slug instead.', request: { params: relatableUserIdParamSchema, query: slugQuerySchema }, - responses: { - 200: { - description: 'User', - content: { 'application/json': { schema: memberUserSchema, example: mockUserResponse() } }, - }, - ...errorResponseRefs, - }, + responses: { 200: json('User', memberUserSchema, mockUserResponse()) }, }), -}; +}); export { userRoutes }; diff --git a/backend/src/modules/user/user-schema.ts b/backend/src/modules/user/user-schema.ts index 47d95277d..437ac3e79 100644 --- a/backend/src/modules/user/user-schema.ts +++ b/backend/src/modules/user/user-schema.ts @@ -5,21 +5,11 @@ import { createInsertSchema, createSelectSchema } from '#/db/utils/drizzle-schem import { memberCountsSchema } from '#/modules/memberships/helpers/member-counts'; import { memberMembershipSchema } from '#/modules/memberships/memberships-schema'; import { usersTable } from '#/modules/user/user-db'; -import { - languageSchema, - maxLength, - paginationQuerySchema, - validCDNUrlSchema, - validNameSchema, - validSlugSchema, -} from '#/schemas'; +import { languageSchema, maxLength, paginationQuerySchema, validCDNUrlSchema, validNameSchema, validSlugSchema } from '#/schemas'; import { userBaseSchema } from '#/schemas/user-schema-base'; import { mockUserResponse } from './user-mocks'; -export const enabledOAuthProvidersSchema = z.enum([...appConfig.enabledOAuthProviders] as [ - EnabledOAuthProvider, - ...EnabledOAuthProvider[], -]); +export const enabledOAuthProvidersSchema = z.enum([...appConfig.enabledOAuthProviders] as [EnabledOAuthProvider, ...EnabledOAuthProvider[]]); export const userFlagsSchema = z.object( Object.keys(appConfig.defaultUserFlags).reduce( @@ -31,11 +21,7 @@ export const userFlagsSchema = z.object( ), ); -export const userSchema = createSelectSchema(usersTable, { - email: z.email(), - language: languageSchema, - userFlags: userFlagsSchema, -}) +export const userSchema = createSelectSchema(usersTable, { email: z.email(), language: languageSchema, userFlags: userFlagsSchema }) .extend({ // Timestamps from user_counters table (populated via subqueries in userSelect) lastSeenAt: z.string().nullable(), @@ -49,9 +35,7 @@ export const userSchema = createSelectSchema(usersTable, { }); /** Public user schema for cross-tenant and member-facing endpoints. Based on userBaseSchema + lastSeenAt. */ -export const memberUserSchema = userBaseSchema.extend({ - lastSeenAt: z.string().nullable(), -}); +export const memberUserSchema = userBaseSchema.extend({ lastSeenAt: z.string().nullable() }); export const memberSchema = memberUserSchema.extend({ membership: memberMembershipSchema, diff --git a/backend/src/modules/workspace/operations/create-workspaces.ts b/backend/src/modules/workspace/operations/create-workspaces.ts index 229df651a..df9a922e4 100644 --- a/backend/src/modules/workspace/operations/create-workspaces.ts +++ b/backend/src/modules/workspace/operations/create-workspaces.ts @@ -1,8 +1,9 @@ -import type { UserContext } from '#/core/context'; +import { nanoid } from 'shared/utils/nanoid'; +import type { DbContext, UserContext } from '#/core/context'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; import { getOrganizationEntityCount } from '#/modules/entities/entities-queries'; import { buildZeroCounts } from '#/modules/entities/helpers/build-zero-counts'; -import { generateUniqueSlug } from '#/modules/entities/helpers/generate-slug'; +import { checkSlugAvailable } from '#/modules/entities/helpers/check-slug'; import { insertMemberships } from '#/modules/memberships/helpers/membership-helpers'; import { toMembershipBase } from '#/modules/memberships/helpers/select'; import { withAuditUsers } from '#/modules/user/helpers/audit-user'; @@ -15,6 +16,16 @@ import { createRejectionState, takeWithRestriction } from '#/utils/rejection-uti type CreateWorkspaceItem = { id: string; name: string }; +const generateUniqueSlug = async (ctx: DbContext, baseSlug: string): Promise => { + if (await checkSlugAvailable(ctx, baseSlug, 'workspace')) return baseSlug; + + const withSuffix = `${baseSlug}-${nanoid(6)}`; + if (await checkSlugAvailable(ctx, withSuffix, 'workspace')) return withSuffix; + + // Final fallback uses enough entropy that collisions are not expected. + return `${withSuffix}-${nanoid(10)}`; +}; + export async function createWorkspacesOp(ctx: UserContext, rawItems: CreateWorkspaceItem[]) { // Lens seam: canonicalize old-shape field names before any body access const items = rawItems.map((item) => workspaceContract.normalizeBody(item)); @@ -22,17 +33,12 @@ export async function createWorkspacesOp(ctx: UserContext, rawItems: CreateWorks const user = ctx.var.user; const organization = ctx.var.organization; - const currentWorkspacesCount = await getOrganizationEntityCount(ctx, { - organizationId: organization.id, - entityType: 'workspace', - }); + const currentWorkspacesCount = await getOrganizationEntityCount(ctx, { organizationId: organization.id, entityType: 'workspace' }); const workspaceRestrictions = ctx.var.tenant.restrictions.quotas.workspace; const availableSlots = workspaceRestrictions === 0 ? items.length : workspaceRestrictions - currentWorkspacesCount; const restrictionFiltered = - workspaceRestrictions === 0 - ? { items, rejectionState: createRejectionState() } - : takeWithRestriction(items, availableSlots, 'restrict_by_org'); + workspaceRestrictions === 0 ? { items, rejectionState: createRejectionState() } : takeWithRestriction(items, availableSlots, 'restrict_by_org'); const itemsToCreate = restrictionFiltered.items; const rejectionState = restrictionFiltered.rejectionState; @@ -46,7 +52,7 @@ export async function createWorkspacesOp(ctx: UserContext, rawItems: CreateWorks const workspaceValues = await Promise.all( itemsToCreate.map(async (item) => ({ name: item.name, - slug: await generateUniqueSlug(ctx, `${user.slug}-${organization.slug}`, 'workspace'), + slug: await generateUniqueSlug(ctx, `${user.slug}-${organization.slug}`), createdBy: user.id, tenantId: organization.tenantId, organizationId: organization.id, @@ -55,10 +61,7 @@ export async function createWorkspacesOp(ctx: UserContext, rawItems: CreateWorks const workspaceRecords = await insertWorkspaces(ctx, { workspaces: workspaceValues }); - log.info('Workspaces created', { - count: workspaceRecords.length, - ids: workspaceRecords.map((ws) => ws.id), - }); + log.info('Workspaces created', { count: workspaceRecords.length, ids: workspaceRecords.map((ws) => ws.id) }); const membershipInserts = workspaceRecords.map((ws) => ({ userId: user.id, @@ -70,7 +73,7 @@ export async function createWorkspacesOp(ctx: UserContext, rawItems: CreateWorks const createdMemberships = await insertMemberships({ var: { db } }, { items: membershipInserts }); // Invalidate membership cache so subsequent requests see the new membership - await invalidateCache.user(db, user.id); + invalidateCache.user(user.id); const counts = buildZeroCounts('workspace'); const membershipByWsId = new Map(createdMemberships.map((m) => [m.workspaceId, m])); diff --git a/backend/src/modules/workspace/operations/delete-workspaces.ts b/backend/src/modules/workspace/operations/delete-workspaces.ts index db03b4e6f..31f70a91f 100644 --- a/backend/src/modules/workspace/operations/delete-workspaces.ts +++ b/backend/src/modules/workspace/operations/delete-workspaces.ts @@ -10,7 +10,7 @@ export async function deleteWorkspacesOp(ctx: UserContext, ids: string[]) { await deleteWorkspacesByIds(ctx, { ids: allowedIds }); // Invalidate membership cache so deleted memberships are absent from later reads. - await invalidateCache.user(ctx.var.db, ctx.var.user.id); + invalidateCache.user(ctx.var.user.id); log.info('Workspaces deleted', { count: allowedIds.length, ids: allowedIds }); return { data: [], rejectedIds }; diff --git a/backend/src/modules/workspace/operations/get-workspace.ts b/backend/src/modules/workspace/operations/get-workspace.ts index 6f0238e44..c84ba9a80 100644 --- a/backend/src/modules/workspace/operations/get-workspace.ts +++ b/backend/src/modules/workspace/operations/get-workspace.ts @@ -25,8 +25,7 @@ export async function getWorkspaceOp(ctx: UserContext, id: string, opts: GetWork const included: { counts?: typeof counts; membership?: ReturnType } = {}; if (counts) included.counts = counts; - if (includeMembership && membership && isMembershipRow(membership)) - included.membership = toMembershipBase(membership); + if (includeMembership && membership && isMembershipRow(membership)) included.membership = toMembershipBase(membership); return { ...workspaceWithAudit, included }; } diff --git a/backend/src/modules/workspace/operations/get-workspaces.ts b/backend/src/modules/workspace/operations/get-workspaces.ts index c68936c77..acaa81904 100644 --- a/backend/src/modules/workspace/operations/get-workspaces.ts +++ b/backend/src/modules/workspace/operations/get-workspaces.ts @@ -23,11 +23,7 @@ export async function getWorkspacesOp(ctx: UserContext, input: GetWorkspacesInpu const includeCounts = include.includes('counts'); const includeMembership = include.includes('membership'); - const { items: workspaceResults, total } = await findWorkspacesPaginated(ctx, { - userId: user.id, - ...queryOpts, - includeCounts, - }); + const { items: workspaceResults, total } = await findWorkspacesPaginated(ctx, { userId: user.id, ...queryOpts, includeCounts }); const items = coalesceAuditUsers(workspaceResults).map((ws) => { const { membership, counts, ...workspace } = ws; diff --git a/backend/src/modules/workspace/operations/update-workspace.ts b/backend/src/modules/workspace/operations/update-workspace.ts index 2ddd6d12f..23611f673 100644 --- a/backend/src/modules/workspace/operations/update-workspace.ts +++ b/backend/src/modules/workspace/operations/update-workspace.ts @@ -20,15 +20,9 @@ export async function updateWorkspaceOp(ctx: UserContext, id: string, rawInput: log.info('Workspace updated', { workspaceId: updatedWorkspaceRecord.id }); - const counts = await getChannelCounts(ctx, { - entityType: workspace.entityType, - entityId: workspace.id, - }); + const counts = await getChannelCounts(ctx, { entityType: workspace.entityType, entityId: workspace.id }); const workspaceWithAudit = await withAuditUser(ctx, updatedWorkspaceRecord, user); - const included = { - ...(membership && isMembershipRow(membership) && { membership: toMembershipBase(membership) }), - counts, - }; + const included = { ...(membership && isMembershipRow(membership) && { membership: toMembershipBase(membership) }), counts }; return { ...workspaceWithAudit, included }; } diff --git a/backend/src/modules/workspace/workspace-db.ts b/backend/src/modules/workspace/workspace-db.ts index 7a2e6f272..7442d86a5 100644 --- a/backend/src/modules/workspace/workspace-db.ts +++ b/backend/src/modules/workspace/workspace-db.ts @@ -7,26 +7,18 @@ import { organizationsTable } from '#/modules/organization/organization-db'; * Each workspace belongs to exactly one organization and inherits its tenant (RLS isolation boundary), * and is owned by a single user (not shared with others). */ -export const workspacesTable = snakeCase.table( - 'workspaces', - { - ...channelColumns('workspace'), - organizationId: uuid().notNull(), - }, - (table) => [ - index('workspaces_name_index').on(table.name.desc()), - index('workspaces_created_at_index').on(table.createdAt.desc()), - index('workspaces_tenant_id_index').on(table.tenantId), - index('workspaces_organization_id_index').on(table.organizationId), - index('workspaces_created_by_index').on(table.createdBy), - index('workspaces_updated_by_index').on(table.updatedBy), - unique('workspaces_tenant_id_unique').on(table.tenantId, table.id), - foreignKey({ - columns: [table.tenantId, table.organizationId], - foreignColumns: [organizationsTable.tenantId, organizationsTable.id], - }).onDelete('cascade'), - ], -); +export const workspacesTable = snakeCase.table('workspaces', { ...channelColumns('workspace'), organizationId: uuid().notNull() }, (table) => [ + index('workspaces_name_index').on(table.name.desc()), + index('workspaces_created_at_index').on(table.createdAt.desc()), + index('workspaces_tenant_id_index').on(table.tenantId), + index('workspaces_organization_id_index').on(table.organizationId), + index('workspaces_created_by_index').on(table.createdBy), + index('workspaces_updated_by_index').on(table.updatedBy), + unique('workspaces_tenant_id_unique').on(table.tenantId, table.id), + foreignKey({ columns: [table.tenantId, table.organizationId], foreignColumns: [organizationsTable.tenantId, organizationsTable.id] }).onDelete( + 'cascade', + ), +]); export type WorkspaceModel = typeof workspacesTable.$inferSelect; export type InsertWorkspaceModel = typeof workspacesTable.$inferInsert; diff --git a/backend/src/modules/workspace/workspace-mocks.ts b/backend/src/modules/workspace/workspace-mocks.ts index 57998a342..3643600b3 100644 --- a/backend/src/modules/workspace/workspace-mocks.ts +++ b/backend/src/modules/workspace/workspace-mocks.ts @@ -24,22 +24,9 @@ const workspaceName = new UniqueEnforcer(); * @param createdAt - Creation timestamp * @param organizationId - Parent organization ID */ -const generateWorkspaceBase = ( - id: string, - name: string, - createdAt: string, - organizationId: string, - tenantId: string, -) => { +const generateWorkspaceBase = (id: string, name: string, createdAt: string, organizationId: string, tenantId: string) => { return { - ...mockChannelColumns('workspace', { - id, - name, - createdAt, - updatedAt: createdAt, - tenantId, - channelIds: { organizationId }, - }), + ...mockChannelColumns('workspace', { id, name, createdAt, updatedAt: createdAt, tenantId, channelIds: { organizationId } }), organizationId, }; }; @@ -61,12 +48,7 @@ export const mockWorkspace = (suffix?: string): InsertWorkspaceModel => { */ export const mockWorkspaceResponse = ( key = 'workspace:default', -): WorkspaceModel & { - included: { - membership: MembershipBaseModel; - counts: ReturnType; - }; -} => +): WorkspaceModel & { included: { membership: MembershipBaseModel; counts: ReturnType } } => withFakerSeed(key, () => { const createdAt = mockPastIsoDate(); const workspaceId = mockUuid(); @@ -84,13 +66,7 @@ export const mockWorkspaceResponse = ( tenantId, }); - return { - ...base, - included: { - membership, - counts: generateMockChannelCounts('workspace', `${key}:counts`), - }, - }; + return { ...base, included: { membership, counts: generateMockChannelCounts('workspace', `${key}:counts`) } }; }); export const mockPaginatedWorkspacesResponse = (count = 2) => mockPaginated(mockWorkspaceResponse, count); diff --git a/backend/src/modules/workspace/workspace-queries.ts b/backend/src/modules/workspace/workspace-queries.ts index a6da8703f..bc6936275 100644 --- a/backend/src/modules/workspace/workspace-queries.ts +++ b/backend/src/modules/workspace/workspace-queries.ts @@ -46,9 +46,7 @@ interface DeleteWorkspacesByIdsOpts { export const deleteWorkspacesByIds = async (ctx: ActorContext, { ids }: DeleteWorkspacesByIdsOpts) => { const { db } = ctx.var; const { organizationId } = requestScope(ctx); - return db - .delete(workspacesTable) - .where(and(inArray(workspacesTable.id, ids), eq(workspacesTable.organizationId, organizationId))); + return db.delete(workspacesTable).where(and(inArray(workspacesTable.id, ids), eq(workspacesTable.organizationId, organizationId))); }; interface FindWorkspacesPaginatedOpts { @@ -124,10 +122,7 @@ export const findWorkspacesPaginated = async (ctx: DbContext, opts: FindWorkspac let query = db.select(selectShape).from(workspacesTable).innerJoin(membershipsTable, membershipOn).$dynamic(); if (countData) { - query = query.leftJoin( - channelCountersTable, - sql`${workspacesTable.id}::text = ${channelCountersTable.channelKey}`, - ) as typeof query; + query = query.leftJoin(channelCountersTable, sql`${workspacesTable.id}::text = ${channelCountersTable.channelKey}`) as typeof query; } const itemsQuery = query diff --git a/backend/src/modules/workspace/workspace-routes.ts b/backend/src/modules/workspace/workspace-routes.ts index e78e1838d..0e2cb548a 100644 --- a/backend/src/modules/workspace/workspace-routes.ts +++ b/backend/src/modules/workspace/workspace-routes.ts @@ -1,12 +1,8 @@ import { createXRoute } from '#/core/x-routes'; -import { crossTenantGuard, orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; +import { orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { insertEntityLock } from '#/middlewares/insert-entity-lock'; import { bulkPointsLimiter, singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; -import { - mockBatchWorkspacesResponse, - mockPaginatedWorkspacesResponse, - mockWorkspaceResponse, -} from '#/modules/workspace/workspace-mocks'; +import { mockBatchWorkspacesResponse, mockPaginatedWorkspacesResponse, mockWorkspaceResponse } from '#/modules/workspace/workspace-mocks'; import { workspaceCreateBodySchema, workspaceCreateResponseSchema, @@ -41,22 +37,13 @@ const workspaceRoutes = { params: tenantOrgParamSchema, body: { required: true, - content: { - 'application/json': { - schema: workspaceCreateBodySchema, - }, - }, + content: { 'application/json': { schema: workspaceCreateBodySchema } }, }, }, responses: { 201: { description: 'Workspaces created', - content: { - 'application/json': { - schema: workspaceCreateResponseSchema, - example: mockBatchWorkspacesResponse(), - }, - }, + content: { 'application/json': { schema: workspaceCreateResponseSchema, example: mockBatchWorkspacesResponse() } }, }, ...errorResponseRefs, }, @@ -67,7 +54,7 @@ const workspaceRoutes = { getWorkspaces: createXRoute({ method: 'get', path: '/workspaces', - xGuard: [userGuard, crossTenantGuard], + xGuard: [userGuard], tags: ['workspaces', 'app', 'channel'], operationId: 'getWorkspaces', summary: 'Get list of workspaces', @@ -81,12 +68,7 @@ const workspaceRoutes = { responses: { 200: { description: 'Workspaces', - content: { - 'application/json': { - schema: paginationSchema(workspaceSchema), - example: mockPaginatedWorkspacesResponse(), - }, - }, + content: { 'application/json': { schema: paginationSchema(workspaceSchema), example: mockPaginatedWorkspacesResponse() } }, }, ...errorResponseRefs, }, @@ -102,10 +84,7 @@ const workspaceRoutes = { operationId: 'getWorkspace', summary: 'Get workspace', description: 'Retrieves a workspace by ID. Pass ?slug=true to resolve by slug instead.', - request: { - params: idInTenantOrgParamSchema, - query: slugIncludeQuerySchema, - }, + request: { params: idInTenantOrgParamSchema, query: slugIncludeQuerySchema }, responses: { 200: { description: 'Workspace', @@ -127,22 +106,13 @@ const workspaceRoutes = { params: idInTenantOrgParamSchema, body: { required: true, - content: { - 'application/json': { - schema: workspaceUpdateBodySchema, - }, - }, + content: { 'application/json': { schema: workspaceUpdateBodySchema } }, }, }, responses: { 200: { description: 'Workspace updated', - content: { - 'application/json': { - schema: workspaceSchema, - example: mockWorkspaceResponse(), - }, - }, + content: { 'application/json': { schema: workspaceSchema, example: mockWorkspaceResponse() } }, }, ...errorResponseRefs, }, diff --git a/backend/src/modules/workspace/workspace-schema.ts b/backend/src/modules/workspace/workspace-schema.ts index cb121b48f..eb4830f32 100644 --- a/backend/src/modules/workspace/workspace-schema.ts +++ b/backend/src/modules/workspace/workspace-schema.ts @@ -41,15 +41,8 @@ export const workspaceWithMembershipSchema = workspaceSchema.extend({ /** Wire registration: lens-widened schemas + entity-bound runtime seam for workspace */ export const workspaceContract = evolutionContract.channel('workspace', { - createItem: z.object({ - id: validTempIdSchema, - name: validNameSchema, - }), - updateBody: createInsertSchema(workspacesTable, { - name: validNameSchema, - }) - .pick({ name: true }) - .partial(), + createItem: z.object({ id: validTempIdSchema, name: validNameSchema }), + updateBody: createInsertSchema(workspacesTable, { name: validNameSchema }).pick({ name: true }).partial(), }); /** Array schema for batch creates */ diff --git a/backend/src/modules/yjs/helpers/sanitize-block-media.test.ts b/backend/src/modules/yjs/helpers/sanitize-block-media.test.ts index 3f227d40e..93734cc45 100644 --- a/backend/src/modules/yjs/helpers/sanitize-block-media.test.ts +++ b/backend/src/modules/yjs/helpers/sanitize-block-media.test.ts @@ -44,9 +44,7 @@ describe('sanitizeBlockMediaUrls', () => { it('sanitizes nested children', () => { const bad = 'https://evil.example/x.mp4'; - const description = JSON.stringify([ - { ...paragraph(), children: [{ id: '3', type: 'video', props: { url: bad }, content: [], children: [] }] }, - ]); + const description = JSON.stringify([{ ...paragraph(), children: [{ id: '3', type: 'video', props: { url: bad }, content: [], children: [] }] }]); const result = sanitizeBlockMediaUrls(description, ctx); expect(result.sanitized).toBe(true); diff --git a/backend/src/modules/yjs/helpers/sanitize-block-media.ts b/backend/src/modules/yjs/helpers/sanitize-block-media.ts index 3f17cde77..d8bb4d3c6 100644 --- a/backend/src/modules/yjs/helpers/sanitize-block-media.ts +++ b/backend/src/modules/yjs/helpers/sanitize-block-media.ts @@ -9,11 +9,7 @@ import { blankMediaReference, findRefusedMediaBlocks } from 'shared/utils/valida export function sanitizeBlockMediaUrls( description: string, ctx: MediaRefContext, -): { - description: string; - sanitized: boolean; - invalidUrls: string[]; -} { +): { description: string; sanitized: boolean; invalidUrls: string[] } { let blocks: unknown; try { blocks = JSON.parse(description); diff --git a/backend/src/modules/yjs/helpers/token-signer.ts b/backend/src/modules/yjs/helpers/token-signer.ts index 4a9184b97..41d09983d 100644 --- a/backend/src/modules/yjs/helpers/token-signer.ts +++ b/backend/src/modules/yjs/helpers/token-signer.ts @@ -1,10 +1,6 @@ import type { KeyObject } from 'node:crypto'; import type { ProductEntityType } from 'shared'; -import { - type YjsTokenPayload as SharedYjsTokenPayload, - signYjsToken as signToken, - yjsTokenSigningKey, -} from 'shared/utils/yjs-token'; +import { type YjsTokenPayload as SharedYjsTokenPayload, signYjsToken as signToken, yjsTokenSigningKey } from 'shared/utils/yjs-token'; import { modeSecret } from '#/env'; /** Token TTL: 5 minutes. The relay closes a socket when its token expires, so revoked access reaches open sockets within it. */ diff --git a/backend/src/modules/yjs/operations/materialize-description.ts b/backend/src/modules/yjs/operations/materialize-description.ts index bf9bf8479..21cccb52b 100644 --- a/backend/src/modules/yjs/operations/materialize-description.ts +++ b/backend/src/modules/yjs/operations/materialize-description.ts @@ -34,45 +34,28 @@ export interface MaterializeDescriptionResult { * entity, through the entity's materializer, which runs the normal update operation and its permission check. When * no editor may, the write is refused (403) and the relay keeps the edits. */ -export async function materializeDescriptionOp( - input: MaterializeDescriptionInput, -): Promise { +export async function materializeDescriptionOp(input: MaterializeDescriptionInput): Promise { const { entityType } = input; if (!isProduct(entityType)) { - throw new AppError(400, 'invalid_request', 'warn', { - meta: { reason: `Unknown entity type: ${entityType}` }, - }); + throw new AppError(400, 'invalid_request', 'warn', { meta: { reason: `Unknown entity type: ${entityType}` } }); } const materializer = getYjsMaterializer(entityType); if (!materializer) { - throw new AppError(400, 'invalid_request', 'warn', { - meta: { reason: `No Yjs materializer registered for ${entityType}` }, - }); + throw new AppError(400, 'invalid_request', 'warn', { meta: { reason: `No Yjs materializer registered for ${entityType}` } }); } - const row = await tenantReadById(input.tenantId, (tx) => - resolveEntity({ var: { db: tx } }, { entityType, identifier: input.entityId }), - ); + const row = await tenantReadById(input.tenantId, (tx) => resolveEntity({ var: { db: tx } }, { entityType, identifier: input.entityId })); if (!row || row.tenantId !== input.tenantId) { throw new AppError(410, 'not_found', 'warn', { entityType, meta: { reason: 'The entity is gone' } }); } if (row.organizationId !== input.organizationId) { - throw new AppError(403, 'forbidden', 'warn', { - entityType, - meta: { reason: 'Organization does not match the entity' }, - }); + throw new AppError(403, 'forbidden', 'warn', { entityType, meta: { reason: 'Organization does not match the entity' } }); } - const { description, sanitized, invalidUrls } = sanitizeBlockMediaUrls(input.description, { - organizationId: row.organizationId, - }); + const { description, sanitized, invalidUrls } = sanitizeBlockMediaUrls(input.description, { organizationId: row.organizationId }); if (sanitized) { - log.warn('Yjs materialization sanitized untrusted media URLs', { - entityType, - entityId: input.entityId, - invalidUrls, - }); + log.warn('Yjs materialization sanitized untrusted media URLs', { entityType, entityId: input.entityId, invalidUrls }); } const [users, memberships] = await Promise.all([ @@ -115,8 +98,5 @@ export async function materializeDescriptionOp( } } - throw new AppError(403, 'forbidden', 'warn', { - entityType, - meta: { reason: 'No editor in the log may still update the entity' }, - }); + throw new AppError(403, 'forbidden', 'warn', { entityType, meta: { reason: 'No editor in the log may still update the entity' } }); } diff --git a/backend/src/modules/yjs/operations/retire-yjs-documents.ts b/backend/src/modules/yjs/operations/retire-yjs-documents.ts index 885f77b93..8cb729477 100644 --- a/backend/src/modules/yjs/operations/retire-yjs-documents.ts +++ b/backend/src/modules/yjs/operations/retire-yjs-documents.ts @@ -14,10 +14,6 @@ import { yjsDocumentsTable, yjsUpdatesTable } from '#/modules/yjs/yjs-db'; */ export async function retireYjsDocuments(db: DbOrTx, entityType: ProductEntityType, ids: string[]): Promise { if (ids.length === 0) return; - await db - .delete(yjsDocumentsTable) - .where(and(eq(yjsDocumentsTable.entityType, entityType), inArray(yjsDocumentsTable.entityId, ids))); - await db - .delete(yjsUpdatesTable) - .where(and(eq(yjsUpdatesTable.entityType, entityType), inArray(yjsUpdatesTable.entityId, ids))); + await db.delete(yjsDocumentsTable).where(and(eq(yjsDocumentsTable.entityType, entityType), inArray(yjsDocumentsTable.entityId, ids))); + await db.delete(yjsUpdatesTable).where(and(eq(yjsUpdatesTable.entityType, entityType), inArray(yjsUpdatesTable.entityId, ids))); } diff --git a/backend/src/modules/yjs/yjs-module.ts b/backend/src/modules/yjs/yjs-module.ts index 820e42327..f664292b1 100644 --- a/backend/src/modules/yjs/yjs-module.ts +++ b/backend/src/modules/yjs/yjs-module.ts @@ -5,8 +5,7 @@ import { retireYjsDocuments } from './operations/retire-yjs-documents'; import { yjsHandlers } from './yjs-handlers'; import { getYjsMaterializer } from './yjs-materializers'; -const idsOf = (rows: MutationPayload['before'] = []) => - rows.flatMap((row) => (typeof row.id === 'string' ? [row.id] : [])); +const idsOf = (rows: MutationPayload['before'] = []) => rows.flatMap((row) => (typeof row.id === 'string' ? [row.id] : [])); /** * A description written by anything but the relay (a REST update, an import) retires the collaborative document of diff --git a/backend/src/modules/yjs/yjs-routes.ts b/backend/src/modules/yjs/yjs-routes.ts index f08219bdc..8b09e5fd5 100644 --- a/backend/src/modules/yjs/yjs-routes.ts +++ b/backend/src/modules/yjs/yjs-routes.ts @@ -1,42 +1,26 @@ import { z } from '@hono/zod-openapi'; -import { createXRoute } from '#/core/x-routes'; +import { createXRoutes, json, xRoute } from '#/core/x-routes'; import { orgGuard, tenantGuard, userGuard } from '#/middlewares/guard'; import { singlePointsLimiter } from '#/middlewares/rate-limiter/limiters'; -import { errorResponseRefs, productEntityTypeSchema, tenantOrgParamSchema, validIdSchema } from '#/schemas'; +import { productEntityTypeSchema, tenantOrgParamSchema, validIdSchema } from '#/schemas'; -const yjsTokenQuerySchema = z.object({ - entityType: productEntityTypeSchema, - entityId: validIdSchema, -}); +const yjsTokenQuerySchema = z.object({ entityType: productEntityTypeSchema, entityId: validIdSchema }); -const yjsTokenResponseSchema = z.object({ - token: z.string(), -}); +const yjsTokenResponseSchema = z.object({ token: z.string() }); -const yjsRoutes = { - getYjsToken: createXRoute({ +const yjsRoutes = createXRoutes(['yjs', 'cella'], { + getYjsToken: xRoute({ method: 'get', path: '/token', - 'x-service': 'yjs', + xEnabledBy: { service: 'yjs' }, xGuard: [userGuard, tenantGuard, orgGuard], xRateLimiter: [singlePointsLimiter], - tags: ['yjs', 'cella'], - operationId: 'getYjsToken', summary: 'Get Yjs token', description: 'Returns an Ed25519-signed token for collaboratively editing one product entity the caller may update. It names the entity, its tenant and organization, and expires after five minutes; the Yjs relay worker verifies it with the public key alone, without a backend callback, and closes the socket when it expires.', - request: { - params: tenantOrgParamSchema, - query: yjsTokenQuerySchema, - }, - responses: { - 200: { - description: 'Yjs auth token', - content: { 'application/json': { schema: yjsTokenResponseSchema } }, - }, - ...errorResponseRefs, - }, + request: { params: tenantOrgParamSchema, query: yjsTokenQuerySchema }, + responses: { 200: json('Yjs auth token', yjsTokenResponseSchema) }, }), -}; +}); export { yjsRoutes }; diff --git a/backend/src/permissions/access.ts b/backend/src/permissions/access.ts index c0e5702e4..c2e7e24af 100644 --- a/backend/src/permissions/access.ts +++ b/backend/src/permissions/access.ts @@ -3,10 +3,7 @@ import type { Actor, ActorBinding } from '#/core/context'; /** The guard-populated context fields the access helpers read; the engine's `userId` is any actor id. */ export interface AccessContext { - var: { - actor?: Pick; - isSystemAdmin?: boolean; - }; + var: { actor?: Pick; isSystemAdmin?: boolean }; } /** The grant element type of a context's actor: membership rows for a `UserContext`, the union otherwise. */ @@ -14,9 +11,7 @@ export type BindingOf = NonNullable[ /** Actor for compiled-predicate paths: a hand-assembled context without `actor` fail-closes every `'own'` grant. */ export const actorFrom = (ctx: AccessContext): PredicateActor => - ctx.var.actor - ? { actorId: ctx.var.actor.id, isSystemAdmin: ctx.var.isSystemAdmin, scopes: ctx.var.actor.scopes } - : { anonymous: true }; + ctx.var.actor ? { actorId: ctx.var.actor.id, isSystemAdmin: ctx.var.isSystemAdmin, scopes: ctx.var.actor.scopes } : { anonymous: true }; /** * Actor AND grants in one object for `checkAccess`. Hand-assembling one risks pairing one diff --git a/backend/src/permissions/build-subject.ts b/backend/src/permissions/build-subject.ts index 3a0a0e6a1..4d6a01865 100644 --- a/backend/src/permissions/build-subject.ts +++ b/backend/src/permissions/build-subject.ts @@ -1,9 +1,5 @@ import type { ChannelEntityType, ChannelIdColumns, ProductEntityType, SubjectForPermission } from 'shared'; -import { - MissingAncestorError, - buildSubject as sharedBuildSubject, - buildSubjectFromEntity as sharedBuildSubjectFromEntity, -} from 'shared'; +import { MissingAncestorError, buildSubject as sharedBuildSubject, buildSubjectFromEntity as sharedBuildSubjectFromEntity } from 'shared'; import { AppError } from '#/core/error'; /** Translate the shared engine's tier-neutral `MissingAncestorError` into `AppError(400, 'missing_ancestor')`. */ @@ -24,11 +20,7 @@ const translateMissingScope = (e: unknown): never => { export const buildSubject = ( entityType: ChannelEntityType | ProductEntityType, ancestorChannelIds: Partial, - options?: { - id?: string; - createdBy?: string | null; - row?: Record; - }, + options?: { id?: string; createdBy?: string | null; row?: Record }, ): SubjectForPermission => { try { return sharedBuildSubject(entityType, ancestorChannelIds, options); diff --git a/backend/src/permissions/channel-collection-scope.test.ts b/backend/src/permissions/channel-collection-scope.test.ts index 8d2d0a976..b38b9cc48 100644 --- a/backend/src/permissions/channel-collection-scope.test.ts +++ b/backend/src/permissions/channel-collection-scope.test.ts @@ -3,10 +3,7 @@ import { deepEntityTypes, deepHierarchy, deepOverrides } from 'shared/testing/de import { configurePolicyMatrix } from 'shared/testing/policies'; import { describe, expect, it } from 'vitest'; import type { MembershipBaseModel } from '#/modules/memberships/helpers/select'; -import { - type ChannelCollectionReadScope, - resolveChannelCollectionReadScopeForPolicies, -} from '#/permissions/channel-collection-scope'; +import { type ChannelCollectionReadScope, resolveChannelCollectionReadScopeForPolicies } from '#/permissions/channel-collection-scope'; const ORG_ID = 'org-1'; @@ -54,17 +51,11 @@ describe('resolveChannelCollectionReadScope', () => { it('org-root grant with update sees everything; read-only sees published org-wide', () => { expect(resolve([membership('organization', ORG_ID, 'admin')])).toEqual({ orgWide: 'all', ancestorScopes: [] }); - expect(resolve([membership('organization', ORG_ID, 'member')])).toEqual({ - orgWide: 'published', - ancestorScopes: [], - }); + expect(resolve([membership('organization', ORG_ID, 'member')])).toEqual({ orgWide: 'published', ancestorScopes: [] }); }); it('ancestor-level grants split managed (drafts visible) from published-only ids', () => { - const scope = resolve([ - membership('course', 'course-1', 'staff'), - membership('courseSection', 'section-1', 'student'), - ]); + const scope = resolve([membership('course', 'course-1', 'staff'), membership('courseSection', 'section-1', 'student')]); expect(scope.orgWide).toBeNull(); expect(scope.ancestorScopes).toEqual([ { channelType: 'course', managedIds: ['course-1'], publishedIds: [] }, diff --git a/backend/src/permissions/channel-collection-scope.ts b/backend/src/permissions/channel-collection-scope.ts index 9b4f464b5..ce0a213d8 100644 --- a/backend/src/permissions/channel-collection-scope.ts +++ b/backend/src/permissions/channel-collection-scope.ts @@ -106,13 +106,7 @@ export const resolveChannelCollectionReadScope = ( organizationId: string, actor: PredicateActor, ): ChannelCollectionReadScope => - resolveChannelCollectionReadScopeForPolicies({ - policies: policyMatrix, - memberships, - channelType, - organizationId, - actor, - }); + resolveChannelCollectionReadScopeForPolicies({ policies: policyMatrix, memberships, channelType, organizationId, actor }); /** The table columns a channel list query exposes to compile the scope into SQL. */ export interface ChannelListReadColumns { @@ -132,10 +126,7 @@ export interface ChannelListReadColumns { * membership join ON (never WHERE), map the nested membership only when its id is non-NULL, and * expect membership-sourced sort columns NULL for discovery rows (ASC puts NULLs last, members first). */ -export const buildChannelListReadWhere = ( - scope: ChannelCollectionReadScope, - columns: ChannelListReadColumns, -): CollectionReadWhere => { +export const buildChannelListReadWhere = (scope: ChannelCollectionReadScope, columns: ChannelListReadColumns): CollectionReadWhere => { if (scope.orgWide === 'all') return { kind: 'all' }; const branches: SQL[] = [isNotNull(columns.membershipUserId)]; diff --git a/backend/src/permissions/check-access.ts b/backend/src/permissions/check-access.ts index b4ba9ea1d..9bfdf95f3 100644 --- a/backend/src/permissions/check-access.ts +++ b/backend/src/permissions/check-access.ts @@ -1,7 +1,4 @@ -import type { - BatchPermissionResult as SharedBatchPermissionResult, - PermissionResult as SharedPermissionResult, -} from 'shared'; +import type { BatchPermissionResult as SharedBatchPermissionResult, PermissionResult as SharedPermissionResult } from 'shared'; import type { MembershipBaseModel } from '#/modules/memberships/helpers/select'; // Re-export the shared engine entry point so backend and yjs call the identical function. diff --git a/backend/src/permissions/collection-scope.ts b/backend/src/permissions/collection-scope.ts index d8b8a7cba..dd45b87b6 100644 --- a/backend/src/permissions/collection-scope.ts +++ b/backend/src/permissions/collection-scope.ts @@ -19,12 +19,7 @@ import { import type { ActorBinding } from '#/core/context'; import { AppError } from '#/core/error'; -const roleReadValue = ( - policies: PolicyMatrix, - entityType: ProductEntityType, - channelType: ChannelEntityType, - role: EntityRole, -): PolicyCell => { +const roleReadValue = (policies: PolicyMatrix, entityType: ProductEntityType, channelType: ChannelEntityType, role: EntityRole): PolicyCell => { const entityPolicies = getEntityPolicies(entityType, policies); const permissions = getPolicyPermissions(entityPolicies, channelType, role); return permissions?.read ?? 0; @@ -100,20 +95,9 @@ const resolveScopes = ( conditional: new Map(), }; - const addConditional = ( - condition: RowConditionName, - channelId: string | null, - channelType?: ChannelEntityType, - homeOnly = false, - ) => { + const addConditional = (condition: RowConditionName, channelId: string | null, channelType?: ChannelEntityType, homeOnly = false) => { const key = `${condition}:${channelType ?? ''}:${homeOnly}`; - const entry = acc.conditional.get(key) ?? { - condition, - channelType, - homeOnly, - orgWide: false, - ids: new Set(), - }; + const entry = acc.conditional.get(key) ?? { condition, channelType, homeOnly, orgWide: false, ids: new Set() }; if (channelId === null) entry.orgWide = true; else entry.ids.add(channelId); acc.conditional.set(key, entry); @@ -140,8 +124,7 @@ const resolveScopes = ( if (membership.channelType === 'organization' && membership.channelId === organizationId) { const value = roleReadValue(policies, entityType, 'organization', membership.role); if (value === 1) addUnconditional('organization', membership.role, null); - else if (isRowCondition(value)) - addConditional(value, null, undefined, isHomeScopedGrant('organization', membership.role)); + else if (isRowCondition(value)) addConditional(value, null, undefined, isHomeScopedGrant('organization', membership.role)); continue; } @@ -197,32 +180,21 @@ const deeperChannelsOf = (orderedChannels: readonly ChannelEntityType[], channel return index > 0 ? [...orderedChannels.slice(0, index)] : []; }; -const toConditionalScopes = ( - acc: ScopeAccumulator, - orderedChannels: readonly ChannelEntityType[], -): ConditionalScope[] => { +const toConditionalScopes = (acc: ScopeAccumulator, orderedChannels: readonly ChannelEntityType[]): ConditionalScope[] => { // Org-wide unconditional scope subsumes every conditional slice. if (acc.unconditionalOrgWide) return []; const scopes: ConditionalScope[] = []; for (const { condition, channelType, homeOnly, orgWide, ids } of acc.conditional.values()) { // Home-scoped conditional slices additionally require the deeper columns NULL - const deeper = homeOnly - ? deeperChannelsOf(orderedChannels, channelType ?? (orderedChannels.at(-1) as ChannelEntityType)) - : undefined; + const deeper = homeOnly ? deeperChannelsOf(orderedChannels, channelType ?? (orderedChannels.at(-1) as ChannelEntityType)) : undefined; if (orgWide) { scopes.push({ condition, channelIds: undefined, ...(deeper?.length && { deeperChannels: deeper }) }); continue; } // Intermediate-level slices keep their own id space (scoped by their own column). if (channelType) { - if (ids.size > 0) - scopes.push({ - condition, - channelIds: [...ids], - channelType, - ...(deeper?.length && { deeperChannels: deeper }), - }); + if (ids.size > 0) scopes.push({ condition, channelIds: [...ids], channelType, ...(deeper?.length && { deeperChannels: deeper }) }); continue; } // Ids already unconditionally readable don't need the conditional slice. @@ -247,12 +219,7 @@ const toHomeScopes = (acc: ScopeAccumulator, orderedChannels: readonly ChannelEn const scopes: HomeScope[] = []; for (const [channelType, ids] of acc.homeScoped) { - if (ids.size > 0) - scopes.push({ - channelType, - channelIds: [...ids], - deeperChannels: deeperChannelsOf(orderedChannels, channelType), - }); + if (ids.size > 0) scopes.push({ channelType, channelIds: [...ids], deeperChannels: deeperChannelsOf(orderedChannels, channelType) }); } return scopes; }; @@ -317,24 +284,14 @@ export const resolveCollectionReadFilterForPolicies = ({ // Administrator short-circuit, matching the engine: they may pass the guard without a membership. if (!('anonymous' in actor) && actor.isSystemAdmin) { // A requested home channel still narrows: sysadmin widens WHO can read, never WHAT a filtered list returns. - if (requested?.homeChannelId !== undefined) - return { homeChannelIds: [requested.homeChannelId], conditionalScopes: [] }; - if (requested?.homeChannelIds !== undefined) - return { homeChannelIds: requested.homeChannelIds, conditionalScopes: [] }; + if (requested?.homeChannelId !== undefined) return { homeChannelIds: [requested.homeChannelId], conditionalScopes: [] }; + if (requested?.homeChannelIds !== undefined) return { homeChannelIds: requested.homeChannelIds, conditionalScopes: [] }; return { homeChannelIds: undefined, conditionalScopes: [] }; } const resolvedHierarchy = hierarchy ?? appHierarchy; const orderedChannels = resolvedHierarchy.getOrderedAncestors(entityType) as ChannelEntityType[]; - const acc = resolveScopes( - policies, - memberships, - entityType, - organizationId, - elevatedGrants, - orderedChannels, - publicGrants, - ); + const acc = resolveScopes(policies, memberships, entityType, organizationId, elevatedGrants, orderedChannels, publicGrants); const conditionalScopes = toConditionalScopes(acc, orderedChannels); const homeChannel = orderedChannels.find((channel) => channel !== 'organization') ?? null; const intermediateScopes = toIntermediateScopes(acc); @@ -345,8 +302,7 @@ export const resolveCollectionReadFilterForPolicies = ({ intermediates: IntermediateScope[] = intermediateScopes, homes: HomeScope[] = homeScopes, ): CollectionReadFilter => { - let base: CollectionReadFilter = - intermediates.length > 0 ? { ...filter, intermediateScopes: intermediates } : filter; + let base: CollectionReadFilter = intermediates.length > 0 ? { ...filter, intermediateScopes: intermediates } : filter; if (homes.length > 0) base = { ...base, homeScopes: homes }; return base; }; @@ -388,8 +344,5 @@ export const resolveCollectionReadFilterForPolicies = ({ } // Aggregate read: org-wide for root-level grants, else the readable home channels plus intermediate / home scopes. - return withScopes({ - homeChannelIds: acc.unconditionalOrgWide ? undefined : [...acc.unconditionalIds], - conditionalScopes, - }); + return withScopes({ homeChannelIds: acc.unconditionalOrgWide ? undefined : [...acc.unconditionalIds], conditionalScopes }); }; diff --git a/backend/src/permissions/get-valid-channel.test.ts b/backend/src/permissions/get-valid-channel.test.ts index c759fcb1c..9f8563805 100644 --- a/backend/src/permissions/get-valid-channel.test.ts +++ b/backend/src/permissions/get-valid-channel.test.ts @@ -28,26 +28,18 @@ describe('getValidChannel request scope', () => { it('compares nothing on a cross-tenant route that set no scope', async () => { vi.mocked(resolveEntity).mockResolvedValue(organization as never); - await expect(getValidChannel(ctx(), ORG, 'organization', 'read')).resolves.toEqual({ - entity: organization, - membership: null, - }); + await expect(getValidChannel(ctx(), ORG, 'organization', 'read')).resolves.toEqual({ entity: organization, membership: null }); }); it('reads a foreign-tenant channel as 404 without consulting the engine', async () => { vi.mocked(resolveEntity).mockResolvedValue({ ...organization, tenantId: 'tenant-b' } as never); - await expect(getValidChannel(ctx({ tenantId: TENANT }), ORG, 'organization', 'read')).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(getValidChannel(ctx({ tenantId: TENANT }), ORG, 'organization', 'read')).rejects.toMatchObject({ status: 404, type: 'not_found' }); expect(checkAccess).not.toHaveBeenCalled(); }); it('skips the organization comparison for the organization row, which carries no organizationId', async () => { vi.mocked(resolveEntity).mockResolvedValue(organization as never); - await expect( - getValidChannel(ctx({ tenantId: TENANT, organizationId: ORG }), ORG, 'organization', 'read'), - ).resolves.toEqual({ + await expect(getValidChannel(ctx({ tenantId: TENANT, organizationId: ORG }), ORG, 'organization', 'read')).resolves.toEqual({ entity: organization, membership: null, }); @@ -56,19 +48,17 @@ describe('getValidChannel request scope', () => { it('reads a sub-channel from another organization as 404', async () => { const foreign = { id: 'ch-1', entityType: 'channel', tenantId: TENANT, organizationId: 'org-b' }; vi.mocked(resolveEntity).mockResolvedValue(foreign as never); - await expect( - getValidChannel(ctx({ tenantId: TENANT, organizationId: ORG }), 'ch-1', 'organization', 'read'), - ).rejects.toMatchObject({ status: 404, type: 'not_found' }); + await expect(getValidChannel(ctx({ tenantId: TENANT, organizationId: ORG }), 'ch-1', 'organization', 'read')).rejects.toMatchObject({ + status: 404, + type: 'not_found', + }); }); it('reads a channel the engine denies `read` on as 404, whatever the action asked', async () => { vi.mocked(resolveEntity).mockResolvedValue(organization as never); vi.mocked(checkAccess).mockReturnValue({ allowed: false, membership: null } as ReturnType); for (const action of ['read', 'update'] as const) { - await expect(getValidChannel(ctx({ tenantId: TENANT }), ORG, 'organization', action)).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(getValidChannel(ctx({ tenantId: TENANT }), ORG, 'organization', action)).rejects.toMatchObject({ status: 404, type: 'not_found' }); } }); @@ -92,14 +82,8 @@ describe('getValidChannel request scope', () => { it('resolveChannelInScope reads a missing and a foreign-tenant row as the same 404', async () => { vi.mocked(resolveEntity).mockResolvedValue(undefined as never); - await expect(resolveChannelInScope(ctx({ tenantId: TENANT }), ORG, 'organization')).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(resolveChannelInScope(ctx({ tenantId: TENANT }), ORG, 'organization')).rejects.toMatchObject({ status: 404, type: 'not_found' }); vi.mocked(resolveEntity).mockResolvedValue({ ...organization, tenantId: 'tenant-b' } as never); - await expect(resolveChannelInScope(ctx({ tenantId: TENANT }), ORG, 'organization')).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(resolveChannelInScope(ctx({ tenantId: TENANT }), ORG, 'organization')).rejects.toMatchObject({ status: 404, type: 'not_found' }); }); }); diff --git a/backend/src/permissions/get-valid-product.test.ts b/backend/src/permissions/get-valid-product.test.ts index f82956040..111f335dd 100644 --- a/backend/src/permissions/get-valid-product.test.ts +++ b/backend/src/permissions/get-valid-product.test.ts @@ -7,9 +7,7 @@ import { checkAccess } from '#/permissions'; import { getValidProduct } from '#/permissions/get-valid-product'; vi.mock('#/db/db', () => ({ baseDb: { kind: 'baseDb' } })); -vi.mock('#/db/tenant-context', () => ({ - tenantRead: vi.fn((ctx: UserContext, fn: (readCtx: UserContext) => unknown) => fn(ctx)), -})); +vi.mock('#/db/tenant-context', () => ({ tenantRead: vi.fn((ctx: UserContext, fn: (readCtx: UserContext) => unknown) => fn(ctx)) })); vi.mock('#/modules/entities/entities-queries', () => ({ resolveEntity: vi.fn() })); vi.mock('#/permissions', () => ({ checkAccess: vi.fn() })); vi.mock('#/permissions/access', () => ({ accessFrom: vi.fn(() => ({})) })); @@ -20,9 +18,7 @@ const ORG = 'org-a'; /** Scope check unit: the entity lookup, permission engine and RLS wrapper are mocked, so only the tenant/organization comparison is under test. */ describe('getValidProduct request scope', () => { - const ctx = ( - scope: Partial<{ tenantId: string; organizationId: string }> = { tenantId: TENANT, organizationId: ORG }, - ) => + const ctx = (scope: Partial<{ tenantId: string; organizationId: string }> = { tenantId: TENANT, organizationId: ORG }) => ({ var: { db: baseDb, @@ -58,41 +54,27 @@ describe('getValidProduct request scope', () => { it('reads a foreign-tenant row as 404 even when the engine would allow it', async () => { vi.mocked(resolveEntity).mockResolvedValue(row({ tenantId: 'tenant-b' }) as never); - await expect(getValidProduct(ctx(), 'att-1', 'attachment', 'read')).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(getValidProduct(ctx(), 'att-1', 'attachment', 'read')).rejects.toMatchObject({ status: 404, type: 'not_found' }); expect(checkAccess).not.toHaveBeenCalled(); }); it('reads a foreign-organization row as 404', async () => { vi.mocked(resolveEntity).mockResolvedValue(row({ organizationId: 'org-b' }) as never); - await expect(getValidProduct(ctx(), 'att-1', 'attachment', 'read')).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(getValidProduct(ctx(), 'att-1', 'attachment', 'read')).rejects.toMatchObject({ status: 404, type: 'not_found' }); expect(checkAccess).not.toHaveBeenCalled(); }); it('returns 403 when the row is in scope and readable but the engine denies the action', async () => { vi.mocked(resolveEntity).mockResolvedValue(row() as never); - vi.mocked(checkAccess).mockImplementation( - (_access, action) => ({ allowed: action === 'read' }) as ReturnType, - ); - await expect(getValidProduct(ctx(), 'att-1', 'attachment', 'update')).rejects.toMatchObject({ - status: 403, - type: 'forbidden', - }); + vi.mocked(checkAccess).mockImplementation((_access, action) => ({ allowed: action === 'read' }) as ReturnType); + await expect(getValidProduct(ctx(), 'att-1', 'attachment', 'update')).rejects.toMatchObject({ status: 403, type: 'forbidden' }); }); it('reads a row the engine denies reading as 404, whatever the action', async () => { vi.mocked(resolveEntity).mockResolvedValue(row() as never); vi.mocked(checkAccess).mockReturnValue({ allowed: false } as ReturnType); for (const action of ['read', 'update', 'delete'] as const) { - await expect(getValidProduct(ctx(), 'att-1', 'attachment', action)).rejects.toMatchObject({ - status: 404, - type: 'not_found', - }); + await expect(getValidProduct(ctx(), 'att-1', 'attachment', action)).rejects.toMatchObject({ status: 404, type: 'not_found' }); } }); diff --git a/backend/src/permissions/row-predicates.test.ts b/backend/src/permissions/row-predicates.test.ts index 5ca4ab7b7..4e0dd3173 100644 --- a/backend/src/permissions/row-predicates.test.ts +++ b/backend/src/permissions/row-predicates.test.ts @@ -15,13 +15,7 @@ import { type SubjectForPermission, toColumnName, } from 'shared'; -import { - type DeepChannelType, - deepChannelRoles, - deepHierarchy, - deepOverrides, - deepReadPolicies as deepPolicies, -} from 'shared/testing/deep-fixture'; +import { type DeepChannelType, deepChannelRoles, deepHierarchy, deepOverrides, deepReadPolicies as deepPolicies } from 'shared/testing/deep-fixture'; import { elevateAcross } from 'shared/testing/elevate'; import { configurePolicyMatrix } from 'shared/testing/policies'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -71,9 +65,7 @@ const parityTable = pgTable( homeIdKey && homeColumnName ? { ...baseColumns, [homeIdKey]: varchar(homeColumnName).notNull() } : baseColumns, ); // The home-channel column passed to `buildCollectionReadWhere`; never referenced on an org-only app, where `id` stands in. -const homeChannelColumn = ( - homeIdKey ? (parityTable as unknown as Record)[homeIdKey] : parityTable.id -) as PgColumn; +const homeChannelColumn = (homeIdKey ? (parityTable as unknown as Record)[homeIdKey] : parityTable.id) as PgColumn; const USERS = ['u1', 'u2'] as const; @@ -142,9 +134,7 @@ interface Scenario { } const scenarioActor = (scenario: Scenario): PredicateActor => - scenario.userId === undefined - ? { anonymous: true } - : { actorId: scenario.userId, isSystemAdmin: scenario.isSystemAdmin, scopes: null }; + scenario.userId === undefined ? { anonymous: true } : { actorId: scenario.userId, isSystemAdmin: scenario.isSystemAdmin, scopes: null }; const membership = (channelType: ChannelEntityType, channelId: string, role: string): MembershipBaseModel => ({ @@ -291,9 +281,7 @@ describe('row-condition parity: engine check ⊆⊇ compiled SQL ⊆⊇ compute- actorId: scenario.userId, elevatedGrants: hierarchy.elevatedGrants, }); - expect(resolved, `${label}; membership ${m.channelType}:${m.channelId}:${m.role}; row ${row.id}`).toBe( - can.read, - ); + expect(resolved, `${label}; membership ${m.channelType}:${m.channelId}:${m.role}; row ${row.id}`).toBe(can.read); } } } @@ -341,9 +329,7 @@ describe('row-condition parity: engine check ⊆⊇ compiled SQL ⊆⊇ compute- // Narrowed SQL result == engine-readable rows of the requested home-channel. const fromEngine = engineReadableIds(scenario); - const expected = new Set( - ROWS.filter((r) => r.homeChannelId === requestedHomeChannel && fromEngine.has(r.id)).map((r) => r.id), - ); + const expected = new Set(ROWS.filter((r) => r.homeChannelId === requestedHomeChannel && fromEngine.has(r.id)).map((r) => r.id)); expect(fromSqlAll, `seed 0xbee5 scenario ${i} home-channel ${requestedHomeChannel}`).toEqual(expected); } }); @@ -412,19 +398,13 @@ interface DeepScenario { const randomDeepScenario = (random: () => number): DeepScenario => { const memberships: MembershipBaseModel[] = []; - if (random() < 0.5) - memberships.push(deepMembership('organization', ROOT_ID, pick(random, deepChannelRoles.organization))); + if (random() < 0.5) memberships.push(deepMembership('organization', ROOT_ID, pick(random, deepChannelRoles.organization))); if (random() < 0.5) memberships.push(deepMembership('course', 'c1', pick(random, deepChannelRoles.course))); if (random() < 0.3) memberships.push(deepMembership('course', 'c2', pick(random, deepChannelRoles.course))); - if (random() < 0.5) - memberships.push(deepMembership('courseSection', 's1', pick(random, deepChannelRoles.courseSection))); + if (random() < 0.5) memberships.push(deepMembership('courseSection', 's1', pick(random, deepChannelRoles.courseSection))); if (random() < 0.5) memberships.push(deepMembership('project', 'p1', pick(random, deepChannelRoles.project))); if (random() < 0.3) memberships.push(deepMembership('project', 'p3', pick(random, deepChannelRoles.project))); - return { - policies: deepPolicies(() => randomReadValue(random)), - memberships, - userId: random() < 0.9 ? pick(random, USERS) : undefined, - }; + return { policies: deepPolicies(() => randomReadValue(random)), memberships, userId: random() < 0.9 ? pick(random, USERS) : undefined }; }; const deepRowSubject = (row: DeepParityRow): SubjectForPermission => @@ -432,12 +412,7 @@ const deepRowSubject = (row: DeepParityRow): SubjectForPermission => entityType: 'item', id: row.id, createdBy: row.createdBy, - channelIds: { - organization: ROOT_ID, - course: row.courseId, - courseSection: row.courseSectionId, - project: row.projectId, - }, + channelIds: { organization: ROOT_ID, course: row.courseId, courseSection: row.courseSectionId, project: row.projectId }, }) as unknown as SubjectForPermission; const deepEngineReadableIds = (scenario: DeepScenario, elevatedGrants?: ReadonlySet): Set => { @@ -455,14 +430,9 @@ const deepEngineReadableIds = (scenario: DeepScenario, elevatedGrants?: Readonly /** The deep scenario's actor. Deep chains exercise scope, not the admin bypass. */ const deepActor = (scenario: DeepScenario): PredicateActor => - scenario.userId === undefined - ? { anonymous: true } - : { actorId: scenario.userId, isSystemAdmin: false, scopes: null }; - -const deepSqlReadableIds = async ( - scenario: DeepScenario, - elevatedGrants?: ReadonlySet, -): Promise> => { + scenario.userId === undefined ? { anonymous: true } : { actorId: scenario.userId, isSystemAdmin: false, scopes: null }; + +const deepSqlReadableIds = async (scenario: DeepScenario, elevatedGrants?: ReadonlySet): Promise> => { const filter = resolveCollectionReadFilterForPolicies({ policies: scenario.policies, memberships: scenario.memberships, @@ -569,11 +539,7 @@ describe('elevatedGrants parity: home-scoped grants agree between engine and SQL userId: 'u1', }; // Rows homed at c1 itself, excluding section/project rows physically below it. - const expected = new Set( - DEEP_ROWS.filter((r) => r.courseId === 'c1' && r.courseSectionId === null && r.projectId === null).map( - (r) => r.id, - ), - ); + const expected = new Set(DEEP_ROWS.filter((r) => r.courseId === 'c1' && r.courseSectionId === null && r.projectId === null).map((r) => r.id)); expect(deepEngineReadableIds(scenario, SUBTREE_ROLES)).toEqual(expected); expect(await deepSqlReadableIds(scenario, SUBTREE_ROLES)).toEqual(expected); }); @@ -597,9 +563,7 @@ describe('elevatedGrants parity: home-scoped grants agree between engine and SQL userId: 'u1', }; const expected = new Set( - DEEP_ROWS.filter( - (r) => r.courseId === 'c1' && r.courseSectionId === null && r.projectId === null && r.createdBy === 'u1', - ).map((r) => r.id), + DEEP_ROWS.filter((r) => r.courseId === 'c1' && r.courseSectionId === null && r.projectId === null && r.createdBy === 'u1').map((r) => r.id), ); expect(deepEngineReadableIds(scenario, SUBTREE_ROLES)).toEqual(expected); expect(await deepSqlReadableIds(scenario, SUBTREE_ROLES)).toEqual(expected); @@ -607,12 +571,7 @@ describe('elevatedGrants parity: home-scoped grants agree between engine and SQL }); // Real-config scenarios must agree across collection SQL, single-row checks, and SSE dispatch. -const realMembership = ( - channelType: ChannelEntityType, - channelId: string, - role: string, - organizationId: string, -): MembershipBaseModel => +const realMembership = (channelType: ChannelEntityType, channelId: string, role: string, organizationId: string): MembershipBaseModel => ({ id: `mem-${channelType}-${channelId}-${role}`, userId: 'actor', @@ -622,26 +581,18 @@ const realMembership = ( role, }) as unknown as MembershipBaseModel; -const randomRealScenario = ( - random: () => number, -): { memberships: MembershipBaseModel[]; userId: string; isSystemAdmin: boolean } => { +const randomRealScenario = (random: () => number): { memberships: MembershipBaseModel[]; userId: string; isSystemAdmin: boolean } => { const memberships: MembershipBaseModel[] = []; if (random() < 0.5) memberships.push(realMembership(ROOT, ROOT_ID, pick(random, hierarchy.getRoles(ROOT)), ROOT_ID)); // A grant in a DIFFERENT org must contribute nothing to this org's collection - if (random() < 0.3) - memberships.push(realMembership(ROOT, 'org-other', pick(random, hierarchy.getRoles(ROOT)), 'org-other')); + if (random() < 0.3) memberships.push(realMembership(ROOT, 'org-other', pick(random, hierarchy.getRoles(ROOT)), 'org-other')); if (HOME) { for (const subId of HOME_INSTANCES) { - if (random() < 0.4) - memberships.push(realMembership(HOME, subId, pick(random, hierarchy.getRoles(HOME)), ROOT_ID)); + if (random() < 0.4) memberships.push(realMembership(HOME, subId, pick(random, hierarchy.getRoles(HOME)), ROOT_ID)); } } // SSE subscribers are always authenticated; 'outsider' stands in for a user with no rows - return { - memberships, - userId: random() < 0.85 ? pick(random, USERS) : 'outsider', - isSystemAdmin: random() < 0.15, - }; + return { memberships, userId: random() < 0.85 ? pick(random, USERS) : 'outsider', isSystemAdmin: random() < 0.15 }; }; /** Channel id columns as they appear on an activity event (and its row). */ @@ -681,20 +632,12 @@ describe('three-way mirror parity: SQL ≍ engine ≍ dispatch under the real ap const filter = resolveCollectionReadFilter(memberships, 'attachment', ROOT_ID, actor); const where = buildCollectionReadWhere(filter, parityTable, homeChannelColumn, actor); const query = seedDb.select({ id: parityTable.id }).from(parityTable); - const fromSql = new Set( - where.kind === 'none' - ? [] - : (where.kind === 'all' ? await query : await query.where(where.where)).map((r) => r.id), - ); + const fromSql = new Set(where.kind === 'none' ? [] : (where.kind === 'all' ? await query : await query.where(where.where)).map((r) => r.id)); for (const row of ROWS) { // Same subject shape dispatch builds: ancestor scope + the row itself const subject = rowSubject(row); - const engineAllowed = checkAccess( - { actorId: userId, isSystemAdmin, memberships, scopes: null }, - 'read', - subject, - ).allowed; + const engineAllowed = checkAccess({ actorId: userId, isSystemAdmin, memberships, scopes: null }, 'read', subject).allowed; const dispatchAllowed = canReceiveProductEvent({ userId, isSystemAdmin, memberships }, dispatchEvent(row)); expect(dispatchAllowed, `${label} → row ${row.id} dispatch-vs-engine`).toBe(engineAllowed); @@ -720,15 +663,9 @@ describe('three-way mirror parity: SQL ≍ engine ≍ dispatch under the real ap rowData.publishedAt = null; const publishedEvent = { ...draftEvent, rowData: { ...rowData, publishedAt: PUBLIC_AT } }; - const engineAllowed = checkAccess( - { actorId: userId, isSystemAdmin, memberships, scopes: null }, - 'read', - rowSubject(row), - ).allowed; + const engineAllowed = checkAccess({ actorId: userId, isSystemAdmin, memberships, scopes: null }, 'read', rowSubject(row)).allowed; // A published row dispatches exactly like the engine decides; the same row as a draft never dispatches. - expect(canReceiveProductEvent(subscriber, publishedEvent), `${label} → row ${row.id} published`).toBe( - engineAllowed, - ); + expect(canReceiveProductEvent(subscriber, publishedEvent), `${label} → row ${row.id} published`).toBe(engineAllowed); expect(canReceiveProductEvent(subscriber, draftEvent), `${label} → row ${row.id} draft`).toBe(false); } } diff --git a/backend/src/permissions/row-predicates.ts b/backend/src/permissions/row-predicates.ts index 39b128571..458456a35 100644 --- a/backend/src/permissions/row-predicates.ts +++ b/backend/src/permissions/row-predicates.ts @@ -9,9 +9,7 @@ const NEVER: SQL = sql`false`; const resolveColumn = (table: AnyPgTable, columnName: string, conditionName: string): PgColumn => { const column = (table as unknown as Record)[columnName]; if (!column) { - throw new Error( - `[Permission] Row condition "${conditionName}" reads column "${columnName}" which does not exist on the queried table`, - ); + throw new Error(`[Permission] Row condition "${conditionName}" reads column "${columnName}" which does not exist on the queried table`); } return column; }; @@ -73,10 +71,8 @@ export const buildCollectionReadWhere = ( // HOME-scoped grants (non-elevated): the grant level's column matches AND every deeper ancestor column is NULL. for (const { channelType, channelIds, deeperChannels } of filter.homeScopes ?? []) { if (channelIds.length === 0) continue; - const scoped = and( - inArray(scopeColumn(channelType), channelIds), - ...deeperChannels.map((deeper) => isNull(scopeColumn(deeper))), - ); + const deeperNulls = deeperChannels.map((deeper) => isNull(scopeColumn(deeper))); + const scoped = and(inArray(scopeColumn(channelType), channelIds), ...deeperNulls); if (scoped) clauses.push(scoped); } diff --git a/backend/src/permissions/view-read-status.test.ts b/backend/src/permissions/view-read-status.test.ts index f02ab65f1..6fa77eca4 100644 --- a/backend/src/permissions/view-read-status.test.ts +++ b/backend/src/permissions/view-read-status.test.ts @@ -1,10 +1,5 @@ import type { PolicyCellInput, ProductEntityType } from 'shared'; -import { - type DeepChannelType, - deepHierarchy, - deepOverrides, - deepReadPolicies as policies, -} from 'shared/testing/deep-fixture'; +import { type DeepChannelType, deepHierarchy, deepOverrides, deepReadPolicies as policies } from 'shared/testing/deep-fixture'; import { elevateAcross } from 'shared/testing/elevate'; import { describe, expect, it } from 'vitest'; @@ -58,14 +53,10 @@ const statusFor = ( ); describe('resolveViewReadStatus', () => { - const orgAdminRead = (ct: DeepChannelType, role: string): PolicyCellInput => - ct === 'organization' && role === 'admin' ? 1 : 0; - const courseStaffRead = (ct: DeepChannelType, role: string): PolicyCellInput => - ct === 'course' && role === 'staff' ? 1 : 0; - const projectOwnerRead = (ct: DeepChannelType, role: string): PolicyCellInput => - ct === 'project' && role === 'owner' ? 1 : 0; - const orgMemberOwnRead = (ct: DeepChannelType, role: string): PolicyCellInput => - ct === 'organization' && role === 'member' ? 'own' : 0; + const orgAdminRead = (ct: DeepChannelType, role: string): PolicyCellInput => (ct === 'organization' && role === 'admin' ? 1 : 0); + const courseStaffRead = (ct: DeepChannelType, role: string): PolicyCellInput => (ct === 'course' && role === 'staff' ? 1 : 0); + const projectOwnerRead = (ct: DeepChannelType, role: string): PolicyCellInput => (ct === 'project' && role === 'owner' ? 1 : 0); + const orgMemberOwnRead = (ct: DeepChannelType, role: string): PolicyCellInput => (ct === 'organization' && role === 'member' ? 'own' : 0); it('org-wide unconditional read answers the org and every verified prefix in it', () => { const opts = { read: orgAdminRead, memberships: [membership('organization', ROOT_ID, 'admin')] }; @@ -116,13 +107,8 @@ describe('resolveViewReadStatus', () => { it('SELF views: a home-scoped grant (non-elevated under elevatedGrants) answers its own node', () => { // Course student read=1 with elevatedGrants configured: the home-scoped grant covers exactly the course wall (rows homed at c1). - const courseStudentRead = (ct: DeepChannelType, role: string): PolicyCellInput => - ct === 'course' && role === 'student' ? 1 : 0; - const opts = { - read: courseStudentRead, - memberships: [membership('course', 'c1', 'student')], - elevatedGrants: DEEP_ELEVATED, - }; + const courseStudentRead = (ct: DeepChannelType, role: string): PolicyCellInput => (ct === 'course' && role === 'student' ? 1 : 0); + const opts = { read: courseStudentRead, memberships: [membership('course', 'c1', 'student')], elevatedGrants: DEEP_ELEVATED }; // Self view on the granted node: provable because homed rows are exactly the grant. expect(statusFor(`${ROOT_ID}/c1`, { ...opts, depth: 'self' })).toBe('ok'); @@ -133,11 +119,7 @@ describe('resolveViewReadStatus', () => { }); it('SELF views: subtree-scoped proofs still apply (self ⊂ subtree)', () => { - const opts = { - read: courseStaffRead, - memberships: [membership('course', 'c1', 'staff')], - elevatedGrants: DEEP_ELEVATED, - }; + const opts = { read: courseStaffRead, memberships: [membership('course', 'c1', 'staff')], elevatedGrants: DEEP_ELEVATED }; expect(statusFor(`${ROOT_ID}/c1`, { ...opts, depth: 'self' })).toBe('ok'); }); @@ -164,13 +146,8 @@ describe('resolveViewReadStatus', () => { }); it('VERIFIED ancestry: ancestor HOME-grants still never prove deeper self views', () => { - const courseStudentRead = (ct: DeepChannelType, role: string): PolicyCellInput => - ct === 'course' && role === 'student' ? 1 : 0; - const opts = { - read: courseStudentRead, - memberships: [membership('course', 'c1', 'student')], - elevatedGrants: DEEP_ELEVATED, - }; + const courseStudentRead = (ct: DeepChannelType, role: string): PolicyCellInput => (ct === 'course' && role === 'student' ? 1 : 0); + const opts = { read: courseStudentRead, memberships: [membership('course', 'c1', 'student')], elevatedGrants: DEEP_ELEVATED }; const deep = `${ROOT_ID}/c1/s1/p1`; // The student's course home-grant covers the course WALL, not project walls below. expect(statusFor(deep, { ...opts, depth: 'self', truePath: deep })).toBe('opaque'); @@ -178,9 +155,7 @@ describe('resolveViewReadStatus', () => { it('no read route at all is forbidden, as is a prefix outside the org', () => { expect(statusFor(`${ROOT_ID}/c1`, {})).toBe('forbidden'); - expect( - statusFor('other-org/c1', { read: orgAdminRead, memberships: [membership('organization', ROOT_ID, 'admin')] }), - ).toBe('forbidden'); + expect(statusFor('other-org/c1', { read: orgAdminRead, memberships: [membership('organization', ROOT_ID, 'admin')] })).toBe('forbidden'); expect(statusFor('', {})).toBe('forbidden'); }); }); diff --git a/backend/src/permissions/view-read-status.ts b/backend/src/permissions/view-read-status.ts index fbe504868..6ab2fcafb 100644 --- a/backend/src/permissions/view-read-status.ts +++ b/backend/src/permissions/view-read-status.ts @@ -25,13 +25,8 @@ export function resolveViewReadStatus( depth: ViewDepth = 'subtree', truePath?: string | null, ): ViewReadStatus { - return classifyPrefix( - prefix, - organizationId, - resolveCollectionReadFilter(memberships, entityType, organizationId, actor), - depth, - truePath, - ); + const filter = resolveCollectionReadFilter(memberships, entityType, organizationId, actor); + return classifyPrefix(prefix, organizationId, filter, depth, truePath); } /** {@link resolveViewReadStatus} against an explicit policy set / hierarchy, for deep-hierarchy parity tests. */ @@ -77,8 +72,7 @@ function classifyPrefix( // Home-level unconditional grant (deepest level: covers its subtree). if (provableIds.some((id) => filter.homeChannelIds?.includes(id))) return 'ok'; // Unconditional grant at an intermediate ancestor level (subtree-scoped: elevated). - if (filter.intermediateScopes?.some((scope) => provableIds.some((id) => scope.channelIds.includes(id)))) - return 'ok'; + if (filter.intermediateScopes?.some((scope) => provableIds.some((id) => scope.channelIds.includes(id)))) return 'ok'; } // A self view accepts only an unconditional home grant on that exact node: ancestor home grants do not prove descendants. diff --git a/backend/src/schemas/api-error-mocks.ts b/backend/src/schemas/api-error-mocks.ts index 198822d08..0dc52a248 100644 --- a/backend/src/schemas/api-error-mocks.ts +++ b/backend/src/schemas/api-error-mocks.ts @@ -1,24 +1,9 @@ -interface ApiError { - name: string; - message: string; - type: string; - status: number; - severity: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; - entityType?: string; - logId?: string; - path?: string; - method?: string; - timestamp?: string; - userId?: string; - organizationId?: string; -} - /** Messages are translation keys from locales/en/error.json. */ -export const mockApiError = (status = 400): ApiError => ({ +export const mockApiError = (status = 400) => ({ name: 'BadRequestError', message: 'error:bad_request_action', type: 'validation_error', status, - severity: 'warn', + severity: 'warn' as const, timestamp: '2025-01-01T12:00:00.000Z', }); diff --git a/backend/src/schemas/api-error-schemas.ts b/backend/src/schemas/api-error-schemas.ts index 35cf86986..98ee897fd 100644 --- a/backend/src/schemas/api-error-schemas.ts +++ b/backend/src/schemas/api-error-schemas.ts @@ -4,14 +4,7 @@ import { schemaTags } from '#/core/openapi-helpers'; import { mockApiError } from './api-error-mocks'; import { entityTypeSchema } from './common-schemas'; -export const severityLevels = [ - 'fatal', - 'error', - 'warn', - 'info', - 'debug', - 'trace', -] as const satisfies readonly Severity[]; +export const severityLevels = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const satisfies readonly Severity[]; /** OpenAPI represents this as a number with min and max. */ const errorStatusCodeSchema = z @@ -30,17 +23,15 @@ export const apiErrorSchema = z status: errorStatusCodeSchema, severity: z.enum(severityLevels), entityType: entityTypeSchema.optional(), - logId: z.string().optional(), - /** Request id, also sent as the `X-Request-Id` response header; quote it when reporting a failure. */ + /** Request id, also sent as the `X-Request-Id` response header and logged with the request; quote it when reporting a failure. */ requestId: z.string().optional(), path: z.string().optional(), method: z.string().optional(), timestamp: z.string().optional(), userId: z.string().optional(), organizationId: z.string().optional(), - meta: z - .record(z.string(), z.union([z.number(), z.string(), z.array(z.string()), z.boolean(), z.null()])) - .optional(), // Optional structured metadata (e.g. retryAfter, slug, reason) + /** Optional structured metadata (e.g. retryAfter, slug, reason). */ + meta: z.record(z.string(), z.union([z.number(), z.string(), z.array(z.string()), z.boolean(), z.null()])).optional(), }) .openapi('ApiError', { description: 'Standard error response returned by all API endpoints.', diff --git a/backend/src/schemas/app-schemas.ts b/backend/src/schemas/app-schemas.ts index 355d19f38..82d9efb7d 100644 --- a/backend/src/schemas/app-schemas.ts +++ b/backend/src/schemas/app-schemas.ts @@ -29,9 +29,7 @@ export const setupConfigSchema = z.object({ .array(primaryLabelDefinitionSchema) .min(primaryLabelLimits.min) .max(primaryLabelLimits.max) - .refine((entries) => new Set(entries.map((e) => e.slug)).size === entries.length, { - message: 'Duplicate primary label slugs', - }), + .refine((entries) => new Set(entries.map((e) => e.slug)).size === entries.length, { message: 'Duplicate primary label slugs' }), }); /** diff --git a/backend/src/schemas/channel-included.ts b/backend/src/schemas/channel-included.ts index cc7c0b8a4..24cd53755 100644 --- a/backend/src/schemas/channel-included.ts +++ b/backend/src/schemas/channel-included.ts @@ -13,9 +13,7 @@ export const channelIncludedSchema = (entityType: ChannelEntityType) => { // Per product descendant, epoch ms of the latest post and the latest content update; null when never. const productDescendants = descendants.filter((descendant) => isProduct(descendant)); const activitySchema = z.object( - recordFromKeys(productDescendants, () => - z.object({ created: z.number().nullable(), updated: z.number().nullable() }), - ), + recordFromKeys(productDescendants, () => z.object({ created: z.number().nullable(), updated: z.number().nullable() })), ); const countsSchema = z.object({ diff --git a/backend/src/schemas/common-schemas.test.ts b/backend/src/schemas/common-schemas.test.ts index 4c7d0fe3f..161630cf3 100644 --- a/backend/src/schemas/common-schemas.test.ts +++ b/backend/src/schemas/common-schemas.test.ts @@ -31,17 +31,11 @@ describe('booleanTransformSchema', () => { describe('paginationQuerySchema', () => { it('applies pagination defaults when parameters are absent', () => { - expect(paginationQuerySchema.parse({})).toMatchObject({ - offset: 0, - limit: appConfig.requestLimits.default, - }); + expect(paginationQuerySchema.parse({})).toMatchObject({ offset: 0, limit: appConfig.requestLimits.default }); }); it('parses complete unsigned integer strings', () => { - expect(paginationQuerySchema.parse({ offset: '12', limit: '39' })).toMatchObject({ - offset: 12, - limit: 39, - }); + expect(paginationQuerySchema.parse({ offset: '12', limit: '39' })).toMatchObject({ offset: 12, limit: 39 }); }); it.each([ @@ -64,12 +58,9 @@ describe('paginationQuerySchema', () => { expect(paginationQuerySchema.parse({ seqCursor: '51,150' }).seqCursor).toBe('51,150'); }); - it.each(['51', '51,', 'a,150', '151,150', '0,9007199254740992'])( - 'rejects invalid sequence cursor %s', - (seqCursor) => { - expect(paginationQuerySchema.safeParse({ seqCursor }).success).toBe(false); - }, - ); + it.each(['51', '51,', 'a,150', '151,150', '0,9007199254740992'])('rejects invalid sequence cursor %s', (seqCursor) => { + expect(paginationQuerySchema.safeParse({ seqCursor }).success).toBe(false); + }); }); describe('normalized input schemas', () => { @@ -95,12 +86,8 @@ describe('normalized input schemas', () => { describe('validUrlSchema', () => { it('lowercases the scheme and host only: userinfo, path, query and fragment keep their case', () => { - expect(validUrlSchema.parse('https://Example.COM/Path/To?Q=Mixed#Frag')).toBe( - 'https://example.com/Path/To?Q=Mixed#Frag', - ); - expect(validUrlSchema.parse('https://User:Pass@Docs.Example.com:8443/A?b=C ')).toBe( - 'https://User:Pass@docs.example.com:8443/A?b=C', - ); + expect(validUrlSchema.parse('https://Example.COM/Path/To?Q=Mixed#Frag')).toBe('https://example.com/Path/To?Q=Mixed#Frag'); + expect(validUrlSchema.parse('https://User:Pass@Docs.Example.com:8443/A?b=C ')).toBe('https://User:Pass@docs.example.com:8443/A?b=C'); expect(validUrlSchema.parse('https://EXAMPLE.com')).toBe('https://example.com'); }); @@ -114,15 +101,9 @@ describe('validation messages', () => { it('translates a message when a value fails, after i18n initialized', () => { expect(messageOf(paginationQuerySchema.safeParse({ offset: 'x' }))).toBe(i18n.t('error:invalid_offset')); - expect(messageOf(paginationQuerySchema.safeParse({ limit: '0' }))).toBe( - i18n.t('error:invalid_limit', { max: 1000 }), - ); - expect(messageOf(validNameSchema.safeParse('x'))).toBe( - i18n.t('error:invalid_between_num', { name: 'Name', min: 2, max: 255 }), - ); + expect(messageOf(paginationQuerySchema.safeParse({ limit: '0' }))).toBe(i18n.t('error:invalid_limit', { max: 1000 })); + expect(messageOf(validNameSchema.safeParse('x'))).toBe(i18n.t('error:invalid_between_num', { name: 'Name', min: 2, max: 255 })); expect(messageOf(validUrlSchema.safeParse('http://example.com'))).toBe(i18n.t('error:invalid_url')); - expect(messageOf(idsBodySchema().safeParse({ ids: [] }))).toBe( - i18n.t('error:invalid_min_items', { min: 'one', name: 'ID' }), - ); + expect(messageOf(idsBodySchema().safeParse({ ids: [] }))).toBe(i18n.t('error:invalid_min_items', { min: 'one', name: 'ID' })); }); }); diff --git a/backend/src/schemas/common-schemas.ts b/backend/src/schemas/common-schemas.ts index af776a2e4..8141fde14 100644 --- a/backend/src/schemas/common-schemas.ts +++ b/backend/src/schemas/common-schemas.ts @@ -2,19 +2,15 @@ import { z } from '@hono/zod-openapi'; import { type TOptions, t } from 'i18next'; import { appConfig } from 'shared'; import { isCDNUrl } from 'shared/utils/is-cdn-url'; -import { schemaTags } from '#/core/openapi-helpers'; import { maxLength } from '#/db/utils/constraints'; export { maxLength }; +// Unnamed so it inlines into each query param and never surfaces as a component schema in the docs. export const booleanTransformSchema = z .union([z.enum(['true', 'false']), z.boolean()]) .default('false') - .transform((value) => value === true || value === 'true') - .openapi('BooleanQueryValue', { - description: 'Boolean query value accepted as a boolean or its lowercase string representation.', - 'x-tags': schemaTags('base', 'cella'), - }); + .transform((value) => value === true || value === 'true'); export const entityTypeSchema = z.enum(appConfig.entityTypes); @@ -48,14 +44,9 @@ export const entityIdParamSchema = z.object({ id: validIdSchema }); /** True resolves the entity by slug, not by ID. */ export const slugQuerySchema = z.object({ slug: booleanTransformSchema.optional() }); -export const tenantIdParamSchema = z.object({ - tenantId: validIdSchema, - id: validIdSchema, -}); +export const tenantIdParamSchema = z.object({ tenantId: validIdSchema, id: validIdSchema }); -export const tenantOnlyParamSchema = z.object({ - tenantId: validIdSchema, -}); +export const tenantOnlyParamSchema = z.object({ tenantId: validIdSchema }); export const inOrgParamSchema = z.object({ organizationId: validIdSchema }); @@ -63,27 +54,14 @@ export const idInOrgParamSchema = z.object({ id: validIdSchema, organizationId: // Tenant-scoped param schemas (for RLS-enabled routes) -export const tenantOrgParamSchema = z.object({ - tenantId: validIdSchema, - organizationId: validIdSchema, -}); +export const tenantOrgParamSchema = z.object({ tenantId: validIdSchema, organizationId: validIdSchema }); -export const idInTenantOrgParamSchema = z.object({ - tenantId: validIdSchema, - organizationId: validIdSchema, - id: validIdSchema, -}); +export const idInTenantOrgParamSchema = z.object({ tenantId: validIdSchema, organizationId: validIdSchema, id: validIdSchema }); -export const userIdInTenantOrgParamSchema = z.object({ - tenantId: validIdSchema, - organizationId: validIdSchema, - userId: validIdSchema, -}); +export const userIdInTenantOrgParamSchema = z.object({ tenantId: validIdSchema, organizationId: validIdSchema, userId: validIdSchema }); /** Cross-tenant routes with a relatability check. */ -export const relatableUserIdParamSchema = z.object({ - relatableUserId: validIdSchema, -}); +export const relatableUserIdParamSchema = z.object({ relatableUserId: validIdSchema }); export const entityWithTypeQuerySchema = z.object({ entityId: validIdSchema, entityType: channelEntityTypeSchema }); @@ -113,10 +91,10 @@ export const paginationQuerySchema = z.object({ sort: z.enum(['createdAt']).default('createdAt'), order: z.enum(['asc', 'desc']).default('desc'), offset: integerQuerySchema(0, translatedError('error:invalid_offset')), - limit: integerQuerySchema( - appConfig.requestLimits.default, + limit: integerQuerySchema(appConfig.requestLimits.default, translatedError('error:invalid_limit', { max: limitMax })).refine( + (value) => value > 0 && value <= limitMax, translatedError('error:invalid_limit', { max: limitMax }), - ).refine((value) => value > 0 && value <= limitMax, translatedError('error:invalid_limit', { max: limitMax })), + ), /** Org-sequence delta filter: bounded inclusive range "51,150" (seq >= 51 AND <= 150). */ seqCursor: seqCursorSchema.optional(), }); @@ -127,9 +105,7 @@ export const excludeArchivedQuerySchema = z .transform((val) => val === 'true'); /** True returns fully hydrated relations. */ -export const fullResponseQuerySchema = z.object({ - fullResponse: booleanTransformSchema.optional(), -}); +export const fullResponseQuerySchema = z.object({ fullResponse: booleanTransformSchema.optional() }); export const includeOptions = ['counts', 'membership', 'members'] as const; export type IncludeOption = (typeof includeOptions)[number]; @@ -141,10 +117,7 @@ export const includeQuerySchema = z .transform((val) => (val ? val.split(',').map((s) => s.trim()) : [])) .pipe(z.array(z.enum(includeOptions))); -export const slugIncludeQuerySchema = z.object({ - slug: booleanTransformSchema.optional(), - include: includeQuerySchema, -}); +export const slugIncludeQuerySchema = z.object({ slug: booleanTransformSchema.optional(), include: includeQuerySchema }); export const idsBodySchema = (maxItems = 50) => z.object({ @@ -161,12 +134,7 @@ export const idsWithStxBodySchema = (maxItems = 50) => .array(z.string()) .min(1, translatedError('error:invalid_min_items', { min: 'one', name: 'ID' })) .max(maxItems, translatedError('error:invalid_max_items', { max: maxItems, name: 'ID' })), - stx: z - .object({ - mutationId: z.string(), - sourceId: z.string(), - }) - .optional(), + stx: z.object({ mutationId: z.string(), sourceId: z.string() }).optional(), }); // Common headers schemas @@ -182,20 +150,14 @@ export const locationSchema = z.object({ Location: z.string() }); export const refineWithType = (check: (val: T) => boolean, errorType: string) => { return (val: T, ctx: z.RefinementCtx) => { if (!check(val)) { - ctx.addIssue({ - code: 'custom', - message: t(`error:${errorType}`), - input: val, - params: { type: errorType }, - }); + ctx.addIssue({ code: 'custom', message: t(`error:${errorType}`), input: val, params: { type: errorType } }); } }; }; export const validUuidSchema = z.string().uuid(translatedError('error:invalid_id')); -export const noDuplicateSlugsRefine = (items: { slug: string }[]) => - new Set(items.map((i) => i.slug)).size === items.length; +export const noDuplicateSlugsRefine = (items: { slug: string }[]) => new Set(items.map((i) => i.slug)).size === items.length; /** Scheme and host are case-insensitive; userinfo, path, query and fragment are not, so they keep their case. */ const lowercaseSchemeAndHost = (url: string) => { @@ -228,15 +190,11 @@ export const validEmailSchema = z z .email(translatedError('error:invalid_email')) .min(4, translatedError('error:invalid_between_num', { name: 'Email', min: 4, max: maxLength.field })) - .max( - maxLength.field, - translatedError('error:invalid_between_num', { name: 'Email', min: 4, max: maxLength.field }), - ), + .max(maxLength.field, translatedError('error:invalid_between_num', { name: 'Email', min: 4, max: maxLength.field })), ) .openapi({ type: 'string', format: 'email', minLength: 4, maxLength: maxLength.field }); -const canonicalDomainPattern = - /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; +const canonicalDomainPattern = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; export const validDomainSchema = z .string() .trim() @@ -245,19 +203,10 @@ export const validDomainSchema = z z .string() .min(4, translatedError('error:invalid_between_num', { name: 'Domain', min: 4, max: maxLength.field })) - .max( - maxLength.field, - translatedError('error:invalid_between_num', { name: 'Domain', min: 4, max: maxLength.field }), - ) + .max(maxLength.field, translatedError('error:invalid_between_num', { name: 'Domain', min: 4, max: maxLength.field })) .regex(canonicalDomainPattern, translatedError('error:invalid_domain')), ) - .openapi({ - type: 'string', - format: 'hostname', - minLength: 4, - maxLength: maxLength.field, - pattern: canonicalDomainPattern.source, - }); + .openapi({ type: 'string', format: 'hostname', minLength: 4, maxLength: maxLength.field, pattern: canonicalDomainPattern.source }); export const validSlugSchema = z .string() diff --git a/backend/src/schemas/count-schemas.ts b/backend/src/schemas/count-schemas.ts index 12e626d23..766fc44b0 100644 --- a/backend/src/schemas/count-schemas.ts +++ b/backend/src/schemas/count-schemas.ts @@ -1,8 +1,4 @@ import { z } from '@hono/zod-openapi'; import { recordFromKeys, roles } from 'shared'; -export const membershipCountSchema = z.object({ - ...recordFromKeys(roles.all, () => z.number()), - pending: z.number(), - total: z.number(), -}); +export const membershipCountSchema = z.object({ ...recordFromKeys(roles.all, () => z.number()), pending: z.number(), total: z.number() }); diff --git a/backend/src/schemas/entity-base-mocks.ts b/backend/src/schemas/entity-base-mocks.ts index 8f9ae45b3..bc1f2a49d 100644 --- a/backend/src/schemas/entity-base-mocks.ts +++ b/backend/src/schemas/entity-base-mocks.ts @@ -2,10 +2,7 @@ import { faker } from '@faker-js/faker'; import { mockNanoid, mockTimestamps, mockUuid, withFakerSeed } from '#/mocks'; /** Must be called within withFakerSeed() for deterministic output. */ -const mockEntityCore = () => ({ - id: mockUuid(), - ...mockTimestamps(), -}); +const mockEntityCore = () => ({ id: mockUuid(), ...mockTimestamps() }); export const mockChannelBase = (key = 'context-entity:default') => withFakerSeed(key, () => { @@ -33,11 +30,7 @@ export const mockProductBase = (key = 'product-entity:default') => })); /** Only the name and slug generation differs per entity type. Must be called within withFakerSeed(). */ -const mockMinimalBase = ( - entityType: T, - naming: () => { name: string; slug: string }, - id?: string, -) => ({ +const mockMinimalBase = (entityType: T, naming: () => { name: string; slug: string }, id?: string) => ({ id: id ?? mockUuid(), ...naming(), thumbnailUrl: null, @@ -51,22 +44,7 @@ export const mockUserMinimalBase = (key = 'user-minimal:default', id?: string) = () => { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); - return { - name: `${firstName} ${lastName}`, - slug: faker.internet.username({ firstName, lastName }).toLowerCase(), - }; - }, - id, - ), - ); - -export const mockOrganizationMinimalBase = (key = 'organization-minimal:default', id?: string) => - withFakerSeed(key, () => - mockMinimalBase( - 'organization' as const, - () => { - const name = faker.company.name(); - return { name, slug: faker.helpers.slugify(name).toLowerCase() }; + return { name: `${firstName} ${lastName}`, slug: faker.internet.username({ firstName, lastName }).toLowerCase() }; }, id, ), @@ -75,12 +53,7 @@ export const mockOrganizationMinimalBase = (key = 'organization-minimal:default' /** Hydrates stored audit-user IDs to the minimal wire representation. */ export const mockAuditUsers = (row: { createdBy: string | null; updatedBy: string | null }, key: string) => { const createdBy = row.createdBy ? mockUserMinimalBase(`${key}:created-by`, row.createdBy) : null; - const updatedBy = - row.updatedBy === row.createdBy - ? createdBy - : row.updatedBy - ? mockUserMinimalBase(`${key}:updated-by`, row.updatedBy) - : null; + const updatedBy = row.updatedBy === row.createdBy ? createdBy : row.updatedBy ? mockUserMinimalBase(`${key}:updated-by`, row.updatedBy) : null; return { createdBy, updatedBy }; }; diff --git a/backend/src/schemas/entity-base.ts b/backend/src/schemas/entity-base.ts index 92438e275..a832ed5f7 100644 --- a/backend/src/schemas/entity-base.ts +++ b/backend/src/schemas/entity-base.ts @@ -4,17 +4,9 @@ import { channelEntityTypeSchema, productEntityTypeSchema } from '#/schemas'; import { nullableUserMinimalBaseSchema } from '#/schemas/minimal-base'; import { mockChannelBase, mockProductBase } from './entity-base-mocks'; -const entityCoreShape = { - id: z.string(), - name: z.string(), - createdAt: z.string(), - updatedAt: z.string().nullable(), -}; +const entityCoreShape = { id: z.string(), name: z.string(), createdAt: z.string(), updatedAt: z.string().nullable() }; -const auditShape = { - createdBy: nullableUserMinimalBaseSchema, - updatedBy: nullableUserMinimalBaseSchema, -}; +const auditShape = { createdBy: nullableUserMinimalBaseSchema, updatedBy: nullableUserMinimalBaseSchema }; /** * Exported separately to avoid circular dependencies, which is also why `included` is left out: channel @@ -45,7 +37,7 @@ export const productBaseSchema = z keywords: z.string(), }) .openapi('ProductBase', { - description: 'Base schema for content entities with creator tracking (e.g. page, attachment).', + description: 'Base schema for content entities with creator tracking (e.g. attachment).', example: mockProductBase(), 'x-tags': schemaTags('base', 'entities', 'cella'), }); diff --git a/backend/src/schemas/error-response-schemas.ts b/backend/src/schemas/error-response-schemas.ts index 07105dcb2..3f0976938 100644 --- a/backend/src/schemas/error-response-schemas.ts +++ b/backend/src/schemas/error-response-schemas.ts @@ -65,10 +65,9 @@ const errorResponseOptions = [ const errorBodySchema = (code: ErrorCode) => { const option = errorResponseOptions.find((o) => o.code === code); - return apiErrorSchema.extend({ status: z.literal(code) }).openapi(option?.name ?? 'Error', { - description: option?.schemaDescription, - 'x-tags': schemaTags('errors', 'cella'), - }); + return apiErrorSchema + .extend({ status: z.literal(code) }) + .openapi(option?.name ?? 'Error', { description: option?.schemaDescription, 'x-tags': schemaTags('errors', 'cella') }); }; // Numeric-keyed map for registry work; no `ref` here. @@ -107,12 +106,7 @@ export const errorResponseRefs = errorResponseOptions.reduce( ) as unknown as Record; // Registry helpers -const registerResponseFromZod = ( - registry: OpenAPIRegistry, - responseName: string, - schemaName: string, - response: ZodBackedResponse, -) => { +const registerResponseFromZod = (registry: OpenAPIRegistry, responseName: string, schemaName: string, response: ZodBackedResponse) => { const schema = response.content['application/json'].schema; registry.register(schemaName, schema); registry.registerComponent('responses', responseName, { diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index 78f8f7992..d527e5ee0 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -51,12 +51,7 @@ export { registerAllErrorResponses, } from './error-response-schemas'; export { mapEntitiesToSchema } from './map-entities-to-schema'; -export { - nullableOrganizationMinimalBaseSchema, - nullableUserMinimalBaseSchema, - organizationMinimalBaseSchema, - userMinimalBaseSchema, -} from './minimal-base'; +export { minimalBaseSchema, nullableUserMinimalBaseSchema, userMinimalBaseSchema } from './minimal-base'; export { type AppCatchupResponse, appCatchupResponseSchema, diff --git a/backend/src/schemas/map-entities-to-schema.ts b/backend/src/schemas/map-entities-to-schema.ts index 9f066e90a..c5c913144 100644 --- a/backend/src/schemas/map-entities-to-schema.ts +++ b/backend/src/schemas/map-entities-to-schema.ts @@ -4,8 +4,6 @@ import type { ZodType } from 'zod'; export const mapEntitiesToSchema = (getSchemaForTable: (tableName: string) => T) => { return z.object( - Object.fromEntries( - appConfig.entityTypes.map((entityType) => [entityType, getSchemaForTable(entityType)]), - ) as Record, + Object.fromEntries(appConfig.entityTypes.map((entityType) => [entityType, getSchemaForTable(entityType)])) as Record, ); }; diff --git a/backend/src/schemas/minimal-base.ts b/backend/src/schemas/minimal-base.ts index 854d40a74..37c0f00aa 100644 --- a/backend/src/schemas/minimal-base.ts +++ b/backend/src/schemas/minimal-base.ts @@ -1,12 +1,14 @@ import { z } from '@hono/zod-openapi'; import { schemaTags } from '#/core/openapi-helpers'; -import { mockOrganizationMinimalBase, mockUserMinimalBase } from './entity-base-mocks'; +import { mockUserMinimalBase } from './entity-base-mocks'; /** * Only the fields needed to render an entity cell (avatar, name, link), discriminated by a literal * `entityType`. Its own file, so references can be imported without the full entity schemas. + * Only the user reference is a named component, as many schemas point to it; a reference to another + * entity stays unnamed and inlines where it is used. */ -const minimalBaseSchema = (entityType: T) => +export const minimalBaseSchema = (entityType: T) => z.object({ id: z.string(), name: z.string(), @@ -27,13 +29,3 @@ export const userMinimalBaseSchema = minimalBaseSchema('user').openapi('UserMini * zod-to-openapi emits a contradictory allOf for `.nullable()` refs. */ export const nullableUserMinimalBaseSchema = z.union([userMinimalBaseSchema, z.null()]); - -/** Minimal organization schema for references (e.g. the single organization a tenant holds). */ -export const organizationMinimalBaseSchema = minimalBaseSchema('organization').openapi('OrganizationMinimalBase', { - description: 'Minimal organization data for references.', - example: mockOrganizationMinimalBase(), - 'x-tags': schemaTags('base', 'organizations', 'cella'), -}); - -/** Nullable minimal-organization reference; unnamed for the same reason as the user variant. */ -export const nullableOrganizationMinimalBaseSchema = z.union([organizationMinimalBaseSchema, z.null()]); diff --git a/backend/src/schemas/openapi-composition.test.ts b/backend/src/schemas/openapi-composition.test.ts index cc05fcc23..393f75c26 100644 --- a/backend/src/schemas/openapi-composition.test.ts +++ b/backend/src/schemas/openapi-composition.test.ts @@ -10,14 +10,9 @@ import { nullableStxBaseSchema, stxBaseSchema } from './sync-transaction-schemas const registry = new OpenAPIRegistry(); registry.register('UserMinimalBase', userMinimalBaseSchema); registry.register('StxBase', stxBaseSchema); -registry.register('BooleanQueryValue', booleanTransformSchema); registry.register( 'ReusableUnionFixture', - z.object({ - user: nullableUserMinimalBaseSchema, - stx: nullableStxBaseSchema, - flag: booleanTransformSchema.optional(), - }), + z.object({ user: nullableUserMinimalBaseSchema, stx: nullableStxBaseSchema, flag: booleanTransformSchema.optional() }), ); registry.register('UploadToken', uploadTokenSchema); registry.register('StreamNotification', streamNotificationSchema); @@ -36,29 +31,22 @@ describe('OpenAPI composition conventions', () => { }; expect(schemas).toMatchObject({ ReusableUnionFixture: { - properties: { - user: nullableUserRef, - stx: nullableStxRef, - flag: { $ref: '#/components/schemas/BooleanQueryValue' }, - }, - }, - StreamNotification: { - properties: { - stx: nullableStxRef, - }, + properties: { user: nullableUserRef, stx: nullableStxRef }, }, + StreamNotification: { properties: { stx: nullableStxRef } }, }); // Nullable wrappers must not surface as named component schemas (SDK/docs export noise). expect(Object.keys(schemas).filter((name) => name.startsWith('Nullable'))).toEqual([]); }); - it('references genuinely reusable unions by name', () => { + it('inlines the boolean query helper instead of exposing it as a component schema', () => { expect(schemas).toMatchObject({ - BooleanQueryValue: { - anyOf: [{ type: 'string', enum: ['true', 'false'] }, { type: 'boolean' }], + ReusableUnionFixture: { + properties: { flag: { anyOf: [{ type: 'string', enum: ['true', 'false'] }, { type: 'boolean' }] } }, }, }); + expect(schemas).not.toHaveProperty('BooleanQueryValue'); }); it('uses nullable type arrays for inline primitives and objects', () => { diff --git a/backend/src/schemas/stream-schemas.ts b/backend/src/schemas/stream-schemas.ts index 3f1b4f3a0..09d235cd8 100644 --- a/backend/src/schemas/stream-schemas.ts +++ b/backend/src/schemas/stream-schemas.ts @@ -5,12 +5,8 @@ import { mockStreamNotification } from './stream-mocks'; import { nullableStxBaseSchema } from './sync-transaction-schemas'; const propagationHintSchema = z.object({ - embeddedProduct: z - .enum(appConfig.productEntityTypes) - .describe('Product type whose change triggered the propagation (e.g. label)'), - hostProduct: z - .enum(appConfig.productEntityTypes) - .describe('Host product type whose cache should be patched (e.g. task)'), + embeddedProduct: z.enum(appConfig.productEntityTypes).describe('Product type whose change triggered the propagation (e.g. label)'), + hostProduct: z.enum(appConfig.productEntityTypes).describe('Host product type whose cache should be patched (e.g. task)'), hostColumn: z.string().describe('Column on the host product that embeds the changed product (e.g. labels)'), update: z.array(z.string()).describe('Host product IDs that need cache refresh'), remove: z.array(z.string()).describe('Host product IDs that need the embedded reference removed'), @@ -19,9 +15,7 @@ const propagationHintSchema = z.object({ /** Clients fetch the entity data separately. Membership events leave `seq` and `stx` null. */ export const streamNotificationSchema = z .object({ - kind: z - .enum(['product', 'membership']) - .describe('Discriminant for the notification: product-entity sync vs membership change'), + kind: z.enum(['product', 'membership']).describe('Discriminant for the notification: product-entity sync vs membership change'), action: z .enum([...activityActions, 'moveOut'] as const) .describe('Change kind; moveOut = the row left this path (reparent) and is no longer readable there'), @@ -30,34 +24,13 @@ export const streamNotificationSchema = z subjectId: z.string().nullable(), organizationId: z.string().nullable(), tenantId: z.string().nullable(), - channelType: z - .enum(appConfig.channelEntityTypes) - .nullable() - .describe('Channel entity type for membership events (e.g. organization, project)'), - path: z - .string() - .nullable() - .describe('Materialized id-path of the affected rows (root-first ancestor ids); moveOut carries the OLD path'), - seq: z - .number() - .int() - .nullable() - .describe('Org-sequence position (one order per organization, shared across product entity types)'), - channelId: z - .string() - .nullable() - .describe('Channel entity ID for grouping (e.g. projectId for tasks in unseen counts)'), + channelType: z.enum(appConfig.channelEntityTypes).nullable().describe('Channel entity type for membership events (e.g. organization, project)'), + path: z.string().nullable().describe('Materialized id-path of the affected rows (root-first ancestor ids); moveOut carries the OLD path'), + seq: z.number().int().nullable().describe('Org-sequence position (one order per organization, shared across product entity types)'), + channelId: z.string().nullable().describe('Channel entity ID for grouping (e.g. projectId for tasks in unseen counts)'), stx: nullableStxBaseSchema, - batchUntilSeq: z - .number() - .int() - .nullable() - .describe('Last sequence position for a batched notification: client should fetch range'), - count: z - .number() - .int() - .nullable() - .describe('Authoritative row count for batches: sequence ranges of different paths may interleave'), + batchUntilSeq: z.number().int().nullable().describe('Last sequence position for a batched notification: client should fetch range'), + count: z.number().int().nullable().describe('Authoritative row count for batches: sequence ranges of different paths may interleave'), spreadWindow: z .number() .int() @@ -65,9 +38,7 @@ export const streamNotificationSchema = z .describe( 'Server-suggested spread window (ms) for the lazy delta fetch; scales with channel audience and load, and the client clamps it between its priority tier bounds', ), - propagation: propagationHintSchema - .nullable() - .describe('Embedded-product propagation hint for cross-product cache invalidation'), + propagation: propagationHintSchema.nullable().describe('Embedded-product propagation hint for cross-product cache invalidation'), }) .openapi('StreamNotification', { description: 'Realtime notification delivered via SSE for entity and membership changes.', @@ -79,9 +50,7 @@ export type StreamNotification = z.infer; /** One client view: a prefix set + entity types + the org-sequence cursor it has caught up to. */ export const catchupViewSchema = z.object({ - key: z.string().max(512).openapi({ - description: 'Client-chosen stable view key, echoed back verbatim to correlate responses', - }), + key: z.string().max(512).openapi({ description: 'Client-chosen stable view key, echoed back verbatim to correlate responses' }), organizationId: z.string(), prefixes: z .array(z.string().max(512)) @@ -93,22 +62,22 @@ export const catchupViewSchema = z.object({ description: 'View depth: subtree (default) covers rows at or below the prefix node; self covers only rows HOMED at the node (exact placement: a channel wall). Self views are answerable by direct home-scoped memberships.', }), - cursor: z.number().int().min(0).openapi({ - description: 'Org-sequence position this view has fully ingested (0 = baseline not yet established)', - }), + cursor: z.number().int().min(0).openapi({ description: 'Org-sequence position this view has fully ingested (0 = baseline not yet established)' }), }); export type CatchupView = z.infer; /** `views` is the sequence-sync contract: prefix views with org-sequence cursors, answered after prefix authorization. */ export const streamCatchupBodySchema = z.object({ - cursor: z.string().optional().openapi({ - description: 'Last activity cursor received by the client (LSN-based). Omit on first sync.', - example: '0-16B3748', - }), - views: z.array(catchupViewSchema).max(256).optional().openapi({ - description: 'Client-declared views: prefix set + entity types + org-sequence cursor per view', - }), + cursor: z + .string() + .optional() + .openapi({ description: 'Last activity cursor received by the client (LSN-based). Omit on first sync.', example: '0-16B3748' }), + views: z + .array(catchupViewSchema) + .max(256) + .optional() + .openapi({ description: 'Client-declared views: prefix set + entity types + org-sequence cursor per view' }), }); /** Product entity sync is answered per view (`catchupViewAnswerSchema`); this carries the org-level concerns. */ @@ -128,24 +97,27 @@ export type CatchupChangeSummary = z.infer; export const catchupViewAnswerSchema = z.object({ key: z.string().openapi({ description: 'The client-supplied view key, echoed verbatim' }), status: z.enum(['ok', 'opaque', 'forbidden']), - frontiers: z.record(z.string(), z.number().int()).optional().openapi({ - description: 'Per-entityType newest sequence position over the view prefixes (subtree: f:{type}; self: fs:{type})', - }), - counts: z.record(z.string(), z.number().int()).optional().openapi({ - description: 'Per-entityType live row counts summed over the view prefixes (subtree: e:{type}; self: es:{type})', - }), + frontiers: z + .record(z.string(), z.number().int()) + .optional() + .openapi({ description: 'Per-entityType newest sequence position over the view prefixes (subtree: f:{type}; self: fs:{type})' }), + counts: z + .record(z.string(), z.number().int()) + .optional() + .openapi({ description: 'Per-entityType live row counts summed over the view prefixes (subtree: e:{type}; self: es:{type})' }), }); export type CatchupViewAnswer = z.infer; /** `views` answers the client-declared views; `changes` is the per-org summary for membership screening and propagation. */ export const appCatchupResponseSchema = z.object({ - changes: z.record(z.string(), catchupChangeSummarySchema).openapi({ - description: 'Per-org change summary: { [organizationId]: { signals?, propagation? } }', - }), - views: z.array(catchupViewAnswerSchema).optional().openapi({ - description: 'Per-view answers for client-declared views (same order as the request)', - }), + changes: z + .record(z.string(), catchupChangeSummarySchema) + .openapi({ description: 'Per-org change summary: { [organizationId]: { signals?, propagation? } }' }), + views: z + .array(catchupViewAnswerSchema) + .optional() + .openapi({ description: 'Per-view answers for client-declared views (same order as the request)' }), cursor: z.string().nullable().openapi({ description: 'Last activity ID (use as offset for next request)' }), }); diff --git a/backend/src/schemas/success-response-schemas.ts b/backend/src/schemas/success-response-schemas.ts index 09ef1faf1..fe1bba67a 100644 --- a/backend/src/schemas/success-response-schemas.ts +++ b/backend/src/schemas/success-response-schemas.ts @@ -1,20 +1,13 @@ import { z } from '@hono/zod-openapi'; -export const paginationSchema = (schema: z.ZodType) => - z.object({ - items: z.array(schema), - total: z.number(), - }); +export const paginationSchema = (schema: z.ZodType) => z.object({ items: z.array(schema), total: z.number() }); /** Passing an item schema produces `data: T[]`; omitting it requires an empty data array. */ export const batchResponseSchema = (itemSchema?: T) => z.object({ data: itemSchema ? z.array(itemSchema) : z.tuple([]).rest(z.never()), rejectedIds: z.array(z.string()).describe('Identifiers of items that could not be processed'), - rejectionReasons: z - .record(z.string(), z.array(z.string())) - .optional() - .describe('Map of reason code to rejected item IDs'), + rejectionReasons: z.record(z.string(), z.array(z.string())).optional().describe('Map of reason code to rejected item IDs'), }); export interface BatchResponseEmpty { diff --git a/backend/src/schemas/sync-transaction-mocks.ts b/backend/src/schemas/sync-transaction-mocks.ts index 2dc23bde7..844c9fda8 100644 --- a/backend/src/schemas/sync-transaction-mocks.ts +++ b/backend/src/schemas/sync-transaction-mocks.ts @@ -1,8 +1,4 @@ import { mockUuid, withFakerSeed } from '#/mocks'; export const mockStxBase = (key = 'stx-base:default') => - withFakerSeed(key, () => ({ - mutationId: mockUuid(), - sourceId: mockUuid(), - fieldTimestamps: {}, - })); + withFakerSeed(key, () => ({ mutationId: mockUuid(), sourceId: mockUuid(), fieldTimestamps: {} })); diff --git a/backend/src/schemas/sync-transaction-schemas.ts b/backend/src/schemas/sync-transaction-schemas.ts index 489683e8d..9167352f3 100644 --- a/backend/src/schemas/sync-transaction-schemas.ts +++ b/backend/src/schemas/sync-transaction-schemas.ts @@ -11,14 +11,10 @@ export const stxBaseSchema = z fieldTimestamps: z .record(z.string(), z.string().refine(isValidHLC, 'Invalid HLC timestamp')) .describe('Per-field HLC timestamps for scalar fields being changed'), - replayed: z - .boolean() - .optional() - .describe('Set on a paused offline mutation being replayed: its field timestamps then arbitrate as intent time'), + replayed: z.boolean().optional().describe('Set on a paused offline mutation being replayed: its field timestamps then arbitrate as intent time'), }) .openapi('StxBase', { - description: - 'Sync transaction metadata for offline and realtime support, idempotency and HLC-based conflict resolution.', + description: 'Sync transaction metadata for offline and realtime support, idempotency and HLC-based conflict resolution.', example: mockStxBase(), 'x-tags': schemaTags('base', 'cella'), }); diff --git a/backend/src/schemas/tools-config.ts b/backend/src/schemas/tools-config.ts index 9ca396deb..4dcd7bce9 100644 --- a/backend/src/schemas/tools-config.ts +++ b/backend/src/schemas/tools-config.ts @@ -4,8 +4,5 @@ import type { ToolsConfig } from 'shared/tools-config'; /** Wire schema for a channel's per-slot tool arrangement (see `shared/tools-config` for the contract). */ export const toolsConfigSchema: z.ZodType = z.record( z.string(), - z.object({ - order: z.array(z.string()).optional(), - hidden: z.array(z.string()).optional(), - }), + z.object({ order: z.array(z.string()).optional(), hidden: z.array(z.string()).optional() }), ); diff --git a/backend/src/schemas/user-schema-base.ts b/backend/src/schemas/user-schema-base.ts index 091b8c253..7072897cf 100644 --- a/backend/src/schemas/user-schema-base.ts +++ b/backend/src/schemas/user-schema-base.ts @@ -6,11 +6,7 @@ import { mockUserBase } from './entity-base-mocks'; /** Exported separately to avoid circular dependencies. Users carry no permissions field; channel entities do. */ export const userBaseSchema = channelBaseSchema .omit({ entityType: true, tenantId: true }) - .extend({ - description: z.string().nullable(), - email: z.email(), - entityType: z.literal('user'), - }) + .extend({ description: z.string().nullable(), email: z.email(), entityType: z.literal('user') }) .openapi('UserBase', { description: 'Base user schema with essential fields for identification and display.', example: mockUserBase(), diff --git a/backend/src/server.ts b/backend/src/server.ts index f5e47916b..c7c649e03 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,22 +7,33 @@ import { healthApp } from '#/lib/health'; import '#/lib/lens-telemetry'; // registers doba lens otel hooks import { app as middlewares } from '#/middlewares/app'; -const baseApp = new OpenAPIHono(); +/** + * A base app: global middlewares, health, the mount-prefix strip, not-found and error handling, with no module routes. + * Hono takes no routes after its first request, so a worker folded into the API process builds its own. + */ +export function createBaseApp() { + const app = new OpenAPIHono(); -// The load balancer preserves same-origin `/api` and `/mcp` prefixes; redispatch through `mount()` strips them. -baseApp.mount('/api', (request, env, executionCtx) => baseApp.fetch(request, env, executionCtx)); -baseApp.mount('/mcp', (request, env, executionCtx) => baseApp.fetch(request, env, executionCtx)); + // The load balancer preserves same-origin `/api` and `/mcp` prefixes; redispatch through `mount()` strips them. + app.mount('/api', (request, env, executionCtx) => app.fetch(request, env, executionCtx)); + app.mount('/mcp', (request, env, executionCtx) => app.fetch(request, env, executionCtx)); -baseApp.get('/favicon.ico', (c) => c.redirect(`${appConfig.frontendUrl}/favicon.ico`, 301)); + app.get('/favicon.ico', (c) => c.redirect(`${appConfig.frontendUrl}/favicon.ico`, 301)); -baseApp.route('/', middlewares); + app.route('/', middlewares); -baseApp.route('/', healthApp); + app.route('/', healthApp); -baseApp.notFound(() => { - throw new AppError(404, 'route_not_found', 'warn'); -}); + app.notFound(() => { + throw new AppError(404, 'route_not_found', 'warn'); + }); -baseApp.onError(appErrorHandler); + app.onError(appErrorHandler); + + return app; +} + +/** The API's app; `#/routes` mounts every module's routes on it. */ +const baseApp = createBaseApp(); export { baseApp }; diff --git a/backend/src/utils/data-encryption.ts b/backend/src/utils/data-encryption.ts index 51bf40d29..9d423e3ca 100644 --- a/backend/src/utils/data-encryption.ts +++ b/backend/src/utils/data-encryption.ts @@ -13,16 +13,12 @@ const HKDF_SALT = 'cella:data-encryption'; const encode = (value: Buffer) => value.toString('base64url'); const decode = (value: string) => Buffer.from(value, 'base64url'); -const deriveKey = (purpose: string): Buffer => - Buffer.from( - hkdfSync( - 'sha256', - Buffer.from(env.DATA_ENCRYPTION_KEY, 'utf8'), - Buffer.from(HKDF_SALT, 'utf8'), - Buffer.from(purpose, 'utf8'), - KEY_BYTES, - ), - ); +const deriveKey = (purpose: string): Buffer => { + const secret = Buffer.from(env.DATA_ENCRYPTION_KEY, 'utf8'); + const salt = Buffer.from(HKDF_SALT, 'utf8'); + const info = Buffer.from(purpose, 'utf8'); + return Buffer.from(hkdfSync('sha256', secret, salt, info, KEY_BYTES)); +}; export const isEncryptedData = (value: string): boolean => value.startsWith(`${VERSION}:`); @@ -41,9 +37,8 @@ export const decryptData = (encryptedValue: string, purpose: string): string => throw new Error('Invalid encrypted data format'); } - const decipher = createDecipheriv(ALGORITHM, deriveKey(purpose), decode(encodedIv), { - authTagLength: AUTH_TAG_BYTES, - }); + const iv = decode(encodedIv); + const decipher = createDecipheriv(ALGORITHM, deriveKey(purpose), iv, { authTagLength: AUTH_TAG_BYTES }); decipher.setAuthTag(decode(encodedAuthTag)); return Buffer.concat([decipher.update(decode(encodedCiphertext)), decipher.final()]).toString('utf8'); diff --git a/backend/src/utils/description-document.test.ts b/backend/src/utils/description-document.test.ts index 2b1d5efca..1423a102f 100644 --- a/backend/src/utils/description-document.test.ts +++ b/backend/src/utils/description-document.test.ts @@ -16,6 +16,15 @@ describe('nameFromDocument', () => { expect(nameFromDocument(doc(' padded '))).toBe('padded'); }); + it('clamps to the name column length', () => { + expect(nameFromDocument(doc('x'.repeat(300)))).toHaveLength(255); + }); + + it('is empty, so the caller keeps the previous name, when an image is moved to the top', () => { + const image = { type: 'image', props: { name: 'photo.png', url: 'https://x/photo.png' } }; + expect(nameFromDocument(JSON.stringify([image, ...JSON.parse(doc('Title'))]))).toBe(''); + }); + it('is empty for a null, unparseable or non-array description', () => { expect(nameFromDocument(null)).toBe(''); expect(nameFromDocument('

    legacy html

    ')).toBe(''); diff --git a/backend/src/utils/description-document.ts b/backend/src/utils/description-document.ts index 3b724a320..5f06fb94e 100644 --- a/backend/src/utils/description-document.ts +++ b/backend/src/utils/description-document.ts @@ -1,20 +1,18 @@ -import { getSearchableTextFromBlocks, getTextFromBlock, parseBlocks } from 'shared/blocknote'; +import { deriveDocument } from 'shared/utils/derive-description-core'; +import { maxLength } from '#/db/utils/constraints'; /** * For entities whose `description` stores the whole edited document as BlockNote blocks, block 0 - * holds the title. `name` is then a denormalized column derived on every write, so the two cannot - * disagree; a Yjs materialization goes through the same update op and gets the same treatment. + * holds the title. `name` is then a denormalized column derived on every write; a Yjs + * materialization goes through the same update op and gets the same treatment. */ -/** Title text of a stored document: block 0's plain text. Empty when the document is unparseable. */ -export const nameFromDocument = (description: string | null | undefined): string => { - const [first] = parseBlocks(description) ?? []; - return first ? getTextFromBlock(first).trim() : ''; -}; +/** + * `name` for a stored title document: the title `deriveDocument` reads (`titleFromDocument`), clamped to the column so + * autosave and Yjs materialize writes, which have no user to report to, never fail on length. Empty when block 0 holds + * no text (an image); the caller then keeps the previous name. + */ +export const nameFromDocument = (description: string | null | undefined): string => deriveDocument(description).name.slice(0, maxLength.field).trim(); -/** Search text for a stored document, capped at 900 characters. Block 0 already carries the title, so it is not prepended. */ -export const keywordsFromDocument = (description: string | null | undefined): string => - getSearchableTextFromBlocks(parseBlocks(description) ?? []) - .replace(/\s+/g, ' ') - .trim() - .slice(0, 900); +/** Search text for a stored document, capped at 900 characters. Block 0 already carries the title, so it is not prepended. One field of `deriveDocument`. */ +export const keywordsFromDocument = (description: string | null | undefined): string => deriveDocument(description).keywords; diff --git a/backend/src/utils/hash-pii.ts b/backend/src/utils/hash-pii.ts index 4dbff03a4..83f0bd1f2 100644 --- a/backend/src/utils/hash-pii.ts +++ b/backend/src/utils/hash-pii.ts @@ -10,19 +10,13 @@ import { modeSecret } from '#/env'; export const hashPii = (value: string, namespace = 'pii'): string => { const normalized = value.trim().toLowerCase(); if (!normalized) return ''; - return createHmac('sha256', modeSecret('PII_HASH_SECRET')) - .update(`${namespace}:${normalized}`) - .digest('hex') - .slice(0, 16); + return createHmac('sha256', modeSecret('PII_HASH_SECRET')).update(`${namespace}:${normalized}`).digest('hex').slice(0, 16); }; /** Bound to the user, so a table leak cannot correlate one IP across users. Backs MFA trust checks. */ export const hashIpForUser = (ip: string, userId: string): string => { if (!ip || !userId) return ''; - return createHmac('sha256', modeSecret('PII_HASH_SECRET')) - .update(`session:ip:${userId}:${ip}`) - .digest('hex') - .slice(0, 32); + return createHmac('sha256', modeSecret('PII_HASH_SECRET')).update(`session:ip:${userId}:${ip}`).digest('hex').slice(0, 32); }; /** @@ -31,27 +25,18 @@ export const hashIpForUser = (ip: string, userId: string): string => { */ export const hashDeviceIdForUser = (deviceId: string, userId: string): string => { if (!deviceId || !userId) return ''; - return createHmac('sha256', modeSecret('PII_HASH_SECRET')) - .update(`session:device:${userId}:${deviceId}`) - .digest('hex') - .slice(0, 32); + return createHmac('sha256', modeSecret('PII_HASH_SECRET')).update(`session:device:${userId}:${deviceId}`).digest('hex').slice(0, 32); }; /** Global namespace, so one subnet always hashes the same and cross-user blocklist matching works. */ export const hashSubnet = (subnet: string): string => { if (!subnet) return ''; - return createHmac('sha256', modeSecret('PII_HASH_SECRET')) - .update(`blocklist:subnet:${subnet}`) - .digest('hex') - .slice(0, 32); + return createHmac('sha256', modeSecret('PII_HASH_SECRET')).update(`blocklist:subnet:${subnet}`).digest('hex').slice(0, 32); }; /** Global namespace per subject kind, so an IP or address counts in one bucket in every process; `rate_limits` holds the pseudonym alone. */ export const hashRateLimitSubject = (kind: 'ip' | 'email', value: string): string => { const normalized = value.trim().toLowerCase(); if (!normalized) return ''; - return createHmac('sha256', modeSecret('PII_HASH_SECRET')) - .update(`ratelimit:${kind}:${normalized}`) - .digest('hex') - .slice(0, 32); + return createHmac('sha256', modeSecret('PII_HASH_SECRET')).update(`ratelimit:${kind}:${normalized}`).digest('hex').slice(0, 32); }; diff --git a/backend/src/utils/idempotency.ts b/backend/src/utils/idempotency.ts index 189c5fbb2..032720b84 100644 --- a/backend/src/utils/idempotency.ts +++ b/backend/src/utils/idempotency.ts @@ -18,23 +18,13 @@ type ProductTable = PgTable & { stx: PgColumn; createdBy: PgColumn; tenantId: Pg * the lookup takes the caller's own rows in the request scope: a replay by another actor finds nothing and creates * its own rows. */ -export async function checkIdempotency( - ctx: ActorContext, - table: T, - stxId: string, -): Promise[] | null> { +export async function checkIdempotency(ctx: ActorContext, table: T, stxId: string): Promise[] | null> { if (!(await isTransactionProcessed(stxId))) return null; const batch = await tenantRead(ctx, (readCtx) => readCtx.var.db .select() .from(table as PgTable) - .where( - and( - sql`${table.stx}->>'mutationId' = ${stxId}`, - eq(table.createdBy, ctx.var.actor.id), - requestScopeWhere(ctx, table), - ), - ), + .where(and(sql`${table.stx}->>'mutationId' = ${stxId}`, eq(table.createdBy, ctx.var.actor.id), requestScopeWhere(ctx, table))), ); return batch.length > 0 ? (batch as InferSelectModel[]) : null; } diff --git a/backend/src/utils/ip-subnet.ts b/backend/src/utils/ip-subnet.ts index 7828dd94b..b8b7bd552 100644 --- a/backend/src/utils/ip-subnet.ts +++ b/backend/src/utils/ip-subnet.ts @@ -40,9 +40,7 @@ const expandIPv6 = (ip: string): string[] | null => { const tailGroups = tail ? tail.split(':') : []; const missing = 8 - headGroups.length - tailGroups.length; if (missing < 0) return null; - return [...headGroups, ...Array(missing).fill('0'), ...tailGroups].map((g) => - g.toLowerCase().replace(/^0+(?=.)/, ''), - ); + return [...headGroups, ...Array(missing).fill('0'), ...tailGroups].map((g) => g.toLowerCase().replace(/^0+(?=.)/, '')); }; /** diff --git a/backend/src/utils/iso-date.ts b/backend/src/utils/iso-date.ts index c27247599..7cf5b4132 100644 --- a/backend/src/utils/iso-date.ts +++ b/backend/src/utils/iso-date.ts @@ -1,2 +1,5 @@ /** ISO 8601, e.g. "2025-02-18T12:34:56.789Z". */ export const getIsoDate = () => new Date().toISOString(); + +/** The current time for a mail reader, e.g. "2025-02-18 12:34:56 UTC". */ +export const utcStamp = () => `${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`; diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index a6ca082de..e08ae80a4 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -14,9 +14,7 @@ export const isBenchTraffic = (userId?: string, tenantId?: string) => { }; /** Ambient log context: the live Hono ctx, or a synthetic { var } for worker jobs. */ -export type LogContext = { - var: Partial>; -} | null; +export type LogContext = { var: Partial> } | null; const logContextStorage = new AsyncLocalStorage(); @@ -28,13 +26,9 @@ export const runWithLogContext = (ctx: LogContext, fn: () => T): T => logCont const extractBase = (ctx: LogContext) => { if (!ctx?.var) return {}; + // Pino leaves undefined values out of the line, so unset ids need no guard. const { tenantId, userId, organizationId, requestId } = ctx.var; - return { - ...(tenantId && { tenantId }), - ...(userId && { userId }), - ...(organizationId && { organizationId }), - ...(requestId && { requestId }), - }; + return { tenantId, userId, organizationId, requestId }; }; const logAt = diff --git a/backend/src/utils/order-column.test.ts b/backend/src/utils/order-column.test.ts index bc1de9643..394d94e78 100644 --- a/backend/src/utils/order-column.test.ts +++ b/backend/src/utils/order-column.test.ts @@ -4,32 +4,18 @@ import { describe, expect, it } from 'vitest'; import { getOrderColumns } from './order-column'; const dialect = new PgDialect(); -const columns = { - name: sql.identifier('name'), - createdAt: sql.identifier('created_at'), -}; +const columns = { name: sql.identifier('name'), createdAt: sql.identifier('created_at') }; const id = sql.identifier('id'); describe('getOrderColumns', () => { it('uses the fallback and appends a same-direction tie-breaker', () => { - const orderBy = getOrderColumns({ - sort: undefined, - order: undefined, - fallback: ['createdAt', 'desc'], - columns, - tieBreaker: id, - }); + const orderBy = getOrderColumns({ sort: undefined, order: undefined, fallback: ['createdAt', 'desc'], columns, tieBreaker: id }); expect(orderBy.map((expression) => dialect.sqlToQuery(expression).sql)).toEqual(['"created_at" desc', '"id" desc']); }); it('uses the requested sort and direction', () => { - const orderBy = getOrderColumns({ - sort: 'name', - order: 'asc', - fallback: ['createdAt', 'desc'], - columns, - }); + const orderBy = getOrderColumns({ sort: 'name', order: 'asc', fallback: ['createdAt', 'desc'], columns }); expect(dialect.sqlToQuery(orderBy[0]).sql).toBe('"name" asc'); }); @@ -45,10 +31,6 @@ describe('getOrderColumns', () => { append: [desc(updatedAt)], }); - expect(orderBy.map((expression) => dialect.sqlToQuery(expression).sql)).toEqual([ - '"name" desc', - '"id" desc', - '"updated_at" desc', - ]); + expect(orderBy.map((expression) => dialect.sqlToQuery(expression).sql)).toEqual(['"name" desc', '"id" desc', '"updated_at" desc']); }); }); diff --git a/backend/src/utils/rejection-utils.test.ts b/backend/src/utils/rejection-utils.test.ts new file mode 100644 index 000000000..1a17b8dfe --- /dev/null +++ b/backend/src/utils/rejection-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { filterWithRejection, takeWithRestriction } from '#/utils/rejection-utils'; + +const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + +describe('rejection utils', () => { + it('records each rejected id under its reason', () => { + const filtered = filterWithRejection(items, (item) => item.id !== 'b', 'slug_exists'); + const limited = takeWithRestriction(filtered.items, 1, 'org_limit_reached', filtered.rejectionState); + + expect(limited.items).toEqual([{ id: 'a' }]); + expect(limited.rejectionState).toEqual({ + rejectedIds: ['b', 'c'], + rejectionReasons: { slug_exists: ['b'], org_limit_reached: ['c'] }, + }); + }); + + // The client reads the reason keys to pick its error, so a reason without ids must not appear. + it('adds no reason when nothing is rejected for it', () => { + const filtered = filterWithRejection(items, () => false, 'slug_exists'); + const limited = takeWithRestriction(filtered.items, 1, 'org_limit_reached', filtered.rejectionState); + + expect(limited.items).toEqual([]); + expect(Object.keys(limited.rejectionState.rejectionReasons)).toEqual(['slug_exists']); + expect(filterWithRejection(items, () => true, 'slug_exists').rejectionState.rejectionReasons).toEqual({}); + }); +}); diff --git a/backend/src/utils/rejection-utils.ts b/backend/src/utils/rejection-utils.ts index 31ee87c63..c07ecd932 100644 --- a/backend/src/utils/rejection-utils.ts +++ b/backend/src/utils/rejection-utils.ts @@ -1,37 +1,16 @@ -export type RejectionState = { - rejectedIds: string[]; - rejectionReasons: Record; -}; - -export const createRejectionState = (): RejectionState => ({ - rejectedIds: [], - rejectionReasons: {}, -}); +export type RejectionState = { rejectedIds: string[]; rejectionReasons: Record }; -export const reject = (rejectionState: RejectionState, id: string, reason: string): RejectionState => ({ - rejectedIds: [...rejectionState.rejectedIds, id], - rejectionReasons: { - ...rejectionState.rejectionReasons, - [reason]: [...(rejectionState.rejectionReasons[reason] ?? []), id], - }, -}); +export const createRejectionState = (): RejectionState => ({ rejectedIds: [], rejectionReasons: {} }); -export const rejectMany = (rejectionState: RejectionState, ids: string[], reason: string): RejectionState => ({ - rejectedIds: [...rejectionState.rejectedIds, ...ids], - rejectionReasons: { - ...rejectionState.rejectionReasons, - [reason]: [...(rejectionState.rejectionReasons[reason] ?? []), ...ids], - }, -}); - -export const mergeRejections = (a: RejectionState, b: RejectionState): RejectionState => { - const merged = { ...a.rejectionReasons }; - for (const [reason, ids] of Object.entries(b.rejectionReasons)) { - merged[reason] = [...(merged[reason] ?? []), ...ids]; - } +/** No ids leaves the state untouched: clients read the reason keys, so a reason must not appear without ids. */ +const rejectMany = (rejectionState: RejectionState, ids: string[], reason: string): RejectionState => { + if (ids.length === 0) return rejectionState; return { - rejectedIds: [...a.rejectedIds, ...b.rejectedIds], - rejectionReasons: merged, + rejectedIds: [...rejectionState.rejectedIds, ...ids], + rejectionReasons: { + ...rejectionState.rejectionReasons, + [reason]: [...(rejectionState.rejectionReasons[reason] ?? []), ...ids], + }, }; }; @@ -42,14 +21,13 @@ export const filterWithRejection = ( rejectionState: RejectionState = createRejectionState(), ): { items: T[]; rejectionState: RejectionState } => { const passed: T[] = []; - let newState = rejectionState; - + const rejectedIds: string[] = []; for (const item of items) { if (predicate(item)) passed.push(item); - else newState = reject(newState, item.id, reason); + else rejectedIds.push(item.id); } - return { items: passed, rejectionState: newState }; + return { items: passed, rejectionState: rejectMany(rejectionState, rejectedIds, reason) }; }; export const takeWithRestriction = ( @@ -58,12 +36,6 @@ export const takeWithRestriction = ( reason: string, rejectionState: RejectionState = createRejectionState(), ): { items: T[]; rejectionState: RejectionState } => { - const taken = items.slice(0, restriction); - const excess = items.slice(restriction); - const excessIds = excess.map((item) => item.id); - - return { - items: taken, - rejectionState: rejectMany(rejectionState, excessIds, reason), - }; + const excessIds = items.slice(restriction).map((item) => item.id); + return { items: items.slice(0, restriction), rejectionState: rejectMany(rejectionState, excessIds, reason) }; }; diff --git a/backend/src/utils/seq-cursor.ts b/backend/src/utils/seq-cursor.ts index eb432ffb2..4e829c30f 100644 --- a/backend/src/utils/seq-cursor.ts +++ b/backend/src/utils/seq-cursor.ts @@ -8,8 +8,7 @@ import { gte, lte } from 'drizzle-orm'; export function parseSeqCursor(raw: string | undefined): { gte?: number; lte?: number } | undefined { if (!raw) return undefined; const parts = raw.split(',').map(Number); - if (parts.length === 2 && Number.isFinite(parts[0]) && Number.isFinite(parts[1])) - return { gte: parts[0], lte: parts[1] }; + if (parts.length === 2 && Number.isFinite(parts[0]) && Number.isFinite(parts[1])) return { gte: parts[0], lte: parts[1] }; return undefined; } diff --git a/backend/src/utils/validate-block-urls.test.ts b/backend/src/utils/validate-block-urls.test.ts index 2f90fbf3e..801d1bf42 100644 --- a/backend/src/utils/validate-block-urls.test.ts +++ b/backend/src/utils/validate-block-urls.test.ts @@ -10,21 +10,9 @@ const attachmentId = '0199a1b2-c3d4-7e5f-8a6b-7c8d9e0f1a2c'; const makeBlocks = (...blocks: Record[]) => JSON.stringify(blocks); -const image = (url: unknown) => ({ - id: '1', - type: 'image', - props: { url, caption: '', width: 512 }, - content: [], - children: [], -}); +const image = (url: unknown) => ({ id: '1', type: 'image', props: { url, caption: '', width: 512 }, content: [], children: [] }); -const video = (url: string) => ({ - id: '2', - type: 'video', - props: { url, caption: '' }, - content: [], - children: [], -}); +const video = (url: string) => ({ id: '2', type: 'video', props: { url, caption: '' }, content: [], children: [] }); const paragraph = (text: string) => ({ id: '3', @@ -64,15 +52,11 @@ describe('validateBlockMediaUrls', () => { it('passes a media block that holds no file yet', () => { expect(validateBlockMediaUrls(makeBlocks(image('')), ctx)).toEqual({ valid: true }); - expect(validateBlockMediaUrls(makeBlocks({ id: '1', type: 'image', props: {}, children: [] }), ctx)).toEqual({ - valid: true, - }); + expect(validateBlockMediaUrls(makeBlocks({ id: '1', type: 'image', props: {}, children: [] }), ctx)).toEqual({ valid: true }); }); it('leaves inline links alone: only media blocks load their reference', () => { - expect(validateBlockMediaUrls(makeBlocks(linkParagraph('https://evil.example/phishing')), ctx)).toEqual({ - valid: true, - }); + expect(validateBlockMediaUrls(makeBlocks(linkParagraph('https://evil.example/phishing')), ctx)).toEqual({ valid: true }); }); }); @@ -80,18 +64,12 @@ describe('validateBlockMediaUrls', () => { it('must not load media from another host via a URL that starts like the CDN', () => { const cdn = appConfig.s3.publicCDNUrl; const bypasses = [`${cdn}@evil.example/pixel.png`, `${cdn}.evil.example/pixel.png`]; - expect(validateBlockMediaUrls(makeBlocks(...bypasses.map(image)), ctx)).toEqual({ - valid: false, - invalidUrls: bypasses, - }); + expect(validateBlockMediaUrls(makeBlocks(...bypasses.map(image)), ctx)).toEqual({ valid: false, invalidUrls: bypasses }); }); it('must not load media from another host via a protocol-relative or backslash reference', () => { const bypasses = ['//evil.example/pixel.png', '\\\\evil.example\\pixel.png']; - expect(validateBlockMediaUrls(makeBlocks(...bypasses.map(image)), ctx)).toEqual({ - valid: false, - invalidUrls: bypasses, - }); + expect(validateBlockMediaUrls(makeBlocks(...bypasses.map(image)), ctx)).toEqual({ valid: false, invalidUrls: bypasses }); }); it('must not load media via any absolute URL, the former allowlist and the own CDN included', () => { @@ -133,24 +111,15 @@ describe('validateBlockMediaUrls', () => { }); it('handles malformed and non-array JSON', () => { - expect(validateBlockMediaUrls('not valid json {{{', ctx)).toEqual({ - valid: false, - invalidUrls: ['[malformed JSON]'], - }); - expect(validateBlockMediaUrls('{"type": "not-an-array"}', ctx)).toEqual({ - valid: false, - invalidUrls: ['[invalid block structure]'], - }); + expect(validateBlockMediaUrls('not valid json {{{', ctx)).toEqual({ valid: false, invalidUrls: ['[malformed JSON]'] }); + expect(validateBlockMediaUrls('{"type": "not-an-array"}', ctx)).toEqual({ valid: false, invalidUrls: ['[invalid block structure]'] }); }); it('must not hide a media block under a node whose type is not a string', () => { const hidden = '//evil.example/pixel.png'; for (const type of [123, null, ['image']]) { const blocks = makeBlocks({ id: '6', type, props: {}, children: [image(hidden)] }); - expect(validateBlockMediaUrls(blocks, ctx), JSON.stringify(type)).toEqual({ - valid: false, - invalidUrls: [hidden], - }); + expect(validateBlockMediaUrls(blocks, ctx), JSON.stringify(type)).toEqual({ valid: false, invalidUrls: [hidden] }); } const untyped = makeBlocks({ id: '6', props: {}, children: [image(hidden)] }); expect(validateBlockMediaUrls(untyped, ctx)).toEqual({ valid: false, invalidUrls: [hidden] }); @@ -159,19 +128,14 @@ describe('validateBlockMediaUrls', () => { it('must not store a media block whose props is not an object', () => { for (const props of ['https://evil.example/pixel.png', null, 1, ['https://evil.example/pixel.png']]) { const blocks = makeBlocks({ id: '7', type: 'image', props, content: [], children: [] }); - expect(validateBlockMediaUrls(blocks, ctx), JSON.stringify(props)).toEqual({ - valid: false, - invalidUrls: ['[invalid props]'], - }); + expect(validateBlockMediaUrls(blocks, ctx), JSON.stringify(props)).toEqual({ valid: false, invalidUrls: ['[invalid props]'] }); } const withoutProps = makeBlocks({ id: '8', type: 'video', content: [], children: [] }); expect(validateBlockMediaUrls(withoutProps, ctx)).toEqual({ valid: false, invalidUrls: ['[invalid props]'] }); }); it('skips list items that are not blocks', () => { - expect(validateBlockMediaUrls('[null, 1, "text", {"props": {"url": "//evil.example"}}]', ctx)).toEqual({ - valid: true, - }); + expect(validateBlockMediaUrls('[null, 1, "text", {"props": {"url": "//evil.example"}}]', ctx)).toEqual({ valid: true }); }); }); }); diff --git a/backend/src/utils/validate-block-urls.ts b/backend/src/utils/validate-block-urls.ts index 51bf35dba..785b0028b 100644 --- a/backend/src/utils/validate-block-urls.ts +++ b/backend/src/utils/validate-block-urls.ts @@ -29,12 +29,7 @@ export const validateBlockMediaUrls = (blocksJson: string, ctx: MediaRefContext) * Refuses (400) a document whose media blocks reference anything but an attachment id, a storage key under * `organizationId` or a re-hosted asset. */ -export const assertBlockMediaUrls = ( - blocksJson: string, - organizationId: string, - entityType: EntityType, - fieldName: string, -) => { +export const assertBlockMediaUrls = (blocksJson: string, organizationId: string, entityType: EntityType, fieldName: string) => { const result = validateBlockMediaUrls(blocksJson, { organizationId }); if (!result.valid) { throw new AppError(400, 'invalid_request', 'warn', { diff --git a/backend/src/utils/within-timeout.ts b/backend/src/utils/within-timeout.ts index 36380004f..87a33e1aa 100644 --- a/backend/src/utils/within-timeout.ts +++ b/backend/src/utils/within-timeout.ts @@ -6,11 +6,7 @@ * @param what - Names the call in the error a timeout returns. * @returns Undefined when `pending` resolved in time; else its rejection, or an error saying `what` got no answer. */ -export async function withinTimeout( - pending: Promise, - ms: number, - what = 'The call', -): Promise { +export async function withinTimeout(pending: Promise, ms: number, what = 'The call'): Promise { let timer: NodeJS.Timeout | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(new Error(`${what} got no answer within ${ms} ms`)), ms); diff --git a/backend/tests/README.md b/backend/tests/README.md index 6b540636b..afd90154f 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -9,7 +9,7 @@ tests/ ├── fixtures.ts # Test constants (dumb data, headers, base configs) ├── helpers.ts # Test logic helpers (inserting/fetching domain models) ├── setup.ts # Test environment control (DB, config, app mock, lifecycle) -├── global-setup.ts # Prepares the shared test database once per run +├── global-setup.ts # Creates and migrates the test databases (shared + one per worker) once per run ├── test-client.ts # HTTP client against the app under test ├── integration/ # Tests that need more than the test database (excluded in core mode) └── / # Route-level tests grouped by area (sign-in, security, invitations, ...) diff --git a/backend/tests/attachment-notifications.test.ts b/backend/tests/attachment-notifications.test.ts index 079afd5c1..cf654d782 100644 --- a/backend/tests/attachment-notifications.test.ts +++ b/backend/tests/attachment-notifications.test.ts @@ -3,20 +3,24 @@ import { type GetNotificationsResponse, getNotifications, updateAttachment } fro import { appConfig } from 'shared'; import type { TestEntityHierarchyPlan } from 'shared/testing/entity-hierarchy'; import { generateId } from 'shared/utils/entity-id'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; import { generateServerHLC } from '#/core/stx'; import { getSeedDb } from '#/db/db'; import type { ActivityEvent } from '#/lib/activity-bus'; import { buildInsertableProduct } from '#/mocks'; import { attachmentsTable } from '#/modules/attachment/attachment-db'; -import { notificationsTable } from '#/modules/notification/notification-db'; +import { commentEmail } from '#/modules/notification/emails/comment-email'; +import { mentionEmail } from '#/modules/notification/emails/mention-email'; +import { notificationPreferencesTable, notificationsTable } from '#/modules/notification/notification-db'; +import { getNotificationSource } from '#/modules/notification/notification-sources'; import { fanOutNotifications } from '#/modules/notification/operations/fan-out'; import { sendPendingInstantEmails } from '#/modules/notification/operations/send-instant-emails'; +import { sendNotificationPush } from '#/modules/push/push-sender'; import { emailsTable } from '#/modules/user/emails-db'; import { materializeDescriptionOp } from '#/modules/yjs/operations/materialize-description'; import { mockStxBase } from '#/schemas/sync-transaction-mocks'; -import { adminRole, defaultHeaders, memberRole } from './fixtures'; -import { createOrganizationAdminUser, createTestUser } from './helpers'; +import { adminRole, defaultHeaders, memberRole, overrideConfig } from './fixtures'; +import { createOrganizationAdminUser, createTestUser, mailsTo } from './helpers'; import { cleanupEntityHierarchy, insertAttachmentRow, seedAttachmentHome } from './hierarchy-helpers'; import { clearSecurityTestData, createOrgUser, createTestTenant, type TestTenant } from './security/helpers'; import { createAppClient } from './test-client'; @@ -27,6 +31,16 @@ const db = getSeedDb(); setTestConfig({ enabledAuthStrategies: ['passkey'] }); +// Push sending is on and records its payloads; nothing reaches a push service. +vi.mock('#/modules/push/push-sender', async (importOriginal) => ({ + ...(await importOriginal()), + isPushSendConfigured: () => true, + sendNotificationPush: vi.fn(async () => undefined), +})); + +/** The context id a notification link carries, as the `/n` route reads it. */ +const linkedContextId = (link: unknown) => new URL(String(link)).searchParams.get('contextId'); + const attachmentId = generateId(); const paragraphWithMentions = (ids: string[]) => ({ @@ -37,6 +51,9 @@ const paragraphWithMentions = (ids: string[]) => ({ children: [], }); +/** A stored body whose one paragraph mentions the given users. */ +const mentionsOf = (ids: string[]) => JSON.stringify([paragraphWithMentions(ids)]); + const paragraphWithText = (text: string) => ({ id: generateId(), type: 'paragraph', @@ -45,10 +62,7 @@ const paragraphWithText = (text: string) => ({ children: [], }); -const updateStx = () => ({ - ...mockStxBase(`stx:${generateId()}`), - fieldTimestamps: { description: generateServerHLC('test-client') }, -}); +const updateStx = () => ({ ...mockStxBase(`stx:${generateId()}`), fieldTimestamps: { description: generateServerHLC('test-client') } }); const nullAncestorScopes = Object.fromEntries( appConfig.channelEntityTypes @@ -57,36 +71,25 @@ const nullAncestorScopes = Object.fromEntries( ); // Covers the attachment notification source, the template consumer of the notifications contract: -// `mentions` is derived server-side from the description on client writes and on Yjs -// materialization, keeps only users who may read the row, fans out to the inbox and mails. +// the fan-out reads mentions from the stored description of a created row or a changed body, +// keeps only users who may read the row, writes the inbox and mails. describe('Attachment mentions (template notification source)', async () => { const call = await createAppClient(); let tenant: TestTenant; - let member: { id: string; sessionCookie: string }; + let member: { id: string; email: string; sessionCookie: string }; /** An account with no membership in the organization: a mention of it names someone who may not read the row. */ let stranger: { id: string }; let plan: TestEntityHierarchyPlan; - const putDescription = async (description: string) => + const putDescription = async (description: string, id = attachmentId) => call(updateAttachment, { - path: { organizationId: tenant.organization.id, tenantId: tenant.tenantId, id: attachmentId }, + path: { organizationId: tenant.organization.id, tenantId: tenant.tenantId, id }, body: { ops: { description }, stx: updateStx() }, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, }); - const storedMentions = async () => { - const [row] = await db - .select({ mentions: attachmentsTable.mentions }) - .from(attachmentsTable) - .where(eq(attachmentsTable.id, attachmentId)); - return row.mentions; - }; - const storedKeywords = async () => { - const [row] = await db - .select({ keywords: attachmentsTable.keywords }) - .from(attachmentsTable) - .where(eq(attachmentsTable.id, attachmentId)); + const [row] = await db.select({ keywords: attachmentsTable.keywords }).from(attachmentsTable).where(eq(attachmentsTable.id, attachmentId)); return row.keywords; }; @@ -96,7 +99,7 @@ describe('Attachment mentions (template notification source)', async () => { .from(notificationsTable) .where(and(eq(notificationsTable.userId, userId), eq(notificationsTable.subjectId, attachmentId))); - const updatedEvent = (actorId: string): ActivityEvent => + const updatedEvent = (actorId: string, overrides: Partial = {}): ActivityEvent => // Test mock: the CDC worker fills the remaining columns; the fan-out reads only these. ({ id: `act:${generateId()}`, @@ -118,32 +121,20 @@ describe('Attachment mentions (template notification source)', async () => { trace: null, stx: null, changedFields: ['description'], + ...overrides, }) as unknown as ActivityEvent; beforeAll(async () => { tenant = await createTestTenant(call, 'attachment-mentions'); // The role that reads every attachment under any app's permission matrix; the stranger covers the drop path. - member = await createOrgUser( - call, - tenant.tenantId, - tenant.organization.id, - 'attachment-mentions-member', - adminRole, - ); + member = await createOrgUser(call, tenant.tenantId, tenant.organization.id, 'attachment-mentions-member', adminRole); stranger = await createTestUser('attachment-mentions-stranger@security-test.com'); plan = await seedAttachmentHome({ id: tenant.organization.id, tenantId: tenant.tenantId }, tenant.user.id); const row = buildInsertableProduct( 'attachment', - { - id: attachmentId, - tenantId: tenant.tenantId, - ...plan.channelIdColumns, - createdBy: tenant.user.id, - updatedBy: null, - deletedBy: null, - }, + { id: attachmentId, tenantId: tenant.tenantId, ...plan.channelIdColumns, createdBy: tenant.user.id, updatedBy: null, deletedBy: null }, attachmentId, ); await insertAttachmentRow(row); @@ -156,22 +147,8 @@ describe('Attachment mentions (template notification source)', async () => { await clearSecurityTestData(); }); - it('stores readable mentioned users and drops an account without read access', async () => { - const result = await putDescription(JSON.stringify([paragraphWithMentions([member.id, stranger.id])])); - expect(result.response.status).toBe(200); - expect(await storedMentions()).toEqual([member.id]); - }); - - it('clears mentions once the description no longer carries them', async () => { - const result = await putDescription(JSON.stringify([paragraphWithMentions([])])); - expect(result.response.status).toBe(200); - expect(await storedMentions()).toEqual([]); - }); - it('re-derives the keywords search column from the description on both write paths', async () => { - const result = await putDescription( - JSON.stringify([paragraphWithText('quarterly budget'), paragraphWithMentions([member.id])]), - ); + const result = await putDescription(JSON.stringify([paragraphWithText('quarterly budget'), paragraphWithMentions([member.id])])); expect(result.response.status).toBe(200); expect(await storedKeywords()).toContain('quarterly budget'); @@ -188,35 +165,57 @@ describe('Attachment mentions (template notification source)', async () => { expect(keywords).not.toContain('quarterly budget'); }); - it('derives from Yjs materialization too, the write path of the collaborative editor', async () => { + it('fans out a mention a Yjs materialization wrote to the inbox and mails it instantly, never to the actor', async () => { await materializeDescriptionOp({ entityType: 'attachment', entityId: attachmentId, tenantId: tenant.tenantId, organizationId: tenant.organization.id, - description: JSON.stringify([paragraphWithMentions([member.id])]), + description: mentionsOf([member.id]), editors: [tenant.user.id], }); - expect(await storedMentions()).toEqual([member.id]); - }); - it('fans out a mention to the inbox and mails it instantly, never to the actor', async () => { - await fanOutNotifications(updatedEvent(member.id)); + // The fan-out reports whether it wrote a row the instant pass mails; only then does the listener run the pass. + expect(await fanOutNotifications(updatedEvent(member.id))).toBe(false); expect(await notificationsFor(member.id)).toEqual([]); - await fanOutNotifications(updatedEvent(tenant.user.id)); + expect(await fanOutNotifications(updatedEvent(tenant.user.id))).toBe(true); expect(await notificationsFor(member.id)).toEqual([{ type: 'mention', emailedAt: null }]); + // The push link opens the notification's context, which defaults to the row itself. + const [, payload] = vi.mocked(sendNotificationPush).mock.calls.at(-1) ?? []; + expect(linkedContextId(payload?.url)).toBe(attachmentId); + + // A later edit of the same body tells nobody twice. + expect(await fanOutNotifications(updatedEvent(tenant.user.id))).toBe(false); // Mention email is on by default; the member's address is verified. await sendPendingInstantEmails(tenant.organization.id); expect((await notificationsFor(member.id))[0]?.emailedAt).not.toBeNull(); }); - it('lists the inbox row with the actor, channel and subject the card sentence needs', async () => { - const result = await call(getNotifications, { - query: { limit: 10 }, - headers: { ...defaultHeaders, Cookie: member.sessionCookie }, + it('links the mention mail to the notification context, the item hosting the subject', async () => { + const hostId = generateId(); + await db.insert(notificationsTable).values({ + userId: member.id, + actorId: tenant.user.id, + type: 'mention', + entityType: 'attachment', + subjectId: attachmentId, + contextId: hostId, + channelId: tenant.organization.id, + channelType: 'organization', + organizationId: tenant.organization.id, + tenantId: tenant.tenantId, + activityId: `act:${generateId()}`, }); + + await sendPendingInstantEmails(tenant.organization.id); + const [mail] = mailsTo(member.email); + expect(linkedContextId(mail?.recipient.link)).toBe(hostId); + }); + + it('lists the inbox row with the actor, channel and subject the card sentence needs', async () => { + const result = await call(getNotifications, { query: { limit: 10 }, headers: { ...defaultHeaders, Cookie: member.sessionCookie } }); expect(result.response.status).toBe(200); // The test client types the body loosely; the SDK response type names the fields under test. const [row] = (result.data as GetNotificationsResponse | undefined)?.items ?? []; @@ -251,21 +250,13 @@ describe('Attachment mentions (template notification source)', async () => { }); const anHourAgo = new Date(Date.now() - 60 * 60 * 1000); await db.insert(notificationsTable).values(Array.from({ length: 201 }, () => mentionOf(unverified.id, anHourAgo))); - const [fresh] = await db - .insert(notificationsTable) - .values(mentionOf(member.id, new Date())) - .returning({ id: notificationsTable.id }); + const [fresh] = await db.insert(notificationsTable).values(mentionOf(member.id, new Date())).returning({ id: notificationsTable.id }); await sendPendingInstantEmails(tenant.organization.id); await sendPendingInstantEmails(tenant.organization.id); const emailedAt = async (id: string) => - ( - await db - .select({ emailedAt: notificationsTable.emailedAt }) - .from(notificationsTable) - .where(eq(notificationsTable.id, id)) - )[0]?.emailedAt; + (await db.select({ emailedAt: notificationsTable.emailedAt }).from(notificationsTable).where(eq(notificationsTable.id, id)))[0]?.emailedAt; expect(await emailedAt(fresh.id)).not.toBeNull(); // The unmailable rows are settled too, so no later pass reads them again. const backlog = await db @@ -274,4 +265,232 @@ describe('Attachment mentions (template notification source)', async () => { .where(eq(notificationsTable.userId, unverified.id)); expect(backlog.filter(({ emailedAt }) => emailedAt === null)).toHaveLength(0); }); + + // Each test edits a subject of its own, so one test's inbox rows never dedupe another's. + describe('mentions read from the stored body', () => { + /** A second readable member, mentioned next to `member`. */ + let other: { id: string }; + const subjectIds: string[] = []; + + const newSubject = async (description: string | null = null) => { + const id = generateId(); + subjectIds.push(id); + const row = buildInsertableProduct( + 'attachment', + { id, tenantId: tenant.tenantId, ...plan.channelIdColumns, description, createdBy: tenant.user.id, updatedBy: null, deletedBy: null }, + id, + ); + await insertAttachmentRow(row); + return id; + }; + + const inboxOf = (userId: string, subjectId: string) => + db + .select({ type: notificationsTable.type }) + .from(notificationsTable) + .where(and(eq(notificationsTable.userId, userId), eq(notificationsTable.subjectId, subjectId))); + + const createdEvent = (subjectId: string) => + updatedEvent(tenant.user.id, { subjectId, type: 'attachment.created', action: 'create', changedFields: null }); + + beforeAll(async () => { + other = await createOrgUser(call, tenant.tenantId, tenant.organization.id, 'attachment-mentions-other', adminRole); + }); + + afterAll(async () => { + if (!subjectIds.length) return; + await db.delete(notificationsTable).where(inArray(notificationsTable.subjectId, subjectIds)); + await db.delete(attachmentsTable).where(inArray(attachmentsTable.id, subjectIds)); + }); + + it('adds no mention on an update that leaves the description alone', async () => { + const subjectId = await newSubject(); + expect((await putDescription(mentionsOf([member.id]), subjectId)).response.status).toBe(200); + + const renamed = updatedEvent(tenant.user.id, { subjectId, changedFields: ['name', 'updatedAt'] }); + expect(await fanOutNotifications(renamed)).toBe(false); + expect(await inboxOf(member.id, subjectId)).toEqual([]); + }); + + it('mentions each user a description change adds, once', async () => { + // Stored without the update op: the fan-out reads whatever body the row holds. + const subjectId = await newSubject(mentionsOf([member.id])); + expect(await fanOutNotifications(createdEvent(subjectId))).toBe(true); + expect(await inboxOf(member.id, subjectId)).toEqual([{ type: 'mention' }]); + + expect((await putDescription(mentionsOf([member.id, other.id]), subjectId)).response.status).toBe(200); + expect(await fanOutNotifications(updatedEvent(tenant.user.id, { subjectId }))).toBe(true); + expect(await fanOutNotifications(updatedEvent(tenant.user.id, { subjectId }))).toBe(false); + expect(await inboxOf(member.id, subjectId)).toEqual([{ type: 'mention' }]); + expect(await inboxOf(other.id, subjectId)).toEqual([{ type: 'mention' }]); + }); + + it('notifies nobody about a mention of an account without read access', async () => { + const subjectId = await newSubject(); + expect((await putDescription(mentionsOf([stranger.id]), subjectId)).response.status).toBe(200); + + expect(await fanOutNotifications(updatedEvent(tenant.user.id, { subjectId }))).toBe(false); + expect(await inboxOf(stranger.id, subjectId)).toEqual([]); + }); + + it('sends no mention from a source declared mentionable: false', async () => { + const source = getNotificationSource('attachment'); + if (!source) throw new Error('attachment notification source not registered'); + source.declaration.mentionable = false; + onTestFinished(() => { + delete source.declaration.mentionable; + }); + + const subjectId = await newSubject(mentionsOf([member.id])); + expect(await fanOutNotifications(createdEvent(subjectId))).toBe(false); + expect(await inboxOf(member.id, subjectId)).toEqual([]); + }); + }); + + // The template emits no comment or reply rows (an app's `resolveRecipients` does), so these tests write the rows + // directly, or give the attachment source a recipient resolver for one test. + describe('comment emails', () => { + /** A second live subject, so the reply mail is not folded into the comment mail on `attachmentId`. */ + const replySubjectId = generateId(); + + const rowOf = (type: 'mention' | 'comment' | 'reply', subjectId = attachmentId) => ({ + userId: member.id, + actorId: tenant.user.id, + type, + entityType: 'attachment' as const, + subjectId, + contextId: subjectId, + channelId: tenant.organization.id, + channelType: 'organization' as const, + organizationId: tenant.organization.id, + tenantId: tenant.tenantId, + activityId: `act:${generateId()}`, + }); + + /** Rows for this test only: a row the pass leaves pending must not reach the next test's pass. */ + const insertRows = async (...rows: ReturnType[]) => { + const inserted = await db.insert(notificationsTable).values(rows).returning({ id: notificationsTable.id }); + const ids = inserted.map(({ id }) => id); + onTestFinished(async () => { + await db.delete(notificationsTable).where(inArray(notificationsTable.id, ids)); + }); + return ids; + }; + + /** The given rows the instant pass has not taken; the digest still covers these. */ + const unemailed = async (ids: string[]) => + ( + await db + .select({ id: notificationsTable.id, emailedAt: notificationsTable.emailedAt }) + .from(notificationsTable) + .where(inArray(notificationsTable.id, ids)) + ).filter(({ emailedAt }) => emailedAt === null); + + const setCommentEmail = (commentEmail: boolean) => + db + .insert(notificationPreferencesTable) + .values({ userId: member.id, commentEmail }) + .onConflictDoUpdate({ target: notificationPreferencesTable.userId, set: { commentEmail } }); + + const offerCommentEmail = () => onTestFinished(overrideConfig(appConfig.has, { commentEmail: true })); + // Each case starts with comment email not offered, whatever the app's default is. + let restoreCommentEmail: () => void; + + beforeAll(async () => { + restoreCommentEmail = overrideConfig(appConfig.has, { commentEmail: false }); + const row = buildInsertableProduct( + 'attachment', + { id: replySubjectId, tenantId: tenant.tenantId, ...plan.channelIdColumns, createdBy: tenant.user.id, updatedBy: null, deletedBy: null }, + replySubjectId, + ); + await insertAttachmentRow(row); + }); + + afterAll(async () => { + restoreCommentEmail(); + await db.delete(notificationsTable).where(eq(notificationsTable.subjectId, replySubjectId)); + await db.delete(attachmentsTable).where(eq(attachmentsTable.id, replySubjectId)); + await db.delete(notificationPreferencesTable).where(eq(notificationPreferencesTable.userId, member.id)); + }); + + it('mails comment and reply rows when the app offers comment email and the recipient turned it on', async () => { + offerCommentEmail(); + await setCommentEmail(true); + const ids = await insertRows(rowOf('comment'), rowOf('reply', replySubjectId)); + + await sendPendingInstantEmails(tenant.organization.id); + + const mails = mailsTo(member.email); + expect(mails.map(({ template, statics }) => [template, statics.reply])).toEqual( + expect.arrayContaining([ + [commentEmail, false], + [commentEmail, true], + ]), + ); + expect(mails).toHaveLength(2); + for (const { recipient } of mails) expect(String(recipient.unsubscribeLink)).toContain('category=comment'); + expect(await unemailed(ids)).toEqual([]); + }); + + it('leaves comment rows to the digest when the recipient keeps comment email off', async () => { + offerCommentEmail(); + await setCommentEmail(false); + const ids = await insertRows(rowOf('comment')); + + await sendPendingInstantEmails(tenant.organization.id); + + expect(mailsTo(member.email)).toEqual([]); + expect(await unemailed(ids)).toHaveLength(1); + }); + + it('mails no comment row while the app does not offer comment email', async () => { + expect(appConfig.has.commentEmail).toBe(false); + await setCommentEmail(true); + const ids = await insertRows(rowOf('comment')); + + await sendPendingInstantEmails(tenant.organization.id); + + expect(mailsTo(member.email)).toEqual([]); + expect(await unemailed(ids)).toHaveLength(1); + }); + + it('mails a mention and a comment on the same subject once, as the mention', async () => { + offerCommentEmail(); + await setCommentEmail(true); + const ids = await insertRows(rowOf('comment'), rowOf('mention')); + + await sendPendingInstantEmails(tenant.organization.id); + + expect(mailsTo(member.email).map(({ template }) => template)).toEqual([mentionEmail]); + // The mention mail settles the comment too, so the digest does not repeat it. + expect(await unemailed(ids)).toEqual([]); + }); + + it('reports a fan-out that wrote a comment row as mailable only while the app offers comment email', async () => { + const source = getNotificationSource('attachment'); + if (!source) throw new Error('attachment notification source not registered'); + source.declaration.resolveRecipients = async () => [{ userId: member.id, type: 'comment' }]; + onTestFinished(() => { + delete source.declaration.resolveRecipients; + }); + // A create event: an update skips recipients already notified about the subject. + const createdEvent = () => + ({ + ...updatedEvent(tenant.user.id), + id: `act:${generateId()}`, + type: 'attachment.created', + action: 'create', + subjectId: replySubjectId, + }) as ActivityEvent; + + expect(await fanOutNotifications(createdEvent())).toBe(false); + offerCommentEmail(); + expect(await fanOutNotifications(createdEvent())).toBe(true); + const written = await db + .select({ type: notificationsTable.type }) + .from(notificationsTable) + .where(eq(notificationsTable.subjectId, replySubjectId)); + expect(written).toEqual([{ type: 'comment' }, { type: 'comment' }]); + }); + }); }); diff --git a/backend/tests/attachment-own-reads.test.ts b/backend/tests/attachment-own-reads.test.ts index a7163bb49..920b825dd 100644 --- a/backend/tests/attachment-own-reads.test.ts +++ b/backend/tests/attachment-own-reads.test.ts @@ -14,11 +14,7 @@ import { setTestConfig } from './test-utils'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); const projectId = generateId(); -const attachmentIds = { - ownedByA: generateId(), - ownedByB: generateId(), - ownedByAdmin: generateId(), -}; +const attachmentIds = { ownedByA: generateId(), ownedByB: generateId(), ownedByAdmin: generateId() }; // Covers row-conditional attachment reads through policy, collection scope, // compiled SQL predicate, and HTTP responses. diff --git a/backend/tests/attachment-seq-reads.test.ts b/backend/tests/attachment-seq-reads.test.ts index fc21b3eb0..ba0954cfe 100644 --- a/backend/tests/attachment-seq-reads.test.ts +++ b/backend/tests/attachment-seq-reads.test.ts @@ -14,13 +14,7 @@ import { setTestConfig } from './test-utils'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); -const attachmentIds = { - seq10: generateId(), - seq20: generateId(), - seq30Deleted: generateId(), - seq40: generateId(), - seq50: generateId(), -}; +const attachmentIds = { seq10: generateId(), seq20: generateId(), seq30Deleted: generateId(), seq40: generateId(), seq50: generateId() }; const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); @@ -49,16 +43,7 @@ describe('Attachment seq reads', async () => { // Audit users beyond createdBy are nulled: the mock's random ids reference no users rows. buildInsertableProduct( 'attachment', - { - id, - tenantId: tenant.tenantId, - ...plan.channelIdColumns, - createdBy: tenant.user.id, - updatedBy: null, - deletedBy: null, - seq, - ...extra, - }, + { id, tenantId: tenant.tenantId, ...plan.channelIdColumns, createdBy: tenant.user.id, updatedBy: null, deletedBy: null, seq, ...extra }, key, ); const rows = [ diff --git a/backend/tests/auth-strategies/enforcement.test.ts b/backend/tests/auth-strategies/enforcement.test.ts index 015baaf40..789dec691 100644 --- a/backend/tests/auth-strategies/enforcement.test.ts +++ b/backend/tests/auth-strategies/enforcement.test.ts @@ -1,24 +1,7 @@ -import { - createTotp, - generatePasskeyChallenge, - generateTotpKey, - github, - google, - microsoft, - signInWithTotp, - toggleMfa, -} from 'sdk'; +import { createTotp, generatePasskeyChallenge, generateTotpKey, github, google, invokeToken, microsoft, signInWithTotp, toggleMfa } from 'sdk'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { defaultHeaders } from '../fixtures'; -import { - authCookie, - createMfaToken, - createTestSession, - createTestUser, - createTotpUser, - type ErrorResponse, - expectRefusal, -} from '../helpers'; +import { authCookie, createMfaToken, createTestSession, createTestUser, createTotpUser, type ErrorResponse, expectRefusal } from '../helpers'; import { passkeySignIn } from '../security/helpers'; import { softwarePasskey } from '../software-passkey'; import { createAppClient } from '../test-client'; @@ -30,11 +13,7 @@ afterEach(async () => { describe('oauth strategy disabled', async () => { beforeAll(() => { - setTestConfig({ - enabledAuthStrategies: ['passkey', 'totp'], - enabledOAuthProviders: [], - selfRegistration: true, - }); + setTestConfig({ enabledAuthStrategies: ['passkey', 'totp'], enabledOAuthProviders: [], selfRegistration: true }); }); const call = await createAppClient(); @@ -52,11 +31,7 @@ describe('oauth strategy disabled', async () => { // OAuth provider configuration: only GitHub enabled. describe('oauth provider configuration', async () => { beforeAll(() => { - setTestConfig({ - enabledAuthStrategies: ['oauth'], - enabledOAuthProviders: ['github'], - selfRegistration: true, - }); + setTestConfig({ enabledAuthStrategies: ['oauth'], enabledOAuthProviders: ['github'], selfRegistration: true }); }); const call = await createAppClient(); @@ -80,18 +55,12 @@ describe('oauth provider configuration', async () => { describe('passkey strategy disabled', async () => { beforeAll(() => { - setTestConfig({ - enabledAuthStrategies: ['oauth', 'totp'], - selfRegistration: true, - }); + setTestConfig({ enabledAuthStrategies: ['oauth', 'totp'], selfRegistration: true }); }); const call = await createAppClient(); it('should reject passkey generation', async () => { - const { response: res, error } = await call(generatePasskeyChallenge, { - body: { type: 'registration' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(generatePasskeyChallenge, { body: { type: 'registration' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 400, 'forbidden_strategy'); }); @@ -103,10 +72,7 @@ describe('passkey strategy disabled', async () => { describe('totp strategy disabled', async () => { beforeAll(() => { - setTestConfig({ - enabledAuthStrategies: ['oauth', 'passkey'], - selfRegistration: true, - }); + setTestConfig({ enabledAuthStrategies: ['oauth', 'passkey'], selfRegistration: true }); }); const call = await createAppClient(); @@ -137,10 +103,7 @@ describe('totp strategy disabled', async () => { describe('all strategies disabled', async () => { beforeAll(() => { - setTestConfig({ - enabledAuthStrategies: [], - selfRegistration: true, - }); + setTestConfig({ enabledAuthStrategies: [], selfRegistration: true }); }); const call = await createAppClient(); @@ -167,3 +130,22 @@ describe('passkey strategy disabled', () => { await expectRefusal(refused, 400, 'forbidden_strategy'); }); }); + +describe('magic strategy disabled', async () => { + beforeAll(() => { + setTestConfig({ enabledAuthStrategies: ['passkey', 'totp'], selfRegistration: true }); + }); + const call = await createAppClient(); + + // The invoke route serves every link type, so only a magic link checks the magic switch. + it('must not open a magic link while magic links are off', async () => { + const refused = await call(invokeToken, { path: { type: 'magic', token: 'any' }, headers: defaultHeaders }); + await expectRefusal(refused, 400, 'forbidden_strategy'); + }); + + it('opens other link types while magic links are off (positive control)', async () => { + const { response, error } = await call(invokeToken, { path: { type: 'invitation', token: 'any' }, headers: defaultHeaders }); + expect((error as ErrorResponse | undefined)?.type).not.toBe('forbidden_strategy'); + expect(response.status).not.toBe(400); + }); +}); diff --git a/backend/tests/auth-strategies/route-strategies.test.ts b/backend/tests/auth-strategies/route-strategies.test.ts index c8b6830c1..8b665bdaa 100644 --- a/backend/tests/auth-strategies/route-strategies.test.ts +++ b/backend/tests/auth-strategies/route-strategies.test.ts @@ -1,30 +1,33 @@ +import type { ConfigSwitch } from 'shared'; import { describe, expect, it } from 'vitest'; -import { authGeneralRoutes } from '#/modules/auth/general/general-routes'; import { authMagicLinkRoutes } from '#/modules/auth/magic/magic-routes'; import { authOAuthRoutes } from '#/modules/auth/oauth/oauth-routes'; import { authPasskeysRoutes } from '#/modules/auth/passkeys/passkeys-routes'; import { authTotpsRoutes } from '#/modules/auth/totps/totps-routes'; /** - * Every route of a sign-in method declares its strategy, so switching the method off in `appConfig` refuses the route - * before any handler runs. A route that stays reachable while its method is off (deleting a factor) says so with - * `null` (`none` in the spec); an undeclared route fails here. + * Every route of a sign-in method names that method as its config switch (`xEnabledBy`), so switching the method off + * in `appConfig` refuses the route before its guards run. The routes that stay reachable while their method is off + * (deleting a factor) are listed here; any other route without the switch fails. The magic link route checks its + * switch per token type, in its handler (enforcement.test.ts). */ -const strategyRoutes = { - totp: authTotpsRoutes, - passkey: authPasskeysRoutes, - magic: authMagicLinkRoutes, - oauth: authOAuthRoutes, -}; +const strategyRoutes = { totp: authTotpsRoutes, passkey: authPasskeysRoutes, magic: authMagicLinkRoutes, oauth: authOAuthRoutes }; +const reachableWhileOff = new Set(['deletePasskey', 'deleteTotp']); -describe('auth routes declare their sign-in method', () => { +const switchOf = (route: object) => (route as { 'x-enabled-by'?: ConfigSwitch })['x-enabled-by']; + +describe('auth routes name their sign-in method as their switch', () => { for (const [method, routes] of Object.entries(strategyRoutes)) { - it.each(Object.entries(routes))(`${method}: %s declares x-strategy`, (_name, route) => { - expect(route).toHaveProperty('x-strategy'); + const switched = Object.entries(routes).filter(([name]) => !reachableWhileOff.has(name)); + it.each(switched)(`${method}: %s is switched by ${method}`, (_name, route) => { + expect(switchOf(route)).toMatchObject({ strategy: method }); }); } - it('the token invoke route gates magic links per request', () => { - expect(authGeneralRoutes.invokeToken).toHaveProperty('x-strategy', 'per-request'); + it('leaves factor deletion reachable while its method is off', () => { + for (const name of reachableWhileOff) { + const route = { ...authPasskeysRoutes, ...authTotpsRoutes }[name as 'deletePasskey' | 'deleteTotp']; + expect(switchOf(route)).toBeUndefined(); + } }); }); diff --git a/backend/tests/catchup-baseline.test.ts b/backend/tests/catchup-baseline.test.ts index b5a99caf3..579d05606 100644 --- a/backend/tests/catchup-baseline.test.ts +++ b/backend/tests/catchup-baseline.test.ts @@ -23,11 +23,9 @@ describe('Catchup (view-driven, sequence)', async () => { beforeAll(async () => { tenant = await createTestTenant(call, 'catchup-baseline'); otherOrgId = (await createTestOrganization()).id; - await db.insert(channelCountersTable).values({ - channelKey: otherOrgId, - counts: { sequence: 9, 'e:f:attachment': 7, 'e:c:attachment': 3 }, - path: otherOrgId, - }); + await db + .insert(channelCountersTable) + .values({ channelKey: otherOrgId, counts: { sequence: 9, 'e:f:attachment': 7, 'e:c:attachment': 3 }, path: otherOrgId }); const counts = { sequence: 50, @@ -41,24 +39,16 @@ describe('Catchup (view-driven, sequence)', async () => { await db .insert(channelCountersTable) .values({ channelKey: tenant.organization.id, counts, path: tenant.organization.id }) - .onConflictDoUpdate({ - target: channelCountersTable.channelKey, - set: { counts, path: tenant.organization.id }, - }); + .onConflictDoUpdate({ target: channelCountersTable.channelKey, set: { counts, path: tenant.organization.id } }); }); afterAll(async () => { - await db - .delete(channelCountersTable) - .where(inArray(channelCountersTable.channelKey, [tenant.organization.id, otherOrgId])); + await db.delete(channelCountersTable).where(inArray(channelCountersTable.channelKey, [tenant.organization.id, otherOrgId])); await clearSecurityTestData(); }); it('returns the membership change signal without cursor (baseline)', async () => { - const result = await call(postAppCatchup, { - body: {}, - headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, - }); + const result = await call(postAppCatchup, { body: {}, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie } }); expect(result.response.status).toBe(200); const { changes, cursor } = result.data as AppCatchupResponse; @@ -76,15 +66,7 @@ describe('Catchup (view-driven, sequence)', async () => { const result = await call(postAppCatchup, { body: { cursor: '0-0', - views: [ - { - key: `${orgId}:attachment`, - organizationId: orgId, - prefixes: [orgId], - entityTypes: ['attachment'], - cursor: 40, - }, - ], + views: [{ key: `${orgId}:attachment`, organizationId: orgId, prefixes: [orgId], entityTypes: ['attachment'], cursor: 40 }], }, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, }); @@ -106,14 +88,7 @@ describe('Catchup (view-driven, sequence)', async () => { body: { cursor: '0-0', views: [ - { - key: `${orgId}:attachment:self`, - organizationId: orgId, - prefixes: [orgId], - entityTypes: ['attachment'], - depth: 'self', - cursor: 39, - }, + { key: `${orgId}:attachment:self`, organizationId: orgId, prefixes: [orgId], entityTypes: ['attachment'], depth: 'self', cursor: 39 }, ], }, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, @@ -122,12 +97,7 @@ describe('Catchup (view-driven, sequence)', async () => { expect(result.response.status).toBe(200); const { views } = result.data as AppCatchupResponse; expect(views).toHaveLength(1); - expect(views![0]).toMatchObject({ - key: `${orgId}:attachment:self`, - status: 'ok', - frontiers: { attachment: 40 }, - counts: { attachment: 12 }, - }); + expect(views![0]).toMatchObject({ key: `${orgId}:attachment:self`, status: 'ok', frontiers: { attachment: 40 }, counts: { attachment: 12 } }); }); it('a claimed prefix that mismatches the verified path answers opaque, no numbers', async () => { @@ -161,20 +131,8 @@ describe('Catchup (view-driven, sequence)', async () => { body: { cursor: '0-0', views: [ - { - key: 'other:attachment', - organizationId: otherOrgId, - prefixes: [otherOrgId], - entityTypes: ['attachment'], - cursor: 0, - }, - { - key: `${orgId}:attachment`, - organizationId: orgId, - prefixes: [orgId], - entityTypes: ['attachment'], - cursor: 42, - }, + { key: 'other:attachment', organizationId: otherOrgId, prefixes: [otherOrgId], entityTypes: ['attachment'], cursor: 0 }, + { key: `${orgId}:attachment`, organizationId: orgId, prefixes: [orgId], entityTypes: ['attachment'], cursor: 42 }, ], }, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, diff --git a/backend/tests/channel-discovery.test.ts b/backend/tests/channel-discovery.test.ts index d4690de8e..c0d860e03 100644 --- a/backend/tests/channel-discovery.test.ts +++ b/backend/tests/channel-discovery.test.ts @@ -64,12 +64,9 @@ let rowCounter = 0; const insertChannel = async (opts: { courseId?: string | null; published?: boolean } = {}) => { const id = `ch-${++rowCounter}`; - await seedDb.insert(channelsTable).values({ - id, - organizationId: ORG_ID, - courseId: opts.courseId ?? null, - publishedAt: opts.published === false ? null : PUBLISHED_AT, - }); + await seedDb + .insert(channelsTable) + .values({ id, organizationId: ORG_ID, courseId: opts.courseId ?? null, publishedAt: opts.published === false ? null : PUBLISHED_AT }); return id; }; @@ -105,10 +102,7 @@ interface ListedRow { /** The list query as a consumer wires it: a LEFT join keyed on the caller's own membership, so discovery rows carry none. */ const listChannels = async (userId: string, opts: ListOpts = {}): Promise => { const actor: PredicateActor = { actorId: userId, isSystemAdmin: opts.isSystemAdmin ?? false, scopes: null }; - const memberships = (await seedDb - .select() - .from(membershipsTable) - .where(eq(membershipsTable.userId, userId))) as unknown as MembershipBaseModel[]; // scratch rows carry the base shape + const memberships = (await seedDb.select().from(membershipsTable).where(eq(membershipsTable.userId, userId))) as unknown as MembershipBaseModel[]; // scratch rows carry the base shape const membershipKeyOn = and( eq(membershipsTable.channelId, channelsTable.id), diff --git a/backend/tests/emails/brevo-send.test.ts b/backend/tests/emails/brevo-send.test.ts index 30c773894..baae72c4a 100644 --- a/backend/tests/emails/brevo-send.test.ts +++ b/backend/tests/emails/brevo-send.test.ts @@ -1,9 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { mailer, neutralizeBrevoTags } from '#/lib/mailer'; -import { describeDigestRow, renderSectionsHtml } from '#/modules/notification/digest/build-digest'; import { digestEmail } from '#/modules/notification/emails/digest-email'; import { mentionEmail } from '#/modules/notification/emails/mention-email'; -import { htmlToExcerpt } from '#/modules/notification/helpers/render-digest-html'; +import { describeDigestRow, htmlToExcerpt, renderSectionsHtml } from '#/modules/notification/helpers/render-digest-html'; import type { SafeHtmlPolicy } from '../../emails/components/safe-html'; import { emailPreviewFixtures } from '../../emails/preview-fixtures'; import { render } from '../../emails/renderer/render'; @@ -22,12 +21,7 @@ const hostile = 'x{{params.unsubscribeLink|sa /** pongo2's `escape` filter, which Brevo applies to every param it fills without `|safe`. */ const pongoEscape = (value: string) => - value - .replaceAll('&', '&') - .replaceAll('>', '>') - .replaceAll('<', '<') - .replaceAll('"', '"') - .replaceAll("'", '''); + value.replaceAll('&', '&').replaceAll('>', '>').replaceAll('<', '<').replaceAll('"', '"').replaceAll("'", '''); /** * Brevo's side of a send, as pongo2 (its template engine) does it: `{{params.x}}` prints the value escaped, @@ -73,14 +67,7 @@ afterEach(() => { * `{{params.x}}` escaped, and a param declared as app-built HTML through `{{params.x|safe}}`. */ describe('Mails as Brevo fills them', () => { - const mentionRecipient = { - email: 'mentioned@example.test', - lng: 'en', - subjectTitle: 'Roadmap', - excerpt: 'See this', - link, - unsubscribeLink, - }; + const mentionRecipient = { email: 'mentioned@example.test', lng: 'en', subjectTitle: 'Roadmap', excerpt: 'See this', link, unsubscribeLink }; it('must not open a Brevo template tag via a name rendered into the mail', async () => { const hostileName = '{{ params.excerpt|safe }}{% autoescape off %}{# hidden'; @@ -118,7 +105,7 @@ describe('Mails as Brevo fills them', () => { it('fills the params a template names in its translated text (positive control)', async () => { const invite = await send( systemInviteEmail, - { senderName: '{{params.name}}', senderThumbnailUrl: null }, + { senderName: '{{params.name}}' }, { email: 'emily@example.test', lng: 'en', name: 'Emily', inviteLink: link }, ); expect(invite.html).toContain('Hi Emily,'); @@ -127,7 +114,7 @@ describe('Mails as Brevo fills them', () => { const verification = await send( oauthVerificationEmail, - { name: 'Emily', verificationLink: link, providerEmail: 'emily@provider.example', providerName: 'GitHub' }, + { name: 'Emily', verificationLink: link, providerEmail: 'emily@provider.example', providerName: 'GitHub', isNewUser: false }, { email: 'emily@example.test', lng: 'en' }, ); expect(verification.html).toContain('emily@example.test'); @@ -135,14 +122,7 @@ describe('Mails as Brevo fills them', () => { it('fills a mention excerpt escaped once', async () => { const excerpt = htmlToExcerpt('

    Tom & Jerry <3

    ', 250); - const { html } = await send( - mentionEmail, - { actorName: 'Jane', channelName: 'Design 101' }, - { - ...mentionRecipient, - excerpt, - }, - ); + const { html } = await send(mentionEmail, { actorName: 'Jane', channelName: 'Design 101' }, { ...mentionRecipient, excerpt }); expect(html).toContain('Tom & Jerry <3'); expect(html).not.toContain('&amp;'); @@ -153,11 +133,7 @@ describe('Mails as Brevo fills them', () => { [{ channelId: 'c1', channelName: hostile, lines: [describeDigestRow('comment', hostile, 'en')], overflow: 0 }], 'en', ); - const { params, html } = await send( - digestEmail, - { daily: true }, - { email: 'reader@example.test', lng: 'en', sectionsHtml, unsubscribeLink }, - ); + const { params, html } = await send(digestEmail, { daily: true }, { email: 'reader@example.test', lng: 'en', sectionsHtml, unsubscribeLink }); expect(paramValue(params, 'sectionsHtml')).not.toMatch(tagOpener); expect(html).not.toMatch(anchorToEvil); @@ -168,32 +144,17 @@ describe('Mails as Brevo fills them', () => { it('renders a digest section as HTML (positive control)', async () => { const sectionsHtml = renderSectionsHtml( - [ - { - channelId: 'c1', - channelName: 'Design 101', - lines: [describeDigestRow('comment', 'Roadmap', 'en')], - overflow: 2, - }, - ], + [{ channelId: 'c1', channelName: 'Design 101', lines: [describeDigestRow('comment', 'Roadmap', 'en')], overflow: 2 }], 'en', ); - const { html } = await send( - digestEmail, - { daily: false }, - { email: 'reader@example.test', lng: 'en', sectionsHtml, unsubscribeLink }, - ); + const { html } = await send(digestEmail, { daily: false }, { email: 'reader@example.test', lng: 'en', sectionsHtml, unsubscribeLink }); expect(html).toContain('

    Design 101

    • New comment on Roadmap
    • '); expect(html).toContain('
    • and 2 more
    '); }); it("keeps the mailer's own placeholders for Brevo to fill and escape (positive control)", async () => { - const { body, params } = await send( - mentionEmail, - { actorName: 'Jane', channelName: 'Design 101' }, - mentionRecipient, - ); + const { body, params } = await send(mentionEmail, { actorName: 'Jane', channelName: 'Design 101' }, mentionRecipient); for (const key of ['subjectTitle', 'excerpt', 'link']) { expect(body.htmlContent).toMatch(new RegExp(`\\{\\{params\\.${key}_[0-9a-f]{16}\\}\\}`)); @@ -205,9 +166,7 @@ describe('Mails as Brevo fills them', () => { describe('neutralizeBrevoTags', () => { /** Renders a template's preview as the mailer does and checks that the pass leaves it as it is. */ - const expectUnchanged = async ( - def: EmailTemplateDef, - ) => { + const expectUnchanged = async (def: EmailTemplateDef) => { const htmlParams: Partial> = { ...def.htmlParams }; const keys = Object.keys(def.preview.recipient); const { subject, ...props } = def.translate('en', def.preview.statics); @@ -226,8 +185,8 @@ describe('neutralizeBrevoTags', () => { it('keeps |safe only for a declared HTML param', () => { const content = '{{params.sectionsHtml|safe}} {{params.link|safe}} {{params.link}} {{params.other}}'; - expect( - neutralizeBrevoTags(content, { params: ['sectionsHtml', 'link'], htmlParams: ['sectionsHtml'] }, 'html'), - ).toBe('{{params.sectionsHtml|safe}} {{params.link|safe}} {{params.link}} {{params.other}}'); + expect(neutralizeBrevoTags(content, { params: ['sectionsHtml', 'link'], htmlParams: ['sectionsHtml'] }, 'html')).toBe( + '{{params.sectionsHtml|safe}} {{params.link|safe}} {{params.link}} {{params.other}}', + ); }); }); diff --git a/backend/tests/emails/email-escaping.test.ts b/backend/tests/emails/email-escaping.test.ts index 80dee843a..ed688531d 100644 --- a/backend/tests/emails/email-escaping.test.ts +++ b/backend/tests/emails/email-escaping.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { describeDigestRow } from '#/modules/notification/digest/build-digest'; import { mentionEmail } from '#/modules/notification/emails/mention-email'; -import { - magicLinkEmail, - memberAddedEmail, - memberInviteEmail, - memberInviteWithTokenEmail, - systemInviteEmail, -} from '../../emails'; +import { describeDigestRow } from '#/modules/notification/helpers/render-digest-html'; +import { magicLinkEmail, memberAddedEmail, memberInviteEmail, memberInviteWithTokenEmail, systemInviteEmail } from '../../emails'; import { EmailButton } from '../../emails/components'; import { render } from '../../emails/renderer/render'; import { memberRole } from '../fixtures'; @@ -21,7 +15,7 @@ const link = 'https://app.example.test/invite'; describe('email templates escape names interpolated into HTML', () => { it('must not inject a link into a member invite via the sender or organization name', async () => { - const statics = { senderName: hostile, senderThumbnailUrl: null, entityName: hostile, role: memberRole }; + const statics = { senderName: hostile, entityName: hostile, role: memberRole }; const translated = memberInviteEmail.translate('en', statics); const html = await render(memberInviteEmail.component({ ...translated, name: 'Emily', memberInviteLink: link })); @@ -32,10 +26,10 @@ describe('email templates escape names interpolated into HTML', () => { }); it('must not inject a link into an invite with token, a member-added or a system invite mail', async () => { - const statics = { senderName: hostile, senderThumbnailUrl: null, entityName: hostile, role: memberRole }; + const statics = { senderName: hostile, entityName: hostile, role: memberRole }; const withToken = memberInviteWithTokenEmail.translate('en', statics); const added = memberAddedEmail.translate('en', statics); - const system = systemInviteEmail.translate('en', { senderName: hostile, senderThumbnailUrl: null }); + const system = systemInviteEmail.translate('en', { senderName: hostile }); const htmls = await Promise.all([ render(memberInviteWithTokenEmail.component({ ...withToken, name: 'Emily', inviteLink: link })), render(memberAddedEmail.component({ ...added, name: 'Emily', entityLink: link })), @@ -84,7 +78,7 @@ describe('email button', () => { }); describe('email plain-text parts keep names as typed', () => { - const statics = { senderName: 'Jane', senderThumbnailUrl: null, entityName: 'R&D ', role: memberRole }; + const statics = { senderName: 'Jane', entityName: 'R&D ', role: memberRole }; it('leaves the subject and preview unescaped, and the rendered mail escapes them once', async () => { const translated = memberInviteEmail.translate('en', statics); @@ -97,11 +91,7 @@ describe('email plain-text parts keep names as typed', () => { }); it('escapes a greeting name once', async () => { - const translated = magicLinkEmail.translate('en', { - magicLinkUrl: link, - name: "O'Brien & ", - isNewUser: false, - }); + const translated = magicLinkEmail.translate('en', { magicLinkUrl: link, name: "O'Brien & ", isNewUser: false }); expect(translated.hiText).toBe("Hi O'Brien & ,"); const html = await render(magicLinkEmail.component({ ...translated })); diff --git a/backend/tests/emails/email-templates.test.ts b/backend/tests/emails/email-templates.test.ts index b535ca2d2..5b9bd1a7d 100644 --- a/backend/tests/emails/email-templates.test.ts +++ b/backend/tests/emails/email-templates.test.ts @@ -1,10 +1,11 @@ /// +import { readFileSync } from 'node:fs'; import { appConfig } from 'shared'; import { describe, expect, it } from 'vitest'; import enBackend from '../../../locales/en/backend.json'; import { i18n } from '../../emails/i18n'; -import { type EmailPreviewFixture, emailPreviewFixtures } from '../../emails/preview-fixtures'; +import { type EmailPreviewFixture, emailPreviewFixtures, emailPreviewNames } from '../../emails/preview-fixtures'; import { render } from '../../emails/renderer/render'; import { accountSecurityEmail } from '../../emails/templates/account-security'; @@ -32,9 +33,12 @@ describe('email translation fallback', () => { // The cast to the loose fixture type stops the heterogeneous defs collapsing // `translate`'s parameter to `never` across the union. -const templateEntries = (Object.entries(emailPreviewFixtures) as [string, EmailPreviewFixture][]).map( - ([name, { def, statics, recipient }]) => ({ name, def, statics, recipient }), -); +const templateEntries = (Object.entries(emailPreviewFixtures) as [string, EmailPreviewFixture][]).map(([name, { def, statics, recipient }]) => ({ + name, + def, + statics, + recipient, +})); /** Catches broken components, runtime errors, and keys missing from every language. */ describe('email template rendering', () => { @@ -58,6 +62,16 @@ describe('email template rendering', () => { } }); +// Storybook indexes stories from static exports, so each preview needs its own line in the stories file. +describe('email storybook', () => { + it('has a story for every preview', () => { + const storiesUrl = new URL('../../../frontend/src/stories/email-templates.stories.tsx', import.meta.url); + const stories = readFileSync(storiesUrl, 'utf8'); + const missing = emailPreviewNames.filter((name) => !stories.includes(`makeEmailStory('${name}')`)); + expect(missing).toEqual([]); + }); +}); + /** Details reach these mails from request data (route, tenant name, browser), and the body is rendered as HTML. */ describe('account security email escapes its details', () => { const hostile = 'click'; @@ -103,11 +117,7 @@ describe('new sign-in notice location line', () => { }; it('names the country when GeoIP resolved one, escaped like every other detail', async () => { - const translated = accountSecurityEmail.translate('en', { - name: 'Emily', - type: 'new-sign-in', - details: { ...details, country: 'NetherLocation: Nether<lands (approximate)'); diff --git a/backend/tests/emails/security-inbox.test.ts b/backend/tests/emails/security-inbox.test.ts new file mode 100644 index 000000000..cc0040a87 --- /dev/null +++ b/backend/tests/emails/security-inbox.test.ts @@ -0,0 +1,79 @@ +import { getRequests } from 'sdk'; +import { appConfig } from 'shared'; +import { generateId } from 'shared/utils/entity-id'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { baseDb as db } from '#/db/db'; +import { activityBus } from '#/lib/activity-bus'; +import { createTenantForUser } from '#/modules/tenants/tenant-service'; +import { defaultHeaders, signUpUser } from '../fixtures'; +import { createTestSession, createTestUser, mailsTo } from '../helpers'; +import { createAppClient } from '../test-client'; +import { clearDatabase } from '../test-utils'; + +afterEach(async () => { + await clearDatabase(); +}); + +const isoTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const utcTime = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/; + +/** The security mails handed to the mailer for the security inbox, with the recipient's language. */ +const inboxMails = () => mailsTo(appConfig.securityEmail).map(({ statics, recipient }) => ({ ...statics, lng: recipient.lng })); + +describe('security inbox mails', async () => { + const call = await createAppClient(); + + it('reports a refused system admin route with an ISO time', async () => { + const user = await createTestUser(signUpUser.email); + const { response } = await call(getRequests, { headers: { ...defaultHeaders, Cookie: await createTestSession(user) } }); + + expect(response.status).toBe(403); + expect(inboxMails()).toEqual([ + { + name: 'Security', + type: 'sysadmin-fail', + lng: appConfig.defaultLanguage, + details: { ip: expect.any(String), route: '/requests', timestamp: expect.stringMatching(isoTime) }, + }, + ]); + }); + + it('reports a new tenant with a readable UTC time', async () => { + const user = await createTestUser(signUpUser.email); + await createTenantForUser(db, { name: 'Acme', createdBy: user.id, userEmail: user.email }); + + expect(inboxMails()).toEqual([ + { + name: 'Security', + type: 'tenant-created', + lng: appConfig.defaultLanguage, + details: { tenantName: 'Acme', userEmail: user.email, timestamp: expect.stringMatching(utcTime) }, + }, + ]); + }); + + it('reports a system role change with a readable UTC time', async () => { + await import('#/modules/system/system-listeners'); + const user = await createTestUser(signUpUser.email); + + activityBus.emit({ + id: generateId(), + type: 'system_role.created', + action: 'create', + resourceType: 'system_role', + entityType: null, + rowData: { userId: user.id, role: 'admin' }, + } as never); + + await vi.waitFor(() => + expect(inboxMails()).toEqual([ + { + name: 'Security', + type: 'system-role-granted', + lng: appConfig.defaultLanguage, + details: { role: 'admin', userEmail: user.email, timestamp: expect.stringMatching(utcTime) }, + }, + ]), + ); + }); +}); diff --git a/backend/tests/fixtures.ts b/backend/tests/fixtures.ts index 7082f8706..67c7777f7 100644 --- a/backend/tests/fixtures.ts +++ b/backend/tests/fixtures.ts @@ -1,15 +1,9 @@ import { appConfig, hierarchy } from 'shared'; import type { OtelSDKOptions } from 'shared/otel'; -export const defaultHeaders = { - 'Content-Type': 'application/json', - 'x-forwarded-for': '123.123.123.123', - Origin: appConfig.frontendUrl, -}; +export const defaultHeaders = { 'Content-Type': 'application/json', 'x-forwarded-for': '123.123.123.123', Origin: appConfig.frontendUrl }; -export const signUpUser = { - email: 'test-user@example.com', -}; +export const signUpUser = { email: 'test-user@example.com' }; /** * The organization's most and least privileged roles, read from the hierarchy: `admin` and `member` in the template, diff --git a/backend/tests/global-setup.ts b/backend/tests/global-setup.ts index 9946ae2c5..cb19b7266 100644 --- a/backend/tests/global-setup.ts +++ b/backend/tests/global-setup.ts @@ -1,22 +1,28 @@ +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { drizzle } from 'drizzle-orm/node-postgres'; import { migrate } from 'drizzle-orm/node-postgres/migrator'; import pg from 'pg'; -import { testDatabaseUrl } from 'shared/test-db'; +import { testDatabaseUrl, testWorkerDatabase, withDatabase } from 'shared/test-db'; +import type { TestProject } from 'vitest/node'; import { crossMark, startSpinner, succeedSpinner } from '#/utils/console'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATABASE_URL = testDatabaseUrl; +/** Arbitrary advisory lock key shared by every backend test run against the test database. */ +const testRunLockKey = 7_365_224; +// Resolve from __dirname so Vitest workspace cwd does not affect migration lookup. +const migrationsFolder = path.resolve(__dirname, '../drizzle'); /** - * Global test setup: provisions the RLS roles, then migrates. The order matters: the RLS, - * trigger and grant blocks need the roles at migration time, and the verify block aborts the - * migration without them. Nothing here repairs catalog state after the migration; the schema - * the tests inspect is the schema the migration produced. + * Global test setup: provisions the RLS roles, then creates and migrates the test databases. The order matters: the RLS, + * trigger and grant blocks need the roles at migration time, and the verify block aborts the migration without them. + * Nothing here repairs catalog state after the migration; the schema the tests inspect is the schema the migration + * produced. */ -export default async function globalSetup() { +export default async function globalSetup(project: TestProject) { if (!DATABASE_URL) { console.error(`\n${crossMark} Backend tests require a database: DATABASE_URL not set`); console.error(' Run `pnpm docker:test` (or `pnpm dev`) to start Postgres, then run tests again.\n'); @@ -36,6 +42,16 @@ export default async function globalSetup() { process.exit(1); } + // Worktrees and parallel sessions share one test database, and a run truncates and seeds rows another run reads, so + // runs take turns: this session holds an advisory lock until the teardown below ends it. + const lockClient = new pg.Client({ connectionString: DATABASE_URL }); + await lockClient.connect(); + const { rows: locked } = await lockClient.query<{ acquired: boolean }>('SELECT pg_try_advisory_lock($1) AS acquired', [testRunLockKey]); + if (!locked[0]?.acquired) { + console.info('Another backend test run is using the test database; waiting for it to finish...'); + await lockClient.query('SELECT pg_advisory_lock($1)', [testRunLockKey]); + } + const pool = new pg.Pool({ connectionString: DATABASE_URL }); // Roles first: the side-effect migration blocks apply ownership, RLS, grants and triggers @@ -54,19 +70,26 @@ export default async function globalSetup() { ELSE ALTER ROLE admin_role NOBYPASSRLS; END IF; - GRANT USAGE ON SCHEMA public TO runtime_role; - GRANT ALL ON SCHEMA public TO admin_role; END $$; `); - const spinner = startSpinner('Running database migrations...'); + // Backend test files run in parallel, each worker on its own database (tests/setup.ts); the shared one stays for the yjs + // and cdc integration tests. VITEST_POOL_ID runs from 1 to `maxWorkers`, which defaults to one less than the cores. + // Created once and then migrated in place, like the shared one. + const workers = Number(project.config.maxWorkers || project.globalConfig.maxWorkers) || os.availableParallelism(); + const workerDatabases = Array.from({ length: workers }, (_, i) => testWorkerDatabase(i + 1)); + const { rows: existing } = await pool.query<{ datname: string }>('SELECT datname FROM pg_database WHERE datname = ANY($1)', [workerDatabases]); + for (const database of workerDatabases) { + if (!existing.some((row) => row.datname === database)) await pool.query(`CREATE DATABASE "${database}"`); + } + await pool.end(); - const db = drizzle({ client: pool }); - // Resolve from __dirname so Vitest workspace cwd does not affect migration lookup. - const migrationsFolder = path.resolve(__dirname, '../drizzle'); + const spinner = startSpinner('Running database migrations...'); + const urls = [DATABASE_URL, ...workerDatabases.map((database) => withDatabase(DATABASE_URL, database))]; + let results: Awaited>[]; try { - await migrate(db, { migrationsFolder, migrationsSchema: 'drizzle-backend' }); + results = await Promise.all(urls.map(prepareDatabase)); succeedSpinner('Migrations complete'); } catch (error) { spinner.fail('Migration failed'); @@ -74,24 +97,43 @@ export default async function globalSetup() { process.exit(1); } - // A volume migrated before the roles existed keeps its degraded catalog (migrations do not - // re-run) and RLS-dependent tests would pass vacuously on it, so the setup refuses such a volume. - const { rows } = await pool.query<{ enabled: boolean; forced: boolean; granted: boolean; owner: string }>(` - SELECT relrowsecurity AS enabled, - relforcerowsecurity AS forced, - has_table_privilege('runtime_role', 'public.yjs_documents', 'SELECT') AS granted, - pg_get_userbyid(relowner) AS owner - FROM pg_class WHERE relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace - `); - const state = rows[0]; - if (!state?.enabled || state.forced || !state.granted || state.owner !== 'admin_role') { + const degraded = results.filter((result) => result !== null); + for (const found of degraded) { console.error( - `\n${crossMark} Test database was migrated without the RLS roles (yjs_documents: ${JSON.stringify(state)})`, + `\n${crossMark} Test database ${found.database} was migrated without the RLS roles (yjs_documents: ${JSON.stringify(found.state)})`, ); console.error(' Reset the test volume: `pnpm docker:test:reset && pnpm docker:test`, then run tests again.\n'); - await pool.end(); - process.exit(1); } + if (degraded.length) process.exit(1); - await pool.end(); + // Closing the session releases the lock for the next waiting run. + return async () => { + await lockClient.end(); + }; +} + +/** + * Grants the roles the public schema, migrates, and checks the catalog of one test database. A database migrated before + * the roles existed keeps its degraded catalog (migrations do not re-run) and RLS-dependent tests would pass vacuously on + * it, so its RLS state is returned for the setup to refuse. + */ +async function prepareDatabase(url: string) { + const pool = new pg.Pool({ connectionString: url }); + try { + await pool.query('GRANT USAGE ON SCHEMA public TO runtime_role; GRANT ALL ON SCHEMA public TO admin_role;'); + await migrate(drizzle({ client: pool }), { migrationsFolder, migrationsSchema: 'drizzle-backend' }); + + const { rows } = await pool.query<{ enabled: boolean; forced: boolean; granted: boolean; owner: string }>(` + SELECT relrowsecurity AS enabled, + relforcerowsecurity AS forced, + has_table_privilege('runtime_role', 'public.yjs_documents', 'SELECT') AS granted, + pg_get_userbyid(relowner) AS owner + FROM pg_class WHERE relname = 'yjs_documents' AND relnamespace = 'public'::regnamespace + `); + const state = rows[0]; + if (state?.enabled && !state.forced && state.granted && state.owner === 'admin_role') return null; + return { database: new URL(url).pathname.slice(1), state }; + } finally { + await pool.end(); + } } diff --git a/backend/tests/health-helpers.test.ts b/backend/tests/health-helpers.test.ts index 601eee54e..ad01906af 100644 --- a/backend/tests/health-helpers.test.ts +++ b/backend/tests/health-helpers.test.ts @@ -13,12 +13,7 @@ import { worstStatus, } from '#/lib/health-helpers'; -const connectedSocket: CdcSocketSnapshot = { - cdcConnected: true, - lastMessageAt: null, - messagesReceived: 10, - parseErrors: 0, -}; +const connectedSocket: CdcSocketSnapshot = { cdcConnected: true, lastMessageAt: null, messagesReceived: 10, parseErrors: 0 }; function worker(overrides: Record = {}) { return { @@ -70,11 +65,7 @@ describe('gradeEventLoop', () => { describe('mapApiComponent', () => { it('reports memory in MB and grades the event loop', () => { - const memory = { - rss: 400 * 1024 * 1024, - heapUsed: 180 * 1024 * 1024, - heapTotal: 256 * 1024 * 1024, - } as NodeJS.MemoryUsage; + const memory = { rss: 400 * 1024 * 1024, heapUsed: 180 * 1024 * 1024, heapTotal: 256 * 1024 * 1024 } as NodeJS.MemoryUsage; const component = mapApiComponent(8, memory); expect(component.status).toBe('healthy'); expect(component.checkedVia).toBe('local'); diff --git a/backend/tests/health.test.ts b/backend/tests/health.test.ts index 29ed92f9c..a2c5cb1e5 100644 --- a/backend/tests/health.test.ts +++ b/backend/tests/health.test.ts @@ -1,5 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { startTestOauthServer } from './oauth-helpers'; +import { describe, expect, it, vi } from 'vitest'; import { setTestConfig } from './test-utils'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); @@ -15,31 +14,7 @@ async function fetchHealth(query = '') { return app.fetch(new Request(`http://localhost/health${query}`)); } -/** A deep health response's HTTP status with its `authInvalidation` component. */ -async function authInvalidationIn(res: Response) { - const body = (await res.json()) as { components: { authInvalidation?: { status?: string; reason?: string } } }; - return { httpStatus: res.status, ...body.components.authInvalidation }; -} - -const authInvalidation = async () => authInvalidationIn(await fetchHealth('?depth=full')); - -/** - * Every process starts its auth invalidation listener at boot, as this file does before the diagnostics are read: a - * process that hears no invalidations keeps ended sessions and removed memberships cached. - */ -describe('Health endpoint', async () => { - const { listenForAuthInvalidation } = await import('#/middlewares/guard/invalidation-listener'); - // Read before the listener starts: the state between a process's boot and its first LISTEN. - const beforeListening = await authInvalidation(); - let stop: () => Promise; - - beforeAll(async () => { - stop = listenForAuthInvalidation(); - await vi.waitFor(async () => expect((await authInvalidation()).status).toBe('healthy')); - }); - - afterAll(async () => await stop()); - +describe('Health endpoint', () => { it('GET /health returns shallow 204 by default', async () => { const res = await fetchHealth(); @@ -95,20 +70,4 @@ describe('Health endpoint', async () => { expect(cdc.details).toHaveProperty('parseErrors'); expect(['healthy', 'degraded', 'unhealthy']).toContain(cdc.status); }); - - it('must not report a process healthy while nothing hears session endings: before the listener starts and once it stopped', async () => { - // The authorization server holds the tenant cache, so its own health reports the same component. - const oauth = await startTestOauthServer(); - const oauthAuthInvalidation = async () => authInvalidationIn(await fetch(`${oauth.issuer}/health?depth=full`)); - try { - expect(beforeListening).toMatchObject({ httpStatus: 503, status: 'unhealthy', reason: 'never_started' }); - expect(await authInvalidation()).toMatchObject({ httpStatus: 200, status: 'healthy' }); - expect(await oauthAuthInvalidation()).toMatchObject({ httpStatus: 200, status: 'healthy' }); - await stop(); - expect(await authInvalidation()).toMatchObject({ httpStatus: 503, status: 'unhealthy', reason: 'stopped' }); - expect(await oauthAuthInvalidation()).toMatchObject({ httpStatus: 503, status: 'unhealthy', reason: 'stopped' }); - } finally { - await oauth.close(); - } - }); }); diff --git a/backend/tests/helpers.ts b/backend/tests/helpers.ts index 3cb0738bf..bccb5e44a 100644 --- a/backend/tests/helpers.ts +++ b/backend/tests/helpers.ts @@ -22,9 +22,9 @@ import { mockOrganization } from '#/modules/organization/organization-mocks'; import { systemRolesTable } from '#/modules/system/system-roles-db'; import { tenantsTable } from '#/modules/tenants/tenants-db'; import { emailsTable } from '#/modules/user/emails-db'; -import { insertUsers } from '#/modules/user/helpers/insert-users'; import { type UserModel, usersTable } from '#/modules/user/user-db'; import { mockEmail, mockUser } from '#/modules/user/user-mocks'; +import { insertUsers } from '#/modules/user/user-queries'; import type { apiErrorSchema } from '#/schemas'; import { hashToken } from '#/utils/hash-token'; import { adminRole, defaultHeaders } from './fixtures'; @@ -109,7 +109,7 @@ export async function rawJsonRequest(path: string, cookie: string, init: { metho /** User with a verified email, for OAuth/passkey tests. */ export async function createUser(email: string) { const userRecord = mockUser({ email }); - const [user] = await insertUsers(db, [userRecord]); + const [user] = await insertUsers({ var: { db } }, { users: [userRecord] }); await db.insert(emailsTable).values(mockEmail(user)); return user; } @@ -130,23 +130,18 @@ export const totpCode = (secret = testTotpSecret, stepsAhead = 0) => { }; /** The current code with its first digit changed. */ -export const wrongTotpCode = (secret = testTotpSecret) => - totpCode(secret).replace(/^./, (digit) => String((Number(digit) + 5) % 10)); +export const wrongTotpCode = (secret = testTotpSecret) => totpCode(secret).replace(/^./, (digit) => String((Number(digit) + 5) % 10)); export async function createTotpUser(email: string) { const user = await createTestUser(email); - await db.insert(totpsTable).values({ - userId: user.id, - secret: encryptTotpSecret(testTotpSecret), - createdAt: mockPastIsoDate(), - }); + await db.insert(totpsTable).values({ userId: user.id, secret: encryptTotpSecret(testTotpSecret), createdAt: mockPastIsoDate() }); await enableMFAForUser(user.id); return user; } export async function createTestUser(email: string) { const userRecord = mockUser({ email }); - const [user] = await insertUsers(db, [userRecord]); + const [user] = await insertUsers({ var: { db } }, { users: [userRecord] }); await db.insert(emailsTable).values(mockEmail(user)); return user; } @@ -163,12 +158,7 @@ export async function createSystemAdminUser(email: string) { const user = await createTestUser(email); // system_roles is admin-only (read-only grant + admin-only write trigger for runtime_role). - await getAdminDb('test setup').insert(systemRolesTable).values({ - id: user.id, - userId: user.id, - role: 'admin', - createdAt: mockPastIsoDate(), - }); + await getAdminDb('test setup').insert(systemRolesTable).values({ id: user.id, userId: user.id, role: 'admin', createdAt: mockPastIsoDate() }); return user; } @@ -204,9 +194,7 @@ export async function parseResponse(response: Response): Promise { } /** The tenant is created first; the FK constraint requires it. */ -export async function createTestOrganization( - overrides?: Partial>, -): Promise { +export async function createTestOrganization(overrides?: Partial>): Promise { const [tenant] = await db.insert(tenantsTable).values({ name: 'Test Tenant' }).returning(); const orgData = mockOrganization(); @@ -234,13 +222,7 @@ interface TestSessionOpts { */ export async function insertTestSession( user: { id: string }, - { - type = 'regular', - authStrategy = 'passkey', - ageMs = 0, - expiresInMs = 7 * 24 * 60 * 60 * 1000, - impersonatorSessionId, - }: TestSessionOpts = {}, + { type = 'regular', authStrategy = 'passkey', ageMs = 0, expiresInMs = 7 * 24 * 60 * 60 * 1000, impersonatorSessionId }: TestSessionOpts = {}, ) { const { token, secret } = newSessionToken(); const id = generateId(); @@ -268,8 +250,7 @@ export async function createTestSession(user: { id: string }, opts?: TestSession /** Every session row of a user, the ended ones included. */ export const sessionsOf = (userId: string) => db.select().from(sessionsTable).where(eq(sessionsTable.userId, userId)); -export const sessionRow = async (id: string) => - (await db.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1))[0]; +export const sessionRow = async (id: string) => (await db.select().from(sessionsTable).where(eq(sessionsTable.id, id)).limit(1))[0]; interface TestTokenOpts extends Partial { /** From now; negative for a token that already expired. Default 15 minutes, a magic link's lifetime. */ @@ -384,8 +365,7 @@ export class CookieJar { } /** The `Cookie` header a browser holding `cookieHeader` sends after `response`. */ -export const cookiesAfter = (cookieHeader: string, response: Response) => - new CookieJar([cookieHeader]).absorb(response).header(); +export const cookiesAfter = (cookieHeader: string, response: Response) => new CookieJar([cookieHeader]).absorb(response).header(); /** Links an external identity to a user; by default a verified GitHub identity asserting the user's own address. */ export async function linkIdentity(user: { id: string; email: string }, overrides: Partial = {}) { diff --git a/backend/tests/hierarchy-helpers.ts b/backend/tests/hierarchy-helpers.ts index 98c0313da..1e76045b5 100644 --- a/backend/tests/hierarchy-helpers.ts +++ b/backend/tests/hierarchy-helpers.ts @@ -49,20 +49,19 @@ export async function cleanupEntityHierarchy(db: ExecutableDb, ...plans: TestEnt /** * Seeds, on the admin connection, the channels between an organization and where its attachments live, and returns - * the plan: none in the template, whose attachments live in the organization itself. + * the plan: none in the template, whose attachments live in the organization itself. Rows home at the deepest strict + * ancestor, as `attachment-placement.ts` homes them, so the plan leaves the nullable ancestor columns unset. */ export async function seedAttachmentHome(org: { id: string; tenantId: string }, createdBy: string) { - const plan = buildTestEntityHierarchyPlan({ - entityType: 'attachment', - organizationId: org.id, - makeChannelId: () => generateId(), - }); + const plan = buildTestEntityHierarchyPlan({ entityType: 'attachment', organizationId: org.id, makeChannelId: () => generateId() }); const slugPrefix = `home-${nanoid(6)}`; await seedEntityHierarchy(getAdminDb('test setup'), plan, { tenantId: org.tenantId, createdBy, slugPrefix }); - return plan; + const nullable = new Set(hierarchy.getNullableAncestors('attachment').map((type) => appConfig.entityIdColumnKeys[type])); + const channelIdColumns = Object.fromEntries(Object.entries(plan.channelIdColumns).filter(([key]) => !nullable.has(key))); + return { ...plan, channelIdColumns }; } -/** The id column a create body names its home by: the deepest channel the plan seeded, none when that is the organization. */ +/** The id column a create body names its home by: the deepest channel in the plan's columns, none when that is the organization. */ export function homeColumns(plan: TestEntityHierarchyPlan): Record { const home = hierarchy .getOrderedAncestors(plan.entityType) diff --git a/backend/tests/integration/cdc-event-bus.test.ts b/backend/tests/integration/cdc-event-bus.test.ts index b954e5fe6..1c9a82ad8 100644 --- a/backend/tests/integration/cdc-event-bus.test.ts +++ b/backend/tests/integration/cdc-event-bus.test.ts @@ -11,8 +11,8 @@ import { organizationsTable } from '#/modules/organization/organization-db'; import { mockOrganization } from '#/modules/organization/organization-mocks'; import { tenantsTable } from '#/modules/tenants/tenants-db'; import { emailsTable } from '#/modules/user/emails-db'; -import { insertUsers } from '#/modules/user/helpers/insert-users'; import { mockUser } from '#/modules/user/user-mocks'; +import { insertUsers } from '#/modules/user/user-queries'; import { cleanupEntityHierarchy, seedAttachmentHome } from '../hierarchy-helpers'; import { clearDatabase, startInProcessCdcWorker, waitFor, waitForEvent } from './test-utils'; @@ -37,7 +37,7 @@ describe.skipIf(process.env.TEST_MODE !== 'full')('Full CDC Flow', () => { .returning({ id: organizationsTable.id, slug: organizationsTable.slug, tenantId: organizationsTable.tenantId }); const userData = mockUser(); - const [insertedUser] = await insertUsers(db, [userData]); + const [insertedUser] = await insertUsers({ var: { db } }, { users: [userData] }); testUser = { id: insertedUser.id, email: insertedUser.email }; await db.insert(emailsTable).values({ email: testUser.email, userId: testUser.id, verified: true }); @@ -62,11 +62,7 @@ describe.skipIf(process.env.TEST_MODE !== 'full')('Full CDC Flow', () => { expect(event.type).toBe('membership.created'); expect(event.resourceType).toBe('membership'); expect(event.subjectId).toBe(membershipData.id); - expect(event.rowData).toMatchObject({ - channelType: 'organization', - channelId: testOrg.id, - organizationId: testOrg.id, - }); + expect(event.rowData).toMatchObject({ channelType: 'organization', channelId: testOrg.id, organizationId: testOrg.id }); }); it("must not leave a runtime-created organization's counters row without its path", async () => { @@ -85,14 +81,7 @@ describe.skipIf(process.env.TEST_MODE !== 'full')('Full CDC Flow', () => { const attachmentId = crypto.randomUUID(); const attachment = buildInsertableProduct( 'attachment', - { - id: attachmentId, - tenantId: testOrg.tenantId, - ...plan.channelIdColumns, - createdBy: testUser.id, - updatedBy: testUser.id, - seq: 0, - }, + { id: attachmentId, tenantId: testOrg.tenantId, ...plan.channelIdColumns, createdBy: testUser.id, updatedBy: testUser.id, seq: 0 }, 'cdc-seq-test-attachment', ); await db.insert(attachmentsTable).values(attachment as never); diff --git a/backend/tests/integration/jobs-store.test.ts b/backend/tests/integration/jobs-store.test.ts index fe1ea7739..37f9a86c2 100644 --- a/backend/tests/integration/jobs-store.test.ts +++ b/backend/tests/integration/jobs-store.test.ts @@ -85,9 +85,7 @@ describe('job store as runtime_role', () => { const snapshot = await readJobsHealth(); expect(snapshot.installed).toBe(true); expect(snapshot.schema).toBe(JOBS_SCHEMA); - expect(snapshot.queues.map((queue) => queue.name)).toEqual( - expect.arrayContaining(getBackendJobs().map((job) => job.name)), - ); + expect(snapshot.queues.map((queue) => queue.name)).toEqual(expect.arrayContaining(getBackendJobs().map((job) => job.name))); const component = mapJobsComponent(snapshot, true); expect(['healthy', 'degraded']).toContain(component.status); expect(component.details).toHaveProperty('queues'); diff --git a/backend/tests/integration/mention-activity.test.ts b/backend/tests/integration/mention-activity.test.ts new file mode 100644 index 000000000..0b7613172 --- /dev/null +++ b/backend/tests/integration/mention-activity.test.ts @@ -0,0 +1,111 @@ +import { and, eq } from 'drizzle-orm'; +import { updateAttachment } from 'sdk'; +import type { TestEntityHierarchyPlan } from 'shared/testing/entity-hierarchy'; +import { generateId } from 'shared/utils/entity-id'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { generateServerHLC } from '#/core/stx'; +import { getSeedDb } from '#/db/db'; +import { buildInsertableProduct } from '#/mocks'; +import { activitiesTable } from '#/modules/activities/activities-db'; +import { attachmentsTable } from '#/modules/attachment/attachment-db'; +import { notificationsTable } from '#/modules/notification/notification-db'; +import { mockStxBase } from '#/schemas/sync-transaction-mocks'; +import { adminRole, defaultHeaders } from '../fixtures'; +import { cleanupEntityHierarchy, insertAttachmentRow, seedAttachmentHome } from '../hierarchy-helpers'; +import { clearSecurityTestData, createOrgUser, createTestTenant, type TestTenant } from '../security/helpers'; +import { createAppClient } from '../test-client'; +import { startInProcessCdcWorker, waitFor } from './test-utils'; + +const db = getSeedDb(); + +const attachmentId = generateId(); + +const mentionDocument = (userId: string) => + JSON.stringify([ + { + id: generateId(), + type: 'paragraph', + props: {}, + content: [{ type: 'mention', props: { id: userId, name: 'someone', slug: 'someone' } }], + children: [], + }, + ]); + +/** + * A mention edit through the CDC worker: the activity log is what sync and the notification + * fan-out consume, so one client edit must stay one `updated` activity attributed to the edit, + * and the fan-out reads the mention from the body that activity carries. + */ +describe.skipIf(process.env.TEST_MODE !== 'full')('Mention edit activity', async () => { + const call = await createAppClient(); + let cdcHarness: Awaited>; + let tenant: TestTenant; + let member: { id: string }; + let plan: TestEntityHierarchyPlan; + + const updateActivities = () => + db + .select({ changedFields: activitiesTable.changedFields }) + .from(activitiesTable) + .where(and(eq(activitiesTable.subjectId, attachmentId), eq(activitiesTable.action, 'update'))) + .orderBy(activitiesTable.id); + + const memberInbox = () => + db + .select({ type: notificationsTable.type }) + .from(notificationsTable) + .where(and(eq(notificationsTable.userId, member.id), eq(notificationsTable.subjectId, attachmentId))); + + beforeAll(async () => { + cdcHarness = await startInProcessCdcWorker(); + tenant = await createTestTenant(call, 'mention-activity'); + member = await createOrgUser(call, tenant.tenantId, tenant.organization.id, 'mention-activity-member', adminRole); + plan = await seedAttachmentHome({ id: tenant.organization.id, tenantId: tenant.tenantId }, tenant.user.id); + + const row = buildInsertableProduct( + 'attachment', + { id: attachmentId, tenantId: tenant.tenantId, ...plan.channelIdColumns, createdBy: tenant.user.id, updatedBy: null }, + attachmentId, + ); + await insertAttachmentRow({ ...row, deletedBy: null }); + + // The insert's seq stamp marks the worker as caught up with the row. + await waitFor( + async () => { + const [stamped] = await db.select({ seq: attachmentsTable.seq }).from(attachmentsTable).where(eq(attachmentsTable.id, attachmentId)); + return (stamped?.seq ?? 0) > 0; + }, + 15_000, + 'CDC insert stamp on the attachment', + ); + }); + + afterAll(async () => { + await cdcHarness?.stop(); + await db.delete(notificationsTable).where(eq(notificationsTable.subjectId, attachmentId)); + await db.delete(attachmentsTable).where(eq(attachmentsTable.id, attachmentId)); + await cleanupEntityHierarchy(db, plan); + await clearSecurityTestData(); + }); + + it('stores an edit that adds a mention as one updated activity and mentions the member', async () => { + const result = await call(updateAttachment, { + path: { organizationId: tenant.organization.id, tenantId: tenant.tenantId, id: attachmentId }, + body: { + ops: { description: mentionDocument(member.id) }, + stx: { ...mockStxBase(`stx:${generateId()}`), fieldTimestamps: { description: generateServerHLC('test') } }, + }, + headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, + }); + expect(result.response.status).toBe(200); + + // The fan-out runs after the activity is persisted; writes of one transaction are persisted in one insert. + await waitFor(async () => (await memberInbox()).length > 0, 15_000, 'mention notification for the member'); + + const activities = await updateActivities(); + expect(activities.map(({ changedFields }) => changedFields)).toEqual([['description', 'updatedAt']]); + // Every integration file runs its own CDC worker on the one WAL, so a parallel run can fan this row + // out more than once (a late create fan-out reads the edited body too); each row is a mention. + expect(new Set((await memberInbox()).map(({ type }) => type))).toEqual(new Set(['mention'])); + }); +}); diff --git a/backend/tests/integration/rls-security.test.ts b/backend/tests/integration/rls-security.test.ts index 13b6f7eb9..553dc7ccc 100644 --- a/backend/tests/integration/rls-security.test.ts +++ b/backend/tests/integration/rls-security.test.ts @@ -36,12 +36,7 @@ let seenByAvailable = false; const quoteIdent = (identifier: string) => `"${identifier.replaceAll('"', '""')}"`; -async function seedEntityHierarchy( - plan: TestEntityHierarchyPlan, - tenantId: string, - createdBy: string, - slugPrefix: string, -) { +async function seedEntityHierarchy(plan: TestEntityHierarchyPlan, tenantId: string, createdBy: string, slugPrefix: string) { for (const row of plan.seedChannelRows) { // Every ancestor id column is NOT NULL on channel tables, so insert all of them. const ancestorNames = sql.join( @@ -99,11 +94,7 @@ const makeRlsProductFixture = (entityType: ProductEntityType): RlsProductFixture const table = getEntityTable(entityType); const rowId = rlsProductRowIds[entityType] ?? randomUUID(); const rowName = `RLS ${entityType}`; - const plan = buildTestEntityHierarchyPlan({ - entityType, - organizationId: TEST_ORG_A, - makeChannelId: () => randomUUID(), - }); + const plan = buildTestEntityHierarchyPlan({ entityType, organizationId: TEST_ORG_A, makeChannelId: () => randomUUID() }); // Deepest seeded ancestor is where unseen counts roll up (the org itself when org-homed). const homeChannelId = plan.sqlChannelColumns[0]?.id ?? TEST_ORG_A; @@ -155,9 +146,7 @@ let activeRlsProducts: { type: string; fixture: RlsProductFixture }[] = []; async function checkRolesExist(): Promise { const rows = getRows<{ exists: boolean }>( - await adminDb.execute( - sql`SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') as exists`, - ), + await adminDb.execute(sql`SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'runtime_role') as exists`), ); return rows[0]?.exists === true; } @@ -289,9 +278,7 @@ async function queryAsRuntimeRole>( } /** Query as runtime_role with no session context: fail-closed reads must yield zero rows. */ -async function queryWithoutChannel>( - queryFn: (tx: NodePgTx) => Promise, -): Promise { +async function queryWithoutChannel>(queryFn: (tx: NodePgTx) => Promise): Promise { return runtimeDb.transaction(async (tx) => { await tx.execute(sql`SELECT set_config('app.tenant_id', '', true)`); await tx.execute(sql`SELECT set_config('app.user_id', '', true)`); @@ -316,9 +303,7 @@ describe('RLS Security Tests', () => { expect(inside[0]).toEqual({ tenant: TEST_TENANT_A, user: '', deleted: 'false' }); // Transaction-scoped: the pooled connection carries no tenant into its next statement. - const after = getRows<{ value: string | null }>( - await adminDb.execute(sql`SELECT current_setting('app.tenant_id', true) AS value`), - ); + const after = getRows<{ value: string | null }>(await adminDb.execute(sql`SELECT current_setting('app.tenant_id', true) AS value`)); expect(after[0]?.value ?? '').toBe(''); }); @@ -363,9 +348,7 @@ const rlsSuiteReady = await (async () => { return; } - runtimeDb = drizzle({ - connection: { connectionString: RUNTIME_DB_URL, connectionTimeoutMillis: 5_000 }, - }); + runtimeDb = drizzle({ connection: { connectionString: RUNTIME_DB_URL, connectionTimeoutMillis: 5_000 } }); const rows = getRows<{ role: string }>(await runtimeDb.execute(sql`SELECT current_user as role`)); expect(rows[0].role).toBe('runtime_role'); @@ -406,9 +389,7 @@ const rlsSuiteReady = await (async () => { }); it('should deny access to attachments without tenant context', async () => { - const rows = await queryWithoutChannel(async (tx) => - tx.execute(sql`SELECT id FROM attachments WHERE id = ${TEST_ATTACHMENT_A}`), - ); + const rows = await queryWithoutChannel(async (tx) => tx.execute(sql`SELECT id FROM attachments WHERE id = ${TEST_ATTACHMENT_A}`)); expect(rows).toHaveLength(0); }); }); @@ -418,9 +399,7 @@ const rlsSuiteReady = await (async () => { describe('Unseen counts (seen-tracking RLS regression)', () => { // FORCE RLS makes context-less base reads return zero, so unseen reads need tenant context. type UnseenRow = { channelId: string; productType: string; unseenCount: number }; - const trackedProduct = iterableRlsProducts.find(([type]) => - (trackedProductTypes as readonly string[]).includes(type), - ); + const trackedProduct = iterableRlsProducts.find(([type]) => (trackedProductTypes as readonly string[]).includes(type)); const [trackedType, trackedFixture] = trackedProduct ?? [undefined, undefined]; const cutoff = () => new Date(Date.now() - seenWindowMs).toISOString(); const countUnseen = (tx: NodePgTx) => @@ -480,27 +459,19 @@ const rlsSuiteReady = await (async () => { it('should allow UPDATE as runtime_role, and the row changes', async () => { const updated = await queryAsRuntimeRole<{ id: string }>(TEST_TENANT_A, TEST_USER_A, async (tx) => - tx.execute( - sql.raw(`UPDATE ${fixture.table} SET name = 'Updated Row' WHERE id = '${fixture.rowId}' RETURNING id`), - ), + tx.execute(sql.raw(`UPDATE ${fixture.table} SET name = 'Updated Row' WHERE id = '${fixture.rowId}' RETURNING id`)), ); // A policy that leaves no row to update makes the statement a silent no-op, so the row count is the proof. expect(updated.map((r) => r.id)).toEqual([fixture.rowId]); - const [row] = getRows<{ name: string }>( - await adminDb.execute(sql.raw(`SELECT name FROM ${fixture.table} WHERE id = '${fixture.rowId}'`)), - ); + const [row] = getRows<{ name: string }>(await adminDb.execute(sql.raw(`SELECT name FROM ${fixture.table} WHERE id = '${fixture.rowId}'`))); expect(row.name).toBe('Updated Row'); - await adminDb.execute( - sql.raw(`UPDATE ${fixture.table} SET name = '${fixture.rowName}' WHERE id = '${fixture.rowId}'`), - ); + await adminDb.execute(sql.raw(`UPDATE ${fixture.table} SET name = '${fixture.rowName}' WHERE id = '${fixture.rowId}'`)); }); it('should allow DELETE as runtime_role', async () => { const id = randomUUID(); await fixture.insert(adminDb, { id, tenantId: TEST_TENANT_A, createdBy: TEST_USER_A }); - await queryAsRuntimeRole(TEST_TENANT_A, TEST_USER_A, async (tx) => - tx.execute(sql.raw(`DELETE FROM ${fixture.table} WHERE id = '${id}'`)), - ); + await queryAsRuntimeRole(TEST_TENANT_A, TEST_USER_A, async (tx) => tx.execute(sql.raw(`DELETE FROM ${fixture.table} WHERE id = '${id}'`))); const rows = getRows(await adminDb.execute(sql.raw(`SELECT id FROM ${fixture.table} WHERE id = '${id}'`))); expect(rows).toHaveLength(0); }); @@ -518,22 +489,15 @@ const rlsSuiteReady = await (async () => { await adminDb.execute(sql`DELETE FROM yjs_documents WHERE entity_id = ${fixture.rowId}`); }); - it.skipIf(iterableRlsProducts.length === 0)( - 'should allow writing without tenant context (write-through is unconditional)', - async () => { - const [, fixture] = iterableRlsProducts[0]; - const id = randomUUID(); - // The write-through policy is sql`true`, so no session context is needed. - await queryWithoutChannel(async (tx) => - fixture.insert(tx, { id, tenantId: TEST_TENANT_A, createdBy: TEST_USER_A }), - ); - const rows = await queryWithoutChannel(async (tx) => - tx.execute(sql.raw(`SELECT id FROM ${fixture.table} WHERE id = '${id}'`)), - ); - expect(rows).toHaveLength(0); - await adminDb.execute(sql.raw(`DELETE FROM ${fixture.table} WHERE id = '${id}'`)); - }, - ); + it.skipIf(iterableRlsProducts.length === 0)('should allow writing without tenant context (write-through is unconditional)', async () => { + const [, fixture] = iterableRlsProducts[0]; + const id = randomUUID(); + // The write-through policy is sql`true`, so no session context is needed. + await queryWithoutChannel(async (tx) => fixture.insert(tx, { id, tenantId: TEST_TENANT_A, createdBy: TEST_USER_A })); + const rows = await queryWithoutChannel(async (tx) => tx.execute(sql.raw(`SELECT id FROM ${fixture.table} WHERE id = '${id}'`))); + expect(rows).toHaveLength(0); + await adminDb.execute(sql.raw(`DELETE FROM ${fixture.table} WHERE id = '${id}'`)); + }); }); // ---- Composite FK violation (tenant_id must match organization's tenant_id) ---- @@ -541,16 +505,14 @@ const rlsSuiteReady = await (async () => { describe('Composite foreign key enforcement', () => { describe.each(iterableRlsProducts)('%s', (_type, fixture) => { it('should reject INSERT with mismatched tenant_id / organization_id', async () => { - await expect( - unwrapDrizzle(fixture.insert(adminDb, { id: randomUUID(), tenantId: TEST_TENANT_B, createdBy: TEST_USER_A })), - ).rejects.toThrow(/foreign key|violates/i); + await expect(unwrapDrizzle(fixture.insert(adminDb, { id: randomUUID(), tenantId: TEST_TENANT_B, createdBy: TEST_USER_A }))).rejects.toThrow( + /foreign key|violates/i, + ); }); it('should allow INSERT with matching tenant_id / organization_id', async () => { const id = randomUUID(); - await expect( - fixture.insert(adminDb, { id, tenantId: TEST_TENANT_A, createdBy: TEST_USER_A }), - ).resolves.not.toThrow(); + await expect(fixture.insert(adminDb, { id, tenantId: TEST_TENANT_A, createdBy: TEST_USER_A })).resolves.not.toThrow(); await adminDb.execute(sql.raw(`DELETE FROM ${fixture.table} WHERE id = '${id}'`)); }); }); @@ -565,9 +527,7 @@ const rlsSuiteReady = await (async () => { const seededChannelRowIdsByTable = new Map([ ['organizations', TEST_ORG_A], - ...iterableRlsProducts.flatMap(([, fixture]) => - fixture.plan.seedChannelRows.map((row) => [row.tableName, row.id] as const), - ), + ...iterableRlsProducts.flatMap(([, fixture]) => fixture.plan.seedChannelRows.map((row) => [row.tableName, row.id] as const)), ]); // Only target rows this suite seeds. @@ -578,18 +538,14 @@ const rlsSuiteReady = await (async () => { return baseImmutableColumns.map((col): ImmutableEntityCase => [tableName, col, entityType, rowId]); }); - const seededProductRowIdsByTable = new Map( - iterableRlsProducts.map(([, fixture]) => [fixture.table, fixture.rowId]), - ); + const seededProductRowIdsByTable = new Map(iterableRlsProducts.map(([, fixture]) => [fixture.table, fixture.rowId])); // Product entities add organization_id. Only target rows this suite seeds. const orgProductCases: ImmutableEntityCase[] = appConfig.productEntityTypes.flatMap((entityType) => { const tableName = getTableName(entityTables[entityType as keyof typeof entityTables]); const rowId = seededProductRowIdsByTable.get(tableName); if (!rowId) return []; - return [...baseImmutableColumns, 'organization_id'].map( - (col): ImmutableEntityCase => [tableName, col, entityType, rowId], - ); + return [...baseImmutableColumns, 'organization_id'].map((col): ImmutableEntityCase => [tableName, col, entityType, rowId]); }); const membershipCases: [string, string][] = membershipImmutableColumns.map((col) => ['memberships', col]); @@ -607,34 +563,26 @@ const rlsSuiteReady = await (async () => { it.each(allEntityCases)('should reject %s.%s mutation (%s)', async (tableName, column, _entityType, rowId) => { await expect( unwrapDrizzle( - adminDb.execute( - sql.raw( - `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(column)} = ${fakeValueForColumn(column)} WHERE id = '${rowId}'`, - ), - ), + adminDb.execute(sql.raw(`UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(column)} = ${fakeValueForColumn(column)} WHERE id = '${rowId}'`)), ), ).rejects.toThrow(/immutable/i); }); it.each(membershipCases)('should reject %s.%s mutation', async (tableName, column) => { - const fakeValue = ['tenant_id', 'channel_type'].includes(column) - ? "'hacked'" - : "'00000000-0000-4000-a000-ffffffffffff'"; - await expect( - unwrapDrizzle(adminDb.execute(sql.raw(`UPDATE ${tableName} SET ${column} = ${fakeValue} WHERE 1=1`))), - ).rejects.toThrow(/immutable/i); + const fakeValue = ['tenant_id', 'channel_type'].includes(column) ? "'hacked'" : "'00000000-0000-4000-a000-ffffffffffff'"; + await expect(unwrapDrizzle(adminDb.execute(sql.raw(`UPDATE ${tableName} SET ${column} = ${fakeValue} WHERE 1=1`)))).rejects.toThrow( + /immutable/i, + ); }); it('should reject updates on append-only activities table', async () => { - await expect( - unwrapDrizzle(adminDb.execute(sql.raw("UPDATE activities SET id = 'hacked' WHERE 1=1"))), - ).rejects.toThrow(/append.only|immutable/i); + await expect(unwrapDrizzle(adminDb.execute(sql.raw("UPDATE activities SET id = 'hacked' WHERE 1=1")))).rejects.toThrow( + /append.only|immutable/i, + ); }); it('should allow updating non-immutable columns', async () => { - await expect( - adminDb.execute(sql`UPDATE organizations SET name = 'Updated Name' WHERE id = ${TEST_ORG_A}`), - ).resolves.not.toThrow(); + await expect(adminDb.execute(sql`UPDATE organizations SET name = 'Updated Name' WHERE id = ${TEST_ORG_A}`)).resolves.not.toThrow(); await adminDb.execute(sql`UPDATE organizations SET name = 'RLS Org A' WHERE id = ${TEST_ORG_A}`); }); }); @@ -647,33 +595,26 @@ const rlsSuiteReady = await (async () => { beforeAll(async () => { if (!rolesAvailable) return; - adminRoleDb = drizzle({ - connection: { connectionString: testAdminRoleDatabaseUrl, connectionTimeoutMillis: 5_000 }, - }); + adminRoleDb = drizzle({ connection: { connectionString: testAdminRoleDatabaseUrl, connectionTimeoutMillis: 5_000 } }); }); - it.skipIf(iterableRlsProducts.length === 0)( - 'tenant-scoped runtime read matches the admin_role read and is empty without context', - async () => { - if (!rolesAvailable) return; - const [entityType, fixture] = iterableRlsProducts[0]; - // The generic table read; app declarations with their own loadRows take the same tx. - const source: NotificationSource = { entityType, declaration: {}, mentionable: false, deriveFrom: 'client' }; - const ids = (rows: { id: string }[]) => rows.map((row) => row.id); - - const asAdmin = await loadSubjectRows(source, adminRoleDb as unknown as DbOrTx, [fixture.rowId]); - const asRuntime = await queryAsRuntimeRole<{ id: string }>(TEST_TENANT_A, TEST_USER_A, (tx) => - loadSubjectRows(source, tx as unknown as DbOrTx, [fixture.rowId]), - ); - const withoutContext = await queryWithoutChannel<{ id: string }>((tx) => - loadSubjectRows(source, tx as unknown as DbOrTx, [fixture.rowId]), - ); + it.skipIf(iterableRlsProducts.length === 0)('tenant-scoped runtime read matches the admin_role read and is empty without context', async () => { + if (!rolesAvailable) return; + const [entityType, fixture] = iterableRlsProducts[0]; + // The generic table read; app declarations with their own loadRows take the same tx. + const source: NotificationSource = { entityType, declaration: {} }; + const ids = (rows: { id: string }[]) => rows.map((row) => row.id); - expect(ids(asAdmin), 'admin_role must see the fixture row (owner bypass)').toEqual([fixture.rowId]); - expect(ids(asRuntime), 'tenant-scoped runtime read must match the admin read').toEqual(ids(asAdmin)); - expect(withoutContext, 'no tenant context must fail closed').toEqual([]); - }, - ); + const asAdmin = await loadSubjectRows(source, adminRoleDb as unknown as DbOrTx, [fixture.rowId]); + const asRuntime = await queryAsRuntimeRole<{ id: string }>(TEST_TENANT_A, TEST_USER_A, (tx) => + loadSubjectRows(source, tx as unknown as DbOrTx, [fixture.rowId]), + ); + const withoutContext = await queryWithoutChannel<{ id: string }>((tx) => loadSubjectRows(source, tx as unknown as DbOrTx, [fixture.rowId])); + + expect(ids(asAdmin), 'admin_role must see the fixture row (owner bypass)').toEqual([fixture.rowId]); + expect(ids(asRuntime), 'tenant-scoped runtime read must match the admin read').toEqual(ids(asAdmin)); + expect(withoutContext, 'no tenant context must fail closed').toEqual([]); + }); }); // CDC stamps seq as `admin_role` with no tenant context. The role has no BYPASSRLS (managed providers @@ -684,15 +625,11 @@ const rlsSuiteReady = await (async () => { beforeAll(async () => { if (!rolesAvailable) return; const ADMIN_ROLE_DB_URL = testAdminRoleDatabaseUrl; - adminRoleDb = drizzle({ - connection: { connectionString: ADMIN_ROLE_DB_URL, connectionTimeoutMillis: 5_000 }, - }); + adminRoleDb = drizzle({ connection: { connectionString: ADMIN_ROLE_DB_URL, connectionTimeoutMillis: 5_000 } }); }); it('admin_role owns every RLS table without forced RLS, and holds no BYPASSRLS attribute', async () => { - const role = getRows<{ bypass: boolean }>( - await adminDb.execute(sql`SELECT rolbypassrls AS bypass FROM pg_roles WHERE rolname = 'admin_role'`), - ); + const role = getRows<{ bypass: boolean }>(await adminDb.execute(sql`SELECT rolbypassrls AS bypass FROM pg_roles WHERE rolname = 'admin_role'`)); expect(role[0]?.bypass, 'the suite must prove owner bypass, not the attribute').toBe(false); const blocked = getRows<{ relname: string }>( @@ -708,32 +645,25 @@ const rlsSuiteReady = await (async () => { ).toEqual([]); }); - it.skipIf(iterableRlsProducts.length === 0)( - 'admin_role can UPDATE seq on a product row without tenant context', - async () => { - const [, fixture] = iterableRlsProducts[0]; - const before = getRows<{ seq: string | number }>( - await adminRoleDb.execute(sql.raw(`SELECT seq FROM ${fixture.table} WHERE id = '${fixture.rowId}'`)), - ); - expect(before, 'admin_role must see the product row (owner bypass)').toHaveLength(1); + it.skipIf(iterableRlsProducts.length === 0)('admin_role can UPDATE seq on a product row without tenant context', async () => { + const [, fixture] = iterableRlsProducts[0]; + const before = getRows<{ seq: string | number }>( + await adminRoleDb.execute(sql.raw(`SELECT seq FROM ${fixture.table} WHERE id = '${fixture.rowId}'`)), + ); + expect(before, 'admin_role must see the product row (owner bypass)').toHaveLength(1); - // bigint columns come back as strings from node-pg; coerce - const newSeq = Number(before[0].seq ?? 0) + 1; - const updateResult = await adminRoleDb.execute( - sql.raw( - `UPDATE ${fixture.table} SET seq = ${newSeq}, stx = stx - 'changedFields' WHERE id = '${fixture.rowId}'`, - ), - ); + // bigint columns come back as strings from node-pg; coerce + const newSeq = Number(before[0].seq ?? 0) + 1; + const updateResult = await adminRoleDb.execute( + sql.raw(`UPDATE ${fixture.table} SET seq = ${newSeq}, stx = stx - 'changedFields' WHERE id = '${fixture.rowId}'`), + ); - expect((updateResult as { rowCount?: number }).rowCount, 'UPDATE must affect the row, not silently no-op').toBe( - 1, - ); + expect((updateResult as { rowCount?: number }).rowCount, 'UPDATE must affect the row, not silently no-op').toBe(1); - const after = getRows<{ seq: string | number }>( - await adminDb.execute(sql.raw(`SELECT seq FROM ${fixture.table} WHERE id = '${fixture.rowId}'`)), - ); - expect(Number(after[0].seq)).toBe(newSeq); - }, - ); + const after = getRows<{ seq: string | number }>( + await adminDb.execute(sql.raw(`SELECT seq FROM ${fixture.table} WHERE id = '${fixture.rowId}'`)), + ); + expect(Number(after[0].seq)).toBe(newSeq); + }); }); }); diff --git a/backend/tests/integration/schema-verification.test.ts b/backend/tests/integration/schema-verification.test.ts index af045d747..083a56469 100644 --- a/backend/tests/integration/schema-verification.test.ts +++ b/backend/tests/integration/schema-verification.test.ts @@ -5,13 +5,9 @@ import { baseDb as adminDb } from '#/db/db'; import { entityTables } from '#/tables'; /** Product entities with a parent org (tasks, labels, attachments) have RLS and composite FK. */ -const orgScopedProductTables = appConfig.productEntityTypes.map((t) => - getTableName(entityTables[t as keyof typeof entityTables]), -); +const orgScopedProductTables = appConfig.productEntityTypes.map((t) => getTableName(entityTables[t as keyof typeof entityTables])); -const channelTables = appConfig.channelEntityTypes.map((t) => - getTableName(entityTables[t as keyof typeof entityTables]), -); +const channelTables = appConfig.channelEntityTypes.map((t) => getTableName(entityTables[t as keyof typeof entityTables])); function getRows>(result: any): T[] { if (Array.isArray(result)) return result; @@ -50,11 +46,9 @@ describe('Schema verification', () => { }); describe('Composite foreign keys (tenant_id, organization_id)', () => { - it.each(orgScopedProductTables)( - 'should have composite FK (tenant_id, organization_id) → organizations on %s', - async (tableName) => { - const rows = getRows<{ constraint_name: string; column_name: string }>( - await adminDb.execute(sql` + it.each(orgScopedProductTables)('should have composite FK (tenant_id, organization_id) → organizations on %s', async (tableName) => { + const rows = getRows<{ constraint_name: string; column_name: string }>( + await adminDb.execute(sql` SELECT kcu.constraint_name, kcu.column_name FROM information_schema.key_column_usage kcu JOIN information_schema.table_constraints tc @@ -68,12 +62,11 @@ describe('Schema verification', () => { AND kcu.table_name = ${tableName} AND kcu2.table_name = 'organizations' `), - ); + ); - const columns = rows.map((r) => r.column_name); - expect(columns, `Missing composite FK on ${tableName}`).toContain('tenant_id'); - expect(columns, `Missing composite FK on ${tableName}`).toContain('organization_id'); - }, - ); + const columns = rows.map((r) => r.column_name); + expect(columns, `Missing composite FK on ${tableName}`).toContain('tenant_id'); + expect(columns, `Missing composite FK on ${tableName}`).toContain('organization_id'); + }); }); }); diff --git a/backend/tests/integration/system-roles-write-guard.test.ts b/backend/tests/integration/system-roles-write-guard.test.ts index e36b6fc25..b932cd93e 100644 --- a/backend/tests/integration/system-roles-write-guard.test.ts +++ b/backend/tests/integration/system-roles-write-guard.test.ts @@ -85,16 +85,12 @@ afterAll(async () => { }); it('blocks runtime_role from updating a system role, even with the grant open', async () => { - const message = await rejectionMessage( - runtimeDb.execute(sql`UPDATE system_roles SET role = 'admin' WHERE user_id = ${TEST_USER}`), - ); + const message = await rejectionMessage(runtimeDb.execute(sql`UPDATE system_roles SET role = 'admin' WHERE user_id = ${TEST_USER}`)); expect(message).toMatch(/not writable by runtime_role/); }); it('blocks runtime_role from deleting a system role, even with the grant open', async () => { - const message = await rejectionMessage( - runtimeDb.execute(sql`DELETE FROM system_roles WHERE user_id = ${TEST_USER}`), - ); + const message = await rejectionMessage(runtimeDb.execute(sql`DELETE FROM system_roles WHERE user_id = ${TEST_USER}`)); expect(message).toMatch(/not writable by runtime_role/); const survived = await adminDb.execute(sql`SELECT 1 FROM system_roles WHERE user_id = ${TEST_USER}`); @@ -102,9 +98,7 @@ afterAll(async () => { }); it('still lets the admin connection write system_roles (seeds must work)', async () => { - await expect( - adminDb.execute(sql`UPDATE system_roles SET role = 'admin' WHERE user_id = ${TEST_USER}`), - ).resolves.toBeDefined(); + await expect(adminDb.execute(sql`UPDATE system_roles SET role = 'admin' WHERE user_id = ${TEST_USER}`)).resolves.toBeDefined(); }); it('does not break the ON DELETE CASCADE from users', async () => { diff --git a/backend/tests/integration/test-utils.ts b/backend/tests/integration/test-utils.ts index a4d91a7c4..130a0c158 100644 --- a/backend/tests/integration/test-utils.ts +++ b/backend/tests/integration/test-utils.ts @@ -47,10 +47,7 @@ interface CdcTestHarness { stop(): Promise; } -export function waitForEvent( - eventType: Parameters[0], - timeoutMs = 10000, -): Promise { +export function waitForEvent(eventType: Parameters[0], timeoutMs = 10000): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`Timeout waiting for event: ${eventType}`)); @@ -64,11 +61,7 @@ export function waitForEvent( } /** Poll a predicate until it returns true or the timeout expires. */ -export async function waitFor( - predicate: () => boolean | Promise, - timeoutMs: number, - label: string, -): Promise { +export async function waitFor(predicate: () => boolean | Promise, timeoutMs: number, label: string): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await predicate()) return; @@ -123,21 +116,14 @@ export async function ensureCdcSetup() { const CDC_PUBLICATION_NAME = 'cdc_pub'; const CDC_SLOT_NAME = process.env.CDC_SLOT_NAME ?? 'cdc_slot'; - const pubResult = await db.execute<{ pubname: string }>( - sql`SELECT pubname FROM pg_publication WHERE pubname = ${CDC_PUBLICATION_NAME}`, - ); + const pubResult = await db.execute<{ pubname: string }>(sql`SELECT pubname FROM pg_publication WHERE pubname = ${CDC_PUBLICATION_NAME}`); if (pubResult.rows.length === 0) { throw new Error(`CDC publication '${CDC_PUBLICATION_NAME}' not found. Run migrations first.`); } // The CDC worker creates the replication slot. - const slotResult = await db.execute<{ slot_name: string }>( - sql`SELECT slot_name FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`, - ); + const slotResult = await db.execute<{ slot_name: string }>(sql`SELECT slot_name FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`); - return { - publicationExists: pubResult.rows.length > 0, - slotExists: slotResult.rows.length > 0, - }; + return { publicationExists: pubResult.rows.length > 0, slotExists: slotResult.rows.length > 0 }; } diff --git a/backend/tests/invitations/claim-on-signup.test.ts b/backend/tests/invitations/claim-on-signup.test.ts index e80f944f4..3ae49f936 100644 --- a/backend/tests/invitations/claim-on-signup.test.ts +++ b/backend/tests/invitations/claim-on-signup.test.ts @@ -24,12 +24,7 @@ describe('Pending invitations are claimed by an inbox proof', async () => { /** Invites `invitedEmail` to a fresh organization through the API, as that organization's admin. */ const inviteToNewOrganization = async (index: number) => { const organization = await createTestOrganization(); - const admin = await createOrganizationAdminUser( - `admin${index}@example.com`, - organization.id, - adminRole, - organization.tenantId, - ); + const admin = await createOrganizationAdminUser(`admin${index}@example.com`, organization.id, adminRole, organization.tenantId); const sessionCookie = await createTestSession(admin); const { response } = await call(membershipInvite, { @@ -43,8 +38,7 @@ describe('Pending invitations are claimed by an inbox proof', async () => { return organization; }; - const pendingFor = (email: string) => - db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.email, email)); + const pendingFor = (email: string) => db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.email, email)); const tokensFor = (email: string) => db.select().from(tokensTable).where(eq(tokensTable.email, email)); const newcomer = { email: invitedEmail, slug: 'newcomer', name: 'Newcomer', firstName: 'Newcomer' }; @@ -65,13 +59,7 @@ describe('Pending invitations are claimed by an inbox proof', async () => { const organization = await createTestOrganization(); const user = await handleCreateUser({ var: { db } }, { newUser: newcomer, via: 'magic' }); // Opening the emailed link binds the invitation and its token to the account that proved the address. - await createInvitation({ - organization, - email: invitedEmail, - createdBy: user.id, - boundTo: user.id, - token: 'invoked', - }); + await createInvitation({ organization, email: invitedEmail, createdBy: user.id, boundTo: user.id, token: 'invoked' }); await markEmailVerified(db, { userId: user.id, email: invitedEmail, via: 'magic' }); @@ -93,9 +81,10 @@ describe('Pending invitations are claimed by an inbox proof', async () => { const newUser = { email: 'taken@example.com', slug: 'taken', name: 'Taken', firstName: 'Taken' }; await handleCreateUser({ var: { db } }, { newUser, via: 'magic' }); - await expect( - handleCreateUser({ var: { db } }, { newUser: { ...newUser, slug: 'taken-2' }, via: 'magic' }), - ).rejects.toMatchObject({ status: 409, type: 'email_exists' }); + await expect(handleCreateUser({ var: { db } }, { newUser: { ...newUser, slug: 'taken-2' }, via: 'magic' })).rejects.toMatchObject({ + status: 409, + type: 'email_exists', + }); }); it('does not disguise another failure as a taken address', async () => { diff --git a/backend/tests/invitations/draft-invites.test.ts b/backend/tests/invitations/draft-invites.test.ts index 0245a80a6..4754c2f93 100644 --- a/backend/tests/invitations/draft-invites.test.ts +++ b/backend/tests/invitations/draft-invites.test.ts @@ -12,21 +12,12 @@ import { membershipsTable } from '#/modules/memberships/memberships-db'; import { organizationsTable } from '#/modules/organization/organization-db'; import { hashToken } from '#/utils/hash-token'; import { adminRole, defaultHeaders, memberRole } from '../fixtures'; -import { - createOrganizationAdminUser, - createTestOrganization, - createTestSession, - createTestUser, - mailedLink, -} from '../helpers'; +import { createOrganizationAdminUser, createTestOrganization, createTestSession, createTestUser, mailedLink } from '../helpers'; import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; // Whether an invite left as mail is the observable difference between a held and a dispatched invite. -setTestConfig({ - enabledAuthStrategies: ['passkey'], - selfRegistration: true, -}); +setTestConfig({ enabledAuthStrategies: ['passkey'], selfRegistration: true }); afterEach(async () => await clearDatabase()); @@ -40,24 +31,14 @@ describe('Draft context invite deferral', async () => { const createDraftOrgWorld = async () => { const organization = await createTestOrganization(); - const admin = await createOrganizationAdminUser( - 'admin@example.com', - organization.id, - adminRole, - organization.tenantId, - ); + const admin = await createOrganizationAdminUser('admin@example.com', organization.id, adminRole, organization.tenantId); const sessionCookie = await createTestSession(admin); // The template always publishes at creation; draft state is an app-specific flow. await db.update(organizationsTable).set({ publishedAt: null }).where(eq(organizationsTable.id, organization.id)); return { organization, admin, sessionCookie }; }; - const invite = async ( - organization: { id: string; tenantId: string }, - emails: string[], - role: EntityRole, - sessionCookie: string, - ) => { + const invite = async (organization: { id: string; tenantId: string }, emails: string[], role: EntityRole, sessionCookie: string) => { return await call(membershipInvite, { path: { tenantId: organization.tenantId, organizationId: organization.id }, body: { emails, role }, @@ -114,13 +95,8 @@ describe('Draft context invite deferral', async () => { expect((before.data as { items: unknown[] }).items).toHaveLength(0); // An app's publish flow: stamp publishedAt, then release the held invites - await db - .update(organizationsTable) - .set({ publishedAt: new Date().toISOString() }) - .where(eq(organizationsTable.id, organization.id)); - await dispatchDeferredInvites(publisherContext(admin), { - channelIds: [organization.id], - }); + await db.update(organizationsTable).set({ publishedAt: new Date().toISOString() }).where(eq(organizationsTable.id, organization.id)); + await dispatchDeferredInvites(publisherContext(admin), { channelIds: [organization.id] }); const after = await myInvitations(); expect((after.data as { items: unknown[] }).items).toHaveLength(1); @@ -159,13 +135,8 @@ describe('Draft context invite deferral', async () => { await invite(organization, ['deferred@example.com'], memberRole, sessionCookie); const [held] = await getInactiveRows(organization.id); - await db - .update(organizationsTable) - .set({ publishedAt: new Date().toISOString() }) - .where(eq(organizationsTable.id, organization.id)); - await dispatchDeferredInvites(publisherContext(admin), { - channelIds: [organization.id], - }); + await db.update(organizationsTable).set({ publishedAt: new Date().toISOString() }).where(eq(organizationsTable.id, organization.id)); + await dispatchDeferredInvites(publisherContext(admin), { channelIds: [organization.id] }); const [dispatched] = await getInactiveRows(organization.id); const links = await db.select().from(tokensTable).where(eq(tokensTable.inactiveMembershipId, held.id)); @@ -177,19 +148,12 @@ describe('Draft context invite deferral', async () => { headers: { ...defaultHeaders, Cookie: sessionCookie }, }); const listed = (data as { items: { id: string; email: string }[] }).items; - expect(listed.filter((item) => item.id === held.id)).toEqual([ - expect.objectContaining({ email: 'deferred@example.com' }), - ]); + expect(listed.filter((item) => item.id === held.id)).toEqual([expect.objectContaining({ email: 'deferred@example.com' })]); }); it('throttles reminder emails to once per 7 days on published contexts', async () => { const organization = await createTestOrganization(); - const admin = await createOrganizationAdminUser( - 'admin@example.com', - organization.id, - adminRole, - organization.tenantId, - ); + const admin = await createOrganizationAdminUser('admin@example.com', organization.id, adminRole, organization.tenantId); const sessionCookie = await createTestSession(admin); const invitee = await createTestUser('pending@example.com'); @@ -204,10 +168,7 @@ describe('Draft context invite deferral', async () => { // The throttle check reads remindedAt, not the immutable createdAt. const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - await db - .update(inactiveMembershipsTable) - .set({ remindedAt: eightDaysAgo }) - .where(eq(inactiveMembershipsTable.id, initial.id)); + await db.update(inactiveMembershipsTable).set({ remindedAt: eightDaysAgo }).where(eq(inactiveMembershipsTable.id, initial.id)); const [aged] = await getInactiveRows(organization.id); await invite(organization, [invitee.email], memberRole, sessionCookie); @@ -219,22 +180,14 @@ describe('Draft context invite deferral', async () => { it('reminds a pending invitation to a new address as one to an account, minting no further link', async () => { const organization = await createTestOrganization(); - const admin = await createOrganizationAdminUser( - 'admin@example.com', - organization.id, - adminRole, - organization.tenantId, - ); + const admin = await createOrganizationAdminUser('admin@example.com', organization.id, adminRole, organization.tenantId); const sessionCookie = await createTestSession(admin); const newcomer = 'newcomer@example.com'; await invite(organization, [newcomer], memberRole, sessionCookie); const [initial] = await getInactiveRows(organization.id); const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - await db - .update(inactiveMembershipsTable) - .set({ remindedAt: eightDaysAgo }) - .where(eq(inactiveMembershipsTable.id, initial.id)); + await db.update(inactiveMembershipsTable).set({ remindedAt: eightDaysAgo }).where(eq(inactiveMembershipsTable.id, initial.id)); const { data } = await invite(organization, [newcomer], memberRole, sessionCookie); expect(data).toMatchObject({ rejectedIds: [], invitesSentCount: 0 }); diff --git a/backend/tests/invitations/invitation-respond.test.ts b/backend/tests/invitations/invitation-respond.test.ts index caa1143c6..e9170d269 100644 --- a/backend/tests/invitations/invitation-respond.test.ts +++ b/backend/tests/invitations/invitation-respond.test.ts @@ -11,10 +11,7 @@ import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; import { createInvitation, readMembersAs } from './helpers'; -setTestConfig({ - enabledAuthStrategies: ['passkey'], - selfRegistration: true, -}); +setTestConfig({ enabledAuthStrategies: ['passkey'], selfRegistration: true }); afterEach(async () => await clearDatabase()); @@ -28,10 +25,7 @@ describe('Invitation response', async () => { async function respondToInvitation(inactiveMembershipId: string, action: 'accept' | 'reject', sessionCookie: string) { return await call(handleMembershipInvitation, { path: { id: inactiveMembershipId, acceptOrReject: action }, - headers: { - ...defaultHeaders, - Cookie: sessionCookie, - }, + headers: { ...defaultHeaders, Cookie: sessionCookie }, }); } @@ -57,10 +51,7 @@ describe('Invitation response', async () => { expect(memberships[0].organizationId).toBe(organization.id); expect(memberships[0].role).toBe(memberRole); - const remainingInactive = await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id!)); + const remainingInactive = await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id!)); expect(remainingInactive).toHaveLength(0); }); @@ -126,10 +117,7 @@ describe('Invitation response', async () => { const memberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, invitedUser.id)); expect(memberships).toHaveLength(0); - const rejectedInactive = await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id!)); + const rejectedInactive = await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id!)); expect(rejectedInactive).toHaveLength(1); expect(rejectedInactive[0].rejectedAt).toBeDefined(); }); @@ -145,20 +133,11 @@ describe('Invitation response', async () => { role: memberRole, }); - const { response: res } = await respondToInvitation( - inactiveMembership.id, - 'reject', - await createTestSession(invitedUser), - ); + const { response: res } = await respondToInvitation(inactiveMembership.id, 'reject', await createTestSession(invitedUser)); expect(res.status).toBe(200); - expect( - await db.select().from(tokensTable).where(eq(tokensTable.inactiveMembershipId, inactiveMembership.id)), - ).toHaveLength(0); - const { response, error } = await call(invokeToken, { - path: { type: 'invitation', token: rawToken }, - headers: defaultHeaders, - }); + expect(await db.select().from(tokensTable).where(eq(tokensTable.inactiveMembershipId, inactiveMembership.id))).toHaveLength(0); + const { response, error } = await call(invokeToken, { path: { type: 'invitation', token: rawToken }, headers: defaultHeaders }); await expectRefusal({ response, error }, 401, 'invitation_not_found'); }); @@ -170,10 +149,7 @@ describe('Invitation response', async () => { const { response: res } = await call(handleMembershipInvitation, { path: { id: '00000000-0000-0000-0000-000000000000', acceptOrReject: 'accept' }, - headers: { - ...defaultHeaders, - Cookie: sessionCookie, - }, + headers: { ...defaultHeaders, Cookie: sessionCookie }, }); expect(res.status).toBe(404); @@ -194,22 +170,14 @@ describe('Invitation response', async () => { boundTo: invitedUser.id, role: memberRole, }); - const unbound = await createInvitation({ - organization, - email: 'nobody@example.com', - createdBy: invitedUser.id, - role: memberRole, - }); + const unbound = await createInvitation({ organization, email: 'nobody@example.com', createdBy: invitedUser.id, role: memberRole }); for (const { inactiveMembership } of [bound, unbound]) { const { response: res } = await respondToInvitation(inactiveMembership.id, 'accept', attackerSession); expect(res.status).toBe(404); } - const attackerMemberships = await db - .select() - .from(membershipsTable) - .where(eq(membershipsTable.userId, attacker.id)); + const attackerMemberships = await db.select().from(membershipsTable).where(eq(membershipsTable.userId, attacker.id)); expect(attackerMemberships).toHaveLength(0); const stillInactive = await db.select().from(inactiveMembershipsTable); diff --git a/backend/tests/invitations/invitation-token-accept.test.ts b/backend/tests/invitations/invitation-token-accept.test.ts index b1fd01621..93503a485 100644 --- a/backend/tests/invitations/invitation-token-accept.test.ts +++ b/backend/tests/invitations/invitation-token-accept.test.ts @@ -34,21 +34,13 @@ describe('Accept an invitation token as the signed-in user', async () => { const setup = async (opts: { boundTo?: string | null } = {}) => { const organization = await createTestOrganization(); const inviter = await createTestUser('inviter@example.com'); - const invitation = await createInvitation({ - token: 'invoked', - email: invitedEmail, - organization, - createdBy: inviter.id, - ...opts, - }); + const invitation = await createInvitation({ token: 'invoked', email: invitedEmail, organization, createdBy: inviter.id, ...opts }); return { organization, inviter, ...invitation }; }; - const accept = (cookies: string[]) => - call(acceptInvitationToken, { headers: { ...defaultHeaders, Cookie: cookies.join('; ') } }); + const accept = (cookies: string[]) => call(acceptInvitationToken, { headers: { ...defaultHeaders, Cookie: cookies.join('; ') } }); - const membershipsOf = (userId: string) => - db.select().from(membershipsTable).where(eq(membershipsTable.userId, userId)); + const membershipsOf = (userId: string) => db.select().from(membershipsTable).where(eq(membershipsTable.userId, userId)); it('activates the membership for the session user, spends the invitation and notifies the invited address', async () => { const { organization, token, inactiveMembership, invitationCookie } = await setup(); @@ -65,19 +57,14 @@ describe('Accept an invitation token as the signed-in user', async () => { expect(memberships[0].organizationId).toBe(organization.id); expect(memberships[0].role).toBe(memberRole); - expect( - await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)), - ).toHaveLength(0); + expect(await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id))).toHaveLength(0); expect(await db.select().from(tokensTable).where(eq(tokensTable.id, token.id))).toHaveLength(0); // The invited inbox may not belong to the accepting account, so it hears about the acceptance. expect(mailer.prepareEmails).toHaveBeenCalledTimes(1); const mails = sentMails(); expect(mails.map(({ recipient }) => recipient.email)).toEqual([invitedEmail]); - expect(mails[0].statics).toMatchObject({ - type: 'invitation-accepted-elsewhere', - details: { accountEmail: 'my-account@example.com' }, - }); + expect(mails[0].statics).toMatchObject({ type: 'invitation-accepted-elsewhere', details: { accountEmail: 'my-account@example.com' } }); }); it('lets the new member into the organization right after accepting', async () => { @@ -116,20 +103,14 @@ describe('Accept an invitation token as the signed-in user', async () => { organization, createdBy: owner.id, }); - await db - .update(inactiveMembershipsTable) - .set({ userId: owner.id }) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + await db.update(inactiveMembershipsTable).set({ userId: owner.id }).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); const attacker = await createTestUser('attacker@example.com'); const { response } = await accept([await createTestSession(attacker), invitationCookie]); expect(response.status).toBe(404); expect(await membershipsOf(attacker.id)).toHaveLength(0); - const [still] = await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + const [still] = await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); expect(still.userId).toBe(owner.id); }); @@ -185,10 +166,7 @@ describe('Accept an invitation token as the signed-in user', async () => { const second = await createTestUser('second@example.com'); const [firstSession, secondSession] = await Promise.all([createTestSession(first), createTestSession(second)]); - const results = await Promise.all([ - accept([firstSession, invitationCookie]), - accept([secondSession, invitationCookie]), - ]); + const results = await Promise.all([accept([firstSession, invitationCookie]), accept([secondSession, invitationCookie])]); expect(results.filter((r) => r.response.status === 200)).toHaveLength(1); const total = (await membershipsOf(first.id)).length + (await membershipsOf(second.id)).length; @@ -197,12 +175,7 @@ describe('Accept an invitation token as the signed-in user', async () => { it('spends the invitation without a duplicate membership when the user is already a member', async () => { const organization = await createTestOrganization(); - const me = await createOrganizationAdminUser( - 'my-account@example.com', - organization.id, - adminRole, - organization.tenantId, - ); + const me = await createOrganizationAdminUser('my-account@example.com', organization.id, adminRole, organization.tenantId); const { inactiveMembership, invitationCookie } = await createInvitation({ token: 'invoked', email: invitedEmail, @@ -216,9 +189,7 @@ describe('Accept an invitation token as the signed-in user', async () => { const memberships = await membershipsOf(me.id); expect(memberships).toHaveLength(1); expect(memberships[0].role).toBe(adminRole); - expect( - await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)), - ).toHaveLength(0); + expect(await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id))).toHaveLength(0); }); }); @@ -262,10 +233,7 @@ describe('Opening a token link while signed in', async () => { const { raw, row: link } = await insertTestToken('magic', owner); // Opened in the browser that asked for it, so it is redeemed directly. - await call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: { ...defaultHeaders, Cookie: authCookie('magic-requested', link.id) }, - }); + await call(invokeToken, { path: { type: 'magic', token: raw }, headers: { ...defaultHeaders, Cookie: authCookie('magic-requested', link.id) } }); const [opened] = await db.select().from(tokensTable).where(eq(tokensTable.email, owner.email)); const minutesLeft = (new Date(opened.expiresAt).getTime() - Date.now()) / 60_000; @@ -291,10 +259,7 @@ describe('Opening a token link while signed in', async () => { const { raw, row: link } = await insertTestToken('magic', owner); const cookies = [await createTestSession(me), authCookie('magic-requested', link.id)].join('; '); - const { response } = await call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: { ...defaultHeaders, Cookie: cookies }, - }); + const { response } = await call(invokeToken, { path: { type: 'magic', token: raw }, headers: { ...defaultHeaders, Cookie: cookies } }); expect(response.status).toBe(409); }); diff --git a/backend/tests/invitations/invitation-token.test.ts b/backend/tests/invitations/invitation-token.test.ts index 113f63671..7c7251d24 100644 --- a/backend/tests/invitations/invitation-token.test.ts +++ b/backend/tests/invitations/invitation-token.test.ts @@ -38,10 +38,7 @@ describe('Invitation token data', async () => { expect(response.status).toBe(200); expect((data as { userId: string }).userId).toBe(lateUser.id); - const [bound] = await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + const [bound] = await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); expect(bound.userId).toBe(lateUser.id); // The token survives: this flow's single-use cookie still points at it. @@ -49,21 +46,14 @@ describe('Invitation token data', async () => { expect(keptToken.userId).toBe(lateUser.id); const sessionCookie = await createTestSession(lateUser); - const { data: invitations } = await call(getMyInvitations, { - headers: { ...defaultHeaders, Cookie: sessionCookie }, - }); + const { data: invitations } = await call(getMyInvitations, { headers: { ...defaultHeaders, Cookie: sessionCookie } }); expect((invitations as { total: number }).total).toBe(1); }); it("must not read another invitation's data via a browser that holds a different one", async () => { const organization = await createTestOrganization(); const inviter = await createTestUser('inviter@example.com'); - const held = await createInvitation({ - token: 'invoked', - email: 'held@example.com', - organization, - createdBy: inviter.id, - }); + const held = await createInvitation({ token: 'invoked', email: 'held@example.com', organization, createdBy: inviter.id }); const other = await createInvitation({ email: 'other@example.com', organization, createdBy: inviter.id }); const { response, error } = await call(getTokenData, { diff --git a/backend/tests/invitations/membership-invite.test.ts b/backend/tests/invitations/membership-invite.test.ts index 3413440c2..a23d42704 100644 --- a/backend/tests/invitations/membership-invite.test.ts +++ b/backend/tests/invitations/membership-invite.test.ts @@ -18,10 +18,7 @@ import { import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; -setTestConfig({ - enabledAuthStrategies: ['passkey'], - selfRegistration: true, -}); +setTestConfig({ enabledAuthStrategies: ['passkey'], selfRegistration: true }); afterEach(async () => await clearDatabase()); @@ -30,40 +27,24 @@ describe('Membership Invitation', async () => { const createOrgAndAdmin = async () => { const organization = await createTestOrganization(); - const user = await createOrganizationAdminUser( - 'admin@example.com', - organization.id, - adminRole, - organization.tenantId, - ); + const user = await createOrganizationAdminUser('admin@example.com', organization.id, adminRole, organization.tenantId); const sessionCookie = await createTestSession(user); return { organization, sessionCookie }; }; - const makeInviteRequest = async ( - tenantId: string, - organizationId: string, - inviteData: any, - sessionCookie: string | null, - ) => { + const makeInviteRequest = async (tenantId: string, organizationId: string, inviteData: any, sessionCookie: string | null) => { return await call(membershipInvite, { path: { tenantId, organizationId }, body: inviteData, query: { entityId: organizationId, entityType: 'organization' as const }, - headers: { - ...defaultHeaders, - Cookie: sessionCookie || '', - }, + headers: { ...defaultHeaders, Cookie: sessionCookie || '' }, }); }; const getInactiveMemberships = async (organizationId: string) => { - return await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.organizationId, organizationId)); + return await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.organizationId, organizationId)); }; it('should invite new users to organization', async () => { @@ -169,20 +150,10 @@ describe('Membership Invitation', async () => { const inviteData = { emails: ['user@example.com'], role: memberRole }; - const { response: firstRes } = await makeInviteRequest( - organization.tenantId, - organization.id, - inviteData, - sessionCookie, - ); + const { response: firstRes } = await makeInviteRequest(organization.tenantId, organization.id, inviteData, sessionCookie); expect(firstRes.status).toBe(200); - const { response: secondRes, data } = await makeInviteRequest( - organization.tenantId, - organization.id, - inviteData, - sessionCookie, - ); + const { response: secondRes, data } = await makeInviteRequest(organization.tenantId, organization.id, inviteData, sessionCookie); expect(secondRes.status).toBe(200); const response = data as { data: any[]; rejectedIds: string[]; invitesSentCount: number }; diff --git a/backend/tests/invitations/system-invite.test.ts b/backend/tests/invitations/system-invite.test.ts index ea80742fc..7d9412bb0 100644 --- a/backend/tests/invitations/system-invite.test.ts +++ b/backend/tests/invitations/system-invite.test.ts @@ -9,10 +9,7 @@ import { createSystemAdminUser, createTestSession, createTestUser, mailedLink } import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; -setTestConfig({ - enabledAuthStrategies: ['passkey'], - selfRegistration: true, -}); +setTestConfig({ enabledAuthStrategies: ['passkey'], selfRegistration: true }); afterEach(async () => await clearDatabase()); @@ -25,26 +22,16 @@ describe('System Invitation', async () => { } async function makeInviteRequest(emails: string[], sessionCookie: string) { - return await call(systemInvite, { - body: { emails }, - headers: { ...defaultHeaders, Cookie: sessionCookie }, - }); + return await call(systemInvite, { body: { emails }, headers: { ...defaultHeaders, Cookie: sessionCookie } }); } describe('Basic Functionality', () => { it('should invite new users successfully', async () => { const sessionCookie = await createAdminSession(); - const { response: res, data } = await makeInviteRequest( - ['user1@example.com', 'user2@example.com'], - sessionCookie, - ); + const { response: res, data } = await makeInviteRequest(['user1@example.com', 'user2@example.com'], sessionCookie); expect(res.status).toBe(200); - const response = data as { - data: any[]; - rejectedIds: string[]; - invitesSentCount: number; - }; + const response = data as { data: any[]; rejectedIds: string[]; invitesSentCount: number }; expect(response.invitesSentCount).toBe(2); expect(response.rejectedIds).toHaveLength(0); @@ -57,17 +44,10 @@ describe('System Invitation', async () => { it('should filter out existing users', async () => { await createTestUser('existing@example.com'); const sessionCookie = await createAdminSession(); - const { response: res, data } = await makeInviteRequest( - ['existing@example.com', 'newuser@example.com'], - sessionCookie, - ); + const { response: res, data } = await makeInviteRequest(['existing@example.com', 'newuser@example.com'], sessionCookie); expect(res.status).toBe(200); - const response = data as { - data: any[]; - rejectedIds: string[]; - invitesSentCount: number; - }; + const response = data as { data: any[]; rejectedIds: string[]; invitesSentCount: number }; expect(response.invitesSentCount).toBe(1); // Only new user expect(response.rejectedIds).toContain('existing@example.com'); }); @@ -77,11 +57,7 @@ describe('System Invitation', async () => { const { response: res, data } = await makeInviteRequest(['user@example.com', 'user@example.com'], sessionCookie); expect(res.status).toBe(200); - const response = data as { - data: any[]; - rejectedIds: string[]; - invitesSentCount: number; - }; + const response = data as { data: any[]; rejectedIds: string[]; invitesSentCount: number }; expect(response.invitesSentCount).toBe(1); // Only one invitation sent expect(response.rejectedIds).toHaveLength(0); }); diff --git a/backend/tests/label-description-gates.test.ts b/backend/tests/label-description-gates.test.ts index f22c26850..093724289 100644 --- a/backend/tests/label-description-gates.test.ts +++ b/backend/tests/label-description-gates.test.ts @@ -83,13 +83,7 @@ describe('Label description gates (epic documentation)', async () => { projectId, }); - const baseLabel = { - tenantId: tenant.tenantId, - organizationId: tenant.organization.id, - projectId, - createdBy: tenant.user.id, - stx: mockStxBase(), - }; + const baseLabel = { tenantId: tenant.tenantId, organizationId: tenant.organization.id, projectId, createdBy: tenant.user.id, stx: mockStxBase() }; await db.insert(labelsTable).values([ { ...baseLabel, id: epicLabelId, name: 'Checkout revamp', slug: 'checkout-revamp', mode: 'epic' }, { ...baseLabel, id: secondaryLabelId, name: 'urgent', slug: 'urgent', mode: 'secondary' }, diff --git a/backend/tests/mcp-worker.test.ts b/backend/tests/mcp-worker.test.ts new file mode 100644 index 000000000..96a1039d2 --- /dev/null +++ b/backend/tests/mcp-worker.test.ts @@ -0,0 +1,60 @@ +import { getMcpProtectedResourceMetadata } from 'sdk'; +import { appConfig } from 'shared'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { otel } from '#/lib/tracing'; +import { resourceUri } from '#/modules/oauth-server/resources'; +import { defaultHeaders } from './fixtures'; +import { createTestOrganization } from './helpers'; +import { createTestClient, sdk } from './test-client'; + +type Fetch = (request: Request) => Response | Promise; + +/** What the worker hands `serve` and its shutdown hook, so the test opens no port and stops what the worker starts. */ +const worker = vi.hoisted(() => ({ fetch: undefined as Fetch | undefined, cleanup: undefined as (() => Promise) | undefined })); + +vi.mock('@hono/node-server', async (importOriginal) => ({ + ...(await importOriginal()), + serve: vi.fn(({ fetch }: { fetch: Fetch }) => { + worker.fetch = fetch; + return { close: () => {} }; + }), +})); +vi.mock('shared/utils/worker-lifecycle', () => ({ + setupGracefulShutdown: vi.fn(({ cleanup }: { cleanup: () => Promise }) => { + worker.cleanup = cleanup; + }), +})); +// The worker starts telemetry; a test exports none. +vi.mock('#/lib/tracing', async (importOriginal) => { + const { otel } = await importOriginal(); + return { + otel: { ...otel, start: vi.fn(), verifyConnection: vi.fn(async () => {}), shutdown: vi.fn(async () => {}) }, + }; +}); + +/** + * Under singleVM the API process starts the MCP worker once it listens, so the API may have answered a request by + * then. Hono takes no routes after its first request, so the worker serves an app of its own. + */ +describe('MCP worker folded into the API process', () => { + afterAll(async () => await worker.cleanup?.()); + + it('serves the MCP routes after the API has answered a request', async () => { + const { baseApp } = await import('#/routes'); + await baseApp.request('/health', { headers: defaultHeaders }); + + const { startMcpWorker } = await import('#/modules/mcp/worker/mcp-worker-entry'); + await startMcpWorker({ port: appConfig.devPorts.mcp, inProcess: true }); + // The API process owns telemetry + expect(otel.start).not.toHaveBeenCalled(); + + const org = await createTestOrganization(); + const call = sdk(createTestClient({ fetch: worker.fetch as Fetch })); + const { data, response } = await call(getMcpProtectedResourceMetadata, { + path: { tenantId: org.tenantId, organizationId: org.id }, + headers: defaultHeaders, + }); + expect(response.status).toBe(200); + expect(data).toMatchObject({ resource: resourceUri({ face: 'mcp', tenantId: org.tenantId, organizationId: org.id }) }); + }); +}); diff --git a/backend/tests/mcp.test.ts b/backend/tests/mcp.test.ts index 0c1f930ae..9977891dd 100644 --- a/backend/tests/mcp.test.ts +++ b/backend/tests/mcp.test.ts @@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { getMcpProtectedResourceMetadata, handleMcp } from 'sdk'; import { appConfig } from 'shared'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { attachmentsTable } from '#/modules/attachment/attachment-db'; import { resourceUri } from '#/modules/oauth-server/resources'; import { adminRole, defaultHeaders } from './fixtures'; @@ -21,17 +21,21 @@ import { import { clearSecurityTestData, createOrgUser } from './security/helpers'; import { createAppClient } from './test-client'; -type Rpc = { - jsonrpc: '2.0'; - id: number | null; - result?: Record; - error?: { code: number; message: string; data?: unknown }; -}; -type ToolResult = { - content: { type: string; text: string }[]; - structuredContent?: Record; - isError?: boolean; -}; +type Rpc = { jsonrpc: '2.0'; id: number | null; result?: Record; error?: { code: number; message: string; data?: unknown } }; +type ToolResult = { content: { type: string; text: string }[]; structuredContent?: Record; isError?: boolean }; + +/** Every limiter passes, as in every test, and counts the requests it charges by its key. */ +const charged = vi.hoisted(() => new Map()); +vi.mock('#/middlewares/rate-limiter/core', () => ({ + rateLimiter: (mode: string, key: string) => + Object.assign( + async (_ctx: unknown, next: () => Promise) => { + charged.set(key, (charged.get(key) ?? 0) + 1); + await next(); + }, + { keyPrefix: `${key}_${mode}`, buckets: [] }, + ), +})); const REDIRECT_URI = 'http://localhost:9999/callback'; @@ -105,8 +109,7 @@ describe('MCP on the substrate (Phase E)', async () => { return { response, rpc: (data ?? error) as Rpc }; } - const toolCall = (ctx: Parameters[0], name: string, args: unknown) => - rpc(ctx, 'tools/call', { name, arguments: args }); + const toolCall = (ctx: Parameters[0], name: string, args: unknown) => rpc(ctx, 'tools/call', { name, arguments: args }); const toolResult = (reply: { rpc: Rpc }) => reply.rpc.result as ToolResult; it('publishes protected resource metadata and challenges a tokenless call with it', async () => { @@ -125,9 +128,7 @@ describe('MCP on the substrate (Phase E)', async () => { const anonymous = await rpc({ org }, 'initialize'); expect(anonymous.response.status).toBe(401); - expect(anonymous.response.headers.get('www-authenticate')).toBe( - `Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource"`, - ); + expect(anonymous.response.headers.get('www-authenticate')).toBe(`Bearer resource_metadata="${resource}/.well-known/oauth-protected-resource"`); }); it('showcase 1: a read token lists and reads attachments, and is stepped up on a write', async () => { @@ -138,13 +139,7 @@ describe('MCP on the substrate (Phase E)', async () => { const list = await rpc(ctx, 'tools/list'); const tools = (list.rpc.result as { tools: { name: string; _meta: { scope: string } }[] }).tools; expect(tools.map((tool) => tool.name)).toEqual( - expect.arrayContaining([ - 'getAttachments', - 'getAttachment', - 'createAttachments', - 'updateAttachment', - 'deleteAttachments', - ]), + expect.arrayContaining(['getAttachments', 'getAttachment', 'createAttachments', 'updateAttachment', 'deleteAttachments']), ); // Query values are strings, as the route reads them. @@ -152,22 +147,28 @@ describe('MCP on the substrate (Phase E)', async () => { expect(read.response.status).toBe(200); expect(toolResult(read).structuredContent).toMatchObject({ items: [], total: 0 }); - const write = await toolCall(ctx, 'updateAttachment', { - id: '00000000-0000-4000-8000-000000000000', - ops: { name: 'x' }, - }); + const write = await toolCall(ctx, 'updateAttachment', { id: '00000000-0000-4000-8000-000000000000', ops: { name: 'x' } }); expect(write.response.status).toBe(403); - expect(write.response.headers.get('www-authenticate')).toContain( - 'error="insufficient_scope", scope="attachment:write"', - ); + expect(write.response.headers.get('www-authenticate')).toContain('error="insufficient_scope", scope="attachment:write"'); expect(write.rpc.error).toMatchObject({ message: 'insufficient_scope', data: { scope: 'attachment:write' } }); }); + it('counts a tool call once against the burst limit, at the route it runs', async () => { + const ctx = await serviceToken('attachment:read'); + charged.clear(); + + // The endpoint's own requests have a bucket of their own. + expect((await rpc(ctx, 'tools/list')).response.status).toBe(200); + expect(Object.fromEntries(charged)).toEqual({ mcpRequest: 1 }); + + // The route charges what a REST request with the same token is charged: the burst once, plus its own limiters. + expect((await toolCall(ctx, 'getAttachments', {})).response.status).toBe(200); + expect(Object.fromEntries(charged)).toEqual({ mcpRequest: 2, serviceBurst: 1, syncRead: 1 }); + }); + it('showcase 3: a service account creates, reads, renames and deletes through the same tools', async () => { const ctx = await serviceToken('attachment:write'); - const created = await toolCall(ctx, 'createAttachments', { - items: [buildItem('Build log', 'build.log', ctx)], - }); + const created = await toolCall(ctx, 'createAttachments', { items: [buildItem('Build log', 'build.log', ctx)] }); expect(created.rpc.error).toBeUndefined(); expect(created.response.status).toBe(200); const result = created.rpc.result as ToolResult; @@ -175,8 +176,7 @@ describe('MCP on the substrate (Phase E)', async () => { const { data: items } = result.structuredContent as { data: { id: string; name: string }[] }; expect(items).toHaveLength(1); // Provenance is the actor id; the wire shape hydrates users only (service badges are a UI follow-up). - const provenance = async (id: string) => - (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; + const provenance = async (id: string) => (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; expect((await provenance(items[0].id)).createdBy).toBe(ctx.accountId); // `write` implies `read` (D2). @@ -196,16 +196,10 @@ describe('MCP on the substrate (Phase E)', async () => { it('showcase 2: a person consents to a registered app, is refused a rename, steps up and renames as themselves', async () => { const reader = await userToken('attachment:read'); - expect(reader.consent).toMatchObject({ - client: { id: CLIENT_ID, kind: 'registered' }, - scopes: ['attachment:read'], - refusal: null, - }); + expect(reader.consent).toMatchObject({ client: { id: CLIENT_ID, kind: 'registered' }, scopes: ['attachment:read'], refusal: null }); const seed = await serviceToken('attachment:write'); - const created = await toolCall(seed, 'createAttachments', { - items: [buildItem('Thesis', 'thesis.pdf', seed)], - }); + const created = await toolCall(seed, 'createAttachments', { items: [buildItem('Thesis', 'thesis.pdf', seed)] }); expect(created.rpc.error).toBeUndefined(); const { data: items } = toolResult(created).structuredContent as { data: { id: string }[] }; @@ -224,9 +218,7 @@ describe('MCP on the substrate (Phase E)', async () => { const writer = await userToken('attachment:read attachment:write', reader); const moved = await toolCall({ org: seed.org, jwt: seed.jwt }, 'getAttachment', { id: items[0].id }); expect(moved.response.status).toBe(200); - const own = await toolCall(writer, 'createAttachments', { - items: [buildItem('Draft', 'draft.pdf', writer)], - }); + const own = await toolCall(writer, 'createAttachments', { items: [buildItem('Draft', 'draft.pdf', writer)] }); expect(own.rpc.error).toBeUndefined(); expect(toolResult(own).isError).toBeUndefined(); const mine = (toolResult(own).structuredContent as { data: { id: string }[] }).data[0]; diff --git a/backend/tests/member-counts.test.ts b/backend/tests/member-counts.test.ts new file mode 100644 index 000000000..7d973abd8 --- /dev/null +++ b/backend/tests/member-counts.test.ts @@ -0,0 +1,78 @@ +import { getMembers } from 'sdk'; +import { appConfig } from 'shared'; +import { buildTestEntityHierarchyPlan } from 'shared/testing/entity-hierarchy'; +import { generateId } from 'shared/utils/entity-id'; +import { nanoid } from 'shared/utils/nanoid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getAdminDb } from '#/db/db'; +import { hasPublishedAt } from '#/db/utils/published-predicate'; +import { buildInsertableProduct } from '#/mocks/product-mock-registry'; +import { getEntityTable } from '#/tables'; +import { defaultHeaders, memberRole } from './fixtures'; +import { seedEntityHierarchy } from './hierarchy-helpers'; +import { clearSecurityTestData, createOrgUser, createTestTenant, type TestTenant } from './security/helpers'; +import { createAppClient } from './test-client'; + +type Counts = { products: Record; activity: Record }; +type MemberItem = { id: string; counts?: Counts }; + +/** + * Member counts read RLS-guarded product tables, so the route reads them as the request's tenant. Under the RLS-subject + * runtime role (`pnpm test:core:runtime`) a read without the tenant sees no rows, and every count comes back zero. + */ +describe('member counts (include=counts)', async () => { + const call = await createAppClient(); + const statType = appConfig.memberStatProductTypes[0]; + const authored = 3; + let tenant: TestTenant; + let memberId: string; + + beforeAll(async () => { + const label = `member-counts-${nanoid(6)}`; + tenant = await createTestTenant(call, label); + const member = await createOrgUser(call, tenant.tenantId, tenant.organization.id, `${label}-member`, memberRole); + memberId = member.id; + + // The member authors rows in the organization, at the channels the product type lives under. + const adminDb = getAdminDb('test setup'); + const plan = buildTestEntityHierarchyPlan({ entityType: statType, organizationId: tenant.organization.id, makeChannelId: () => generateId() }); + await seedEntityHierarchy(adminDb, plan, { tenantId: tenant.tenantId, createdBy: memberId, slugPrefix: label }); + const table = getEntityTable(statType); + const rows = Array.from({ length: authored }, (_, index) => + buildInsertableProduct( + statType, + { + id: generateId(), + tenantId: tenant.tenantId, + ...plan.channelIdColumns, + createdBy: memberId, + updatedBy: null, + deletedBy: null, + deletedAt: null, + ...(hasPublishedAt(table) && { publishedAt: new Date().toISOString() }), + }, + `${label}-${index}`, + ), + ); + // buildInsertableProduct returns a config-derived Record, so the insert type needs a cast. + await adminDb.insert(table).values(rows as (typeof table.$inferInsert)[]); + }); + + afterAll(async () => await clearSecurityTestData()); + + it('counts the rows a member authored in the channel', async () => { + const { data, error } = await call(getMembers, { + path: { tenantId: tenant.tenantId, organizationId: tenant.organization.id }, + query: { entityId: tenant.organization.id, entityType: 'organization', include: 'counts' }, + headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, + }); + + expect(error).toBeUndefined(); + const items = (data as { items: MemberItem[] }).items; + const member = items.find(({ id }) => id === memberId); + expect(member?.counts?.products[statType]).toBe(authored); + expect(member?.counts?.activity[statType]).toEqual(expect.any(Number)); + // Rows count for their author only: the admin authored none. + expect(items.find(({ id }) => id === tenant.user.id)?.counts?.products[statType]).toBe(0); + }); +}); diff --git a/backend/tests/notification-digest.test.ts b/backend/tests/notification-digest.test.ts new file mode 100644 index 000000000..8e126b280 --- /dev/null +++ b/backend/tests/notification-digest.test.ts @@ -0,0 +1,98 @@ +import { eq } from 'drizzle-orm'; +import { createAttachments } from 'sdk'; +import { generateId } from 'shared/utils/entity-id'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { baseDb as db } from '#/db/db'; +import { attachmentsTable } from '#/modules/attachment/attachment-db'; +import { notificationPreferencesTable, notificationsTable } from '#/modules/notification/notification-db'; +import { runDigest } from '#/modules/notification/operations/run-digest'; +import { adminRole, defaultHeaders } from './fixtures'; +import { adminDb, mailsTo } from './helpers'; +import { attachmentBody, seedAttachmentHome } from './hierarchy-helpers'; +import { clearSecurityTestData, createOrgUser, createTestTenant, type TestTenant } from './security/helpers'; +import { createAppClient } from './test-client'; +import { setTestConfig } from './test-utils'; + +setTestConfig({ enabledAuthStrategies: ['passkey'] }); + +/** Noon, local time, on the next `isoWeekday` (1 = Monday … 7 = Sunday) after today: past the send hour. */ +const nextNoonOn = (isoWeekday: number) => { + const date = new Date(); + date.setHours(12, 0, 0, 0); + do date.setDate(date.getDate() + 1); + while ((date.getDay() || 7) !== isoWeekday); + return date; +}; + +// The weekly digest is the default cadence: an account that never opened its notification settings has no +// preferences row and still gets it. +describe('Weekly digest by default', async () => { + const call = await createAppClient(); + let tenant: TestTenant; + let member: { id: string; email: string }; + /** A member with nothing to digest: the run never walks it. */ + let idle: { id: string; email: string }; + const attachmentId = generateId(); + + const preferencesOf = async (userId: string) => + (await db.select().from(notificationPreferencesTable).where(eq(notificationPreferencesTable.userId, userId)))[0]; + + beforeAll(async () => { + tenant = await createTestTenant(call, 'digest-default'); + member = await createOrgUser(call, tenant.tenantId, tenant.organization.id, 'digest-default-member', adminRole); + idle = await createOrgUser(call, tenant.tenantId, tenant.organization.id, 'digest-default-idle', adminRole); + + const home = await seedAttachmentHome({ id: tenant.organization.id, tenantId: tenant.tenantId }, tenant.user.id); + const { response } = await call(createAttachments, { + path: { tenantId: tenant.tenantId, organizationId: tenant.organization.id }, + body: [attachmentBody(attachmentId, home)], + headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, + }); + expect(response.status).toBe(201); + await adminDb.update(attachmentsTable).set({ name: 'Weekly item' }).where(eq(attachmentsTable.id, attachmentId)); + + // Undigested and never mailed instantly, an hour old. + await db.insert(notificationsTable).values({ + createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + userId: member.id, + actorId: tenant.user.id, + type: 'comment', + entityType: 'attachment', + subjectId: attachmentId, + contextId: attachmentId, + channelId: tenant.organization.id, + channelType: 'organization', + organizationId: tenant.organization.id, + tenantId: tenant.tenantId, + activityId: `act:${generateId()}`, + }); + }); + + afterAll(async () => { + await db.delete(notificationsTable).where(eq(notificationsTable.userId, member.id)); + await clearSecurityTestData(); + }); + + it('skips a user without a preferences row on a weekday that is not the weekly one', async () => { + expect(await preferencesOf(member.id)).toBeUndefined(); + await runDigest(nextNoonOn(4)); + expect(mailsTo(member.email)).toEqual([]); + }); + + it('mails the weekly digest to a user without a preferences row and stamps the run on a new row', async () => { + expect(await preferencesOf(member.id)).toBeUndefined(); + const friday = nextNoonOn(5); + await runDigest(friday); + + const [digest] = mailsTo(member.email); + const sectionsHtml = digest && 'sectionsHtml' in digest.recipient ? String(digest.recipient.sectionsHtml) : ''; + expect(sectionsHtml).toContain('Weekly item'); + const preferences = await preferencesOf(member.id); + expect(preferences?.digest).toBe('weekly'); + expect(preferences?.lastDigestAt).not.toBeNull(); + + // A user with nothing undigested is not due, so the run neither mails nor stamps it. + expect(mailsTo(idle.email)).toEqual([]); + expect(await preferencesOf(idle.id)).toBeUndefined(); + }); +}); diff --git a/backend/tests/oauth-helpers.ts b/backend/tests/oauth-helpers.ts index 557cc2327..6f0e361b4 100644 --- a/backend/tests/oauth-helpers.ts +++ b/backend/tests/oauth-helpers.ts @@ -20,10 +20,7 @@ type KeyScopes = NonNullable['scopes']; type OrgPath = { id: string; tenantId: string }; /** A machine caller's headers: a bearer API key or access token, and neither Origin nor cookie, as a server sends. */ -export const bearerHeaders = (token: string) => ({ - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, -}); +export const bearerHeaders = (token: string) => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }); /** * A service account created in `org` by its admin through the route, with one live secret key; the key doubles as the @@ -46,8 +43,7 @@ export async function serviceAccountWithKey( } /** Registers an OAuth app deployment-wide; registering it again keeps the first row. */ -export const registerApp = (app: typeof oauthClientsTable.$inferInsert) => - db.insert(oauthClientsTable).values(app).onConflictDoNothing(); +export const registerApp = (app: typeof oauthClientsTable.$inferInsert) => db.insert(oauthClientsTable).values(app).onConflictDoNothing(); /** * Installs a registered app in `org` as its admin does: a service account with the organization's least privileged @@ -61,10 +57,7 @@ export async function installApp(org: OrgPath, adminCookie: string, clientId: st headers: { ...defaultHeaders, Cookie: adminCookie }, }); const installationId = (data as { serviceAccount: { id: string } }).serviceAccount.id; - await db - .update(serviceAccountsTable) - .set({ oauthClientId: clientId }) - .where(eq(serviceAccountsTable.id, installationId)); + await db.update(serviceAccountsTable).set({ oauthClientId: clientId }).where(eq(serviceAccountsTable.id, installationId)); return installationId; } @@ -99,10 +92,7 @@ export function serveClientMetadataDocuments(documents: Record spy.mockRestore(); } @@ -169,16 +159,7 @@ export async function startAuthorization(issuer: string, input: AuthorizationInp const uid = /\/oauth\/interaction\/([^/?]+)/.exec(location)?.[1] ?? null; const redirect = location.startsWith(input.redirectUri) ? new URL(location).searchParams : null; if (redirect && redirect.get('state') !== state) throw new Error('state mismatch'); - return { - browser, - verifier, - state, - status: start.status, - location, - uid, - redirect, - code: redirect?.get('code') ?? null, - }; + return { browser, verifier, state, status: start.status, location, uid, redirect, code: redirect?.get('code') ?? null }; } /** @@ -196,8 +177,7 @@ export async function authorizationCode( const details = await fetch(`${origin}/oauth/interaction/${uid}/details`, { headers: { Cookie: jar.header() } }); const consent = (await details.json()) as Record; - if (details.status !== 200) - return { code: null, verifier, consent, failure: { status: details.status, body: consent } }; + if (details.status !== 200) return { code: null, verifier, consent, failure: { status: details.status, body: consent } }; const decision = await fetch(`${origin}/oauth/interaction/${uid}/consent`, { method: 'POST', @@ -226,8 +206,7 @@ export async function authorizationCode( if (next.startsWith(input.redirectUri)) { const params = new URL(next).searchParams; if (params.get('state') !== state) throw new Error('state mismatch'); - if (params.get('error')) - return { code: null, verifier, consent, failure: { status: 400, body: Object.fromEntries(params) } }; + if (params.get('error')) return { code: null, verifier, consent, failure: { status: 400, body: Object.fromEntries(params) } }; code = params.get('code'); } else { location = next.startsWith('/') ? `${origin}${next}` : next; @@ -262,11 +241,7 @@ export function exchangeCode( /** The refresh_token grant of a public client; the provider rotates the refresh token on every use. */ export function refreshAccessToken(issuer: string, input: { clientId: string; refreshToken: string }) { - return tokenRequest(issuer, { - grant_type: 'refresh_token', - refresh_token: input.refreshToken, - client_id: input.clientId, - }); + return tokenRequest(issuer, { grant_type: 'refresh_token', refresh_token: input.refreshToken, client_id: input.clientId }); } /** Consent and code exchange in one go; a refusal on the way comes back as `status` and `body`. */ diff --git a/backend/tests/oauth-server.test.ts b/backend/tests/oauth-server.test.ts index 5543165e7..d36ed6539 100644 --- a/backend/tests/oauth-server.test.ts +++ b/backend/tests/oauth-server.test.ts @@ -51,15 +51,11 @@ describe('OAuth authorization server', async () => { const response = await fetch(`${oauth.issuer}/.well-known/oauth-authorization-server`); expect(response.status).toBe(200); const metadata = (await response.json()) as Record; - expect(metadata.grant_types_supported).toEqual( - expect.arrayContaining(['authorization_code', 'refresh_token', 'client_credentials']), - ); + expect(metadata.grant_types_supported).toEqual(expect.arrayContaining(['authorization_code', 'refresh_token', 'client_credentials'])); expect(metadata.code_challenge_methods_supported).toEqual(['S256']); expect(metadata.client_id_metadata_document_supported).toBe(true); - const jwks = await fetch(`${oauth.issuer}/jwks`).then( - (r) => r.json() as Promise<{ keys: { kid: string; d?: string }[] }>, - ); + const jwks = await fetch(`${oauth.issuer}/jwks`).then((r) => r.json() as Promise<{ keys: { kid: string; d?: string }[] }>); expect(jwks.keys.length).toBeGreaterThanOrEqual(2); expect(jwks.keys.every((key) => !key.d)).toBe(true); }); @@ -72,12 +68,7 @@ describe('OAuth authorization server', async () => { expect(body.token_type).toBe('Bearer'); const token = await verifyAccessToken(String(body.access_token), { tenantId: client.org.tenantId }); - expect(token).toMatchObject({ - kind: 'service', - actorId: client.clientId, - tenantId: client.org.tenantId, - scopes: ['attachment:read'], - }); + expect(token).toMatchObject({ kind: 'service', actorId: client.clientId, tenantId: client.org.tenantId, scopes: ['attachment:read'] }); }); it('refuses a wrong client secret and a resource outside this deployment', async () => { @@ -99,10 +90,7 @@ describe('OAuth authorization server', async () => { const { body } = await clientCredentials(client, { scope: 'attachment:read', resource }); const jwt = String(body.access_token); - const read = await call(getAttachments, { - path: { tenantId: client.org.tenantId, organizationId: client.org.id }, - headers: bearerHeaders(jwt), - }); + const read = await call(getAttachments, { path: { tenantId: client.org.tenantId, organizationId: client.org.id }, headers: bearerHeaders(jwt) }); expect(read.response.status).toBe(200); // The account is an admin, the token only carries attachment:read: under that mask it cannot read the @@ -114,10 +102,7 @@ describe('OAuth authorization server', async () => { }); expect(write.response.status).toBe(404); - const otherTenant = await call(getAttachments, { - path: { tenantId: 'other01', organizationId: client.org.id }, - headers: bearerHeaders(jwt), - }); + const otherTenant = await call(getAttachments, { path: { tenantId: 'other01', organizationId: client.org.id }, headers: bearerHeaders(jwt) }); expect(otherTenant.response.status).toBe(401); }); @@ -145,10 +130,7 @@ describe('OAuth authorization server', async () => { }); expect(result.consent).toMatchObject({ client: { id: clientId, kind: 'cimd' }, refusal: null }); expect(result.status).toBe(200); - const token = await verifyAccessToken(String(result.body.access_token), { - tenantId: org.tenantId, - organizationId: org.id, - }); + const token = await verifyAccessToken(String(result.body.access_token), { tenantId: org.tenantId, organizationId: org.id }); expect(token).toMatchObject({ kind: 'user', actorId: user.id, clientId, scopes: ['attachment:read'] }); } finally { restore(); diff --git a/backend/tests/organization-member-previews.test.ts b/backend/tests/organization-member-previews.test.ts index 78e7994e2..2c8a3b2d7 100644 --- a/backend/tests/organization-member-previews.test.ts +++ b/backend/tests/organization-member-previews.test.ts @@ -28,21 +28,12 @@ describe('Organization member previews (include=members)', async () => { let memberUserId: string; const listOrganizations = async (query: Record = {}) => { - const result = await call(getOrganizations, { - query, - headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, - }); + const result = await call(getOrganizations, { query, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie } }); const data = result.data as { items: OrgListItem[]; total: number } | undefined; return { status: result.response.status, items: data?.items ?? [], total: data?.total ?? 0 }; }; - const insertMembership = async ( - userId: string, - orgId: string, - role: EntityRole, - createdAt: string, - tenantId: string = tenant.tenantId, - ) => { + const insertMembership = async (userId: string, orgId: string, role: EntityRole, createdAt: string, tenantId: string = tenant.tenantId) => { await db.insert(membershipsTable).values({ id: generateId(), userId, diff --git a/backend/tests/public-read-routes.test.ts b/backend/tests/public-read-routes.test.ts index 0b755f578..792624dc0 100644 --- a/backend/tests/public-read-routes.test.ts +++ b/backend/tests/public-read-routes.test.ts @@ -29,11 +29,7 @@ describe('Public read routes (engine-resolved grants, anonymous actor)', async ( beforeAll(async () => { tenant = await createTestTenant(call, 'public-read-routes'); - const baseProject = { - tenantId: tenant.tenantId, - organizationId: tenant.organization.id, - createdBy: tenant.user.id, - }; + const baseProject = { tenantId: tenant.tenantId, organizationId: tenant.organization.id, createdBy: tenant.user.id }; await db.insert(projectsTable).values([ { ...baseProject, @@ -42,13 +38,7 @@ describe('Public read routes (engine-resolved grants, anonymous actor)', async ( slug: `public-project-${publicProjectId.slice(0, 8)}`, publicAt: new Date().toISOString(), }, - { - ...baseProject, - id: privateProjectId, - name: 'Private project', - slug: `private-project-${privateProjectId.slice(0, 8)}`, - publicAt: null, - }, + { ...baseProject, id: privateProjectId, name: 'Private project', slug: `private-project-${privateProjectId.slice(0, 8)}`, publicAt: null }, ]); const publicAt = new Date().toISOString(); @@ -65,20 +55,12 @@ describe('Public read routes (engine-resolved grants, anonymous actor)', async ( await db.insert(tasksTable).values([ { ...baseTask, id: publicTaskId, name: 'public task', projectId: publicProjectId, publicAt }, { ...baseTask, id: privateTaskId, name: 'private task', projectId: privateProjectId, publicAt: null }, - { - ...baseTask, - id: publicTaskInPrivateProjectId, - name: 'public task, private project', - projectId: privateProjectId, - publicAt, - }, + { ...baseTask, id: publicTaskInPrivateProjectId, name: 'public task, private project', projectId: privateProjectId, publicAt }, ]); }); afterAll(async () => { - await db - .delete(tasksTable) - .where(inArray(tasksTable.id, [publicTaskId, privateTaskId, publicTaskInPrivateProjectId])); + await db.delete(tasksTable).where(inArray(tasksTable.id, [publicTaskId, privateTaskId, publicTaskInPrivateProjectId])); await db.delete(projectsTable).where(inArray(projectsTable.id, [publicProjectId, privateProjectId])); await clearSecurityTestData(); }); @@ -99,10 +81,7 @@ describe('Public read routes (engine-resolved grants, anonymous actor)', async ( expect(privateResult.response.status).toBe(403); // Decoupled: a public task in a private project is readable - const decoupledResult = await call(getPublicTask, { - path: { id: publicTaskInPrivateProjectId }, - headers: defaultHeaders, - }); + const decoupledResult = await call(getPublicTask, { path: { id: publicTaskInPrivateProjectId }, headers: defaultHeaders }); expect(decoupledResult.response.status).toBe(200); }); diff --git a/backend/tests/recalculate-sequence.test.ts b/backend/tests/recalculate-sequence.test.ts index 3ab2f06e0..1c5123cca 100644 --- a/backend/tests/recalculate-sequence.test.ts +++ b/backend/tests/recalculate-sequence.test.ts @@ -30,10 +30,7 @@ describe('recalculateCounters (sequence + frontier)', async () => { // deepest strict ancestor; invented ids remain only for strict deeper ancestors. const nullableAncestors = new Set(hierarchy.getNullableAncestors(PRODUCT)); const deeperAncestorIds = Object.fromEntries( - ANCESTORS.filter((type) => type !== 'organization' && !nullableAncestors.has(type)).map((type) => [ - type, - crypto.randomUUID(), - ]), + ANCESTORS.filter((type) => type !== 'organization' && !nullableAncestors.has(type)).map((type) => [type, crypto.randomUUID()]), ); const homeChannelId = () => { const deepest = ANCESTORS.find((type) => type === 'organization' || !nullableAncestors.has(type)); @@ -41,10 +38,7 @@ describe('recalculateCounters (sequence + frontier)', async () => { }; const ancestorColumns = (orgId: string) => Object.fromEntries( - ANCESTORS.map((type) => [ - appConfig.entityIdColumnKeys[type], - type === 'organization' ? orgId : (deeperAncestorIds[type] ?? null), - ]), + ANCESTORS.map((type) => [appConfig.entityIdColumnKeys[type], type === 'organization' ? orgId : (deeperAncestorIds[type] ?? null)]), ); beforeAll(async () => { @@ -75,15 +69,7 @@ describe('recalculateCounters (sequence + frontier)', async () => { // Audit users are nulled: mock ids have no users rows and the columns are nullable FKs. buildInsertableProduct( PRODUCT, - { - tenantId: tenant.tenantId, - ...ancestorColumns(tenant.organization.id), - createdBy: null, - updatedBy: null, - deletedBy: null, - seq, - ...extra, - }, + { tenantId: tenant.tenantId, ...ancestorColumns(tenant.organization.id), createdBy: null, updatedBy: null, deletedBy: null, seq, ...extra }, key, ); diff --git a/backend/tests/request-id.test.ts b/backend/tests/request-id.test.ts new file mode 100644 index 000000000..109da580d --- /dev/null +++ b/backend/tests/request-id.test.ts @@ -0,0 +1,44 @@ +import { createOtelSDK } from 'shared/otel'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { collectingExporter, defaultHeaders, type ExportedSpan } from './fixtures'; + +type Answer = { status: number; header: string | null; body: Record }; + +/** A user quotes one id for a failed request: the server makes it, and the header, error body and request span carry it. */ +describe('request id', () => { + const exported: ExportedSpan[] = []; + const answers: Answer[] = []; + + beforeAll(async () => { + const otel = createOtelSDK({ serviceName: 'test-api', traceExporter: collectingExporter(exported), autoInstrumentations: false }); + otel.start(); + const { baseApp } = await import('#/routes'); + + // Without a session `/me` answers 401 with an error body; the second request brings an id of its own. + for (const headers of [defaultHeaders, { ...defaultHeaders, 'X-Request-Id': 'caller-chosen-id' }]) { + const response = await baseApp.request('/me', { headers }); + answers.push({ status: response.status, header: response.headers.get('X-Request-Id'), body: await response.json() }); + } + await otel.shutdown(); + }); + + it('answers an error with the request id of its header', () => { + for (const { status, header, body } of answers) { + expect(status).toBe(401); + expect(header).toMatch(/^[0-9a-f-]{36}$/); + expect(body.requestId).toBe(header); + expect(body).not.toHaveProperty('logId'); + } + }); + + it('must not take the request id from the caller', () => { + const [first, second] = answers; + expect(second.header).not.toBe('caller-chosen-id'); + expect(second.header).not.toBe(first.header); + }); + + it('records the request id on the request span', () => { + const recorded = exported.map((span) => span.attributes['http.response.header.x-request-id']); + expect(recorded).toEqual(expect.arrayContaining(answers.map(({ header }) => header))); + }); +}); diff --git a/backend/tests/security/account-enumeration.test.ts b/backend/tests/security/account-enumeration.test.ts index 2d6a158b5..37e32cf0f 100644 --- a/backend/tests/security/account-enumeration.test.ts +++ b/backend/tests/security/account-enumeration.test.ts @@ -50,12 +50,8 @@ describe('Account enumeration', async () => { it('must not learn whether an address has an account via the passkey challenge', async () => { const { account, stranger } = await accountAndStranger(); - const forAccount = await challengeShape( - await post('/auth/passkey/generate-challenge', { type: 'authentication', email: account.email }), - ); - const forStranger = await challengeShape( - await post('/auth/passkey/generate-challenge', { type: 'authentication', email: stranger }), - ); + const forAccount = await challengeShape(await post('/auth/passkey/generate-challenge', { type: 'authentication', email: account.email })); + const forStranger = await challengeShape(await post('/auth/passkey/generate-challenge', { type: 'authentication', email: stranger })); expect(forAccount).toEqual(forStranger); expect(forAccount).toEqual({ status: 200, credentialIds: [] }); @@ -67,11 +63,7 @@ describe('Account enumeration', async () => { /** Answers a fresh challenge with a passkey no account holds, naming `email`. */ const signInAs = async (email: string) => { const { challenge, cookie } = await passkeyChallenge('authentication'); - const res = await post( - '/auth/passkey-verification', - { type: 'authentication', email, assertion: softwarePasskey().assert(challenge) }, - cookie, - ); + const res = await post('/auth/passkey-verification', { type: 'authentication', email, assertion: softwarePasskey().assert(challenge) }, cookie); const { type } = (await res.json()) as { type?: string }; return { status: res.status, type }; }; diff --git a/backend/tests/security/api-key-scope.test.ts b/backend/tests/security/api-key-scope.test.ts index 390585f3a..df6b7b7c8 100644 --- a/backend/tests/security/api-key-scope.test.ts +++ b/backend/tests/security/api-key-scope.test.ts @@ -9,13 +9,7 @@ import { verifyAccessToken } from '#/modules/oauth-server/verify-access-token'; import { organizationsTable } from '#/modules/organization/organization-db'; import { adminRole } from '../fixtures'; import { createTestOrganization } from '../helpers'; -import { - bearerHeaders, - clientCredentialsToken, - serviceAccountWithKey, - startTestOauthServer, - type TestOauthServer, -} from '../oauth-helpers'; +import { bearerHeaders, clientCredentialsToken, serviceAccountWithKey, startTestOauthServer, type TestOauthServer } from '../oauth-helpers'; import { createAppClient } from '../test-client'; import { clearSecurityTestData, createOrgUser } from './helpers'; @@ -68,12 +62,9 @@ describe('API key scopes at the token endpoint', async () => { it('keeps read under a write scope: a write key may mint a read token', async () => { const account = await adminAccountWithKey(['attachment:write']); - const token = await verifyAccessToken( - await tokenFor(account, 'attachment:read attachment:write organization:read'), - { - tenantId: account.org.tenantId, - }, - ); + const token = await verifyAccessToken(await tokenFor(account, 'attachment:read attachment:write organization:read'), { + tenantId: account.org.tenantId, + }); expect(token.scopes.sort()).toEqual(['attachment:read', 'attachment:write']); }); diff --git a/backend/tests/security/attachment-idempotency.test.ts b/backend/tests/security/attachment-idempotency.test.ts index 434fe2ee8..e954d2dde 100644 --- a/backend/tests/security/attachment-idempotency.test.ts +++ b/backend/tests/security/attachment-idempotency.test.ts @@ -41,11 +41,7 @@ describe('Idempotent attachment creates', async () => { stx: { mutationId, sourceId: 'idempotency-test', fieldTimestamps: {} }, }); - const create = async ( - as: { sessionCookie: string }, - body: ReturnType, - home: { id: string; tenantId: string } = organization, - ) => { + const create = async (as: { sessionCookie: string }, body: ReturnType, home: { id: string; tenantId: string } = organization) => { const { data, response } = await call(createAttachments, { path: { tenantId: home.tenantId, organizationId: home.id }, body: [body] as never, @@ -70,8 +66,7 @@ describe('Idempotent attachment creates', async () => { stx: { mutationId, sourceId: 'idempotency-test', fieldTimestamps: {} }, }); - const storedRow = async (id: string) => - (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; + const storedRow = async (id: string) => (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; beforeAll(async () => { organization = await createTestOrganization(); diff --git a/backend/tests/security/block-media-refs.test.ts b/backend/tests/security/block-media-refs.test.ts index 6b77126aa..40ef7d272 100644 --- a/backend/tests/security/block-media-refs.test.ts +++ b/backend/tests/security/block-media-refs.test.ts @@ -19,18 +19,11 @@ import { clearSecurityTestData, createTestTenant, type TestTenant } from './help setTestConfig({ enabledAuthStrategies: ['passkey'] }); -const imageBlock = (url: string) => ({ - id: generateId(), - type: 'image', - props: { url, name: 'image.png', caption: '' }, - content: [], - children: [], -}); +const imageBlock = (url: string) => ({ id: generateId(), type: 'image', props: { url, name: 'image.png', caption: '' }, content: [], children: [] }); const documentOf = (...urls: string[]) => JSON.stringify(urls.map(imageBlock)); -const urlsIn = (description: string | null) => - (JSON.parse(description ?? '[]') as { props: { url: string } }[]).map((block) => block.props.url); +const urlsIn = (description: string | null) => (JSON.parse(description ?? '[]') as { props: { url: string } }[]).map((block) => block.props.url); /** * A media block renders its `url` in every viewer's browser. It may name only an attachment id or a storage key under @@ -81,8 +74,7 @@ describe('Block media references', async () => { return row?.description ?? null; }; - const resetDescription = () => - adminDb.update(attachmentsTable).set({ description: original }).where(eq(attachmentsTable.id, attachmentId)); + const resetDescription = () => adminDb.update(attachmentsTable).set({ description: original }).where(eq(attachmentsTable.id, attachmentId)); const storedWelcomeText = async () => { const [row] = await adminDb diff --git a/backend/tests/security/brute-force-limits.test.ts b/backend/tests/security/brute-force-limits.test.ts index 8bdf1c188..ae8636a7c 100644 --- a/backend/tests/security/brute-force-limits.test.ts +++ b/backend/tests/security/brute-force-limits.test.ts @@ -12,6 +12,7 @@ import { authCookie, cookieChange, createMfaToken, + createSystemAdminUser, createTestUser, createTotpUser, type ErrorResponse, @@ -22,7 +23,7 @@ import { } from '../helpers'; import { createAppClient } from '../test-client'; import { clearSecurityTestData } from './helpers'; -import { insertSession } from './session-helpers'; +import { insertImpersonation, insertSession } from './session-helpers'; vi.unmock('#/middlewares/rate-limiter/core'); /** A fresh client IP per test: limiter rows outlive a run, and the IP-keyed budgets must start empty. */ @@ -45,8 +46,7 @@ describe('brute-force budgets', async () => { const owner = await createTestUser(`magic-limit-${nanoid(8)}@security-test.com`.toLowerCase()); const other = await createTestUser(`magic-other-${nanoid(8)}@security-test.com`.toLowerCase()); /** A request for a link to `email`, each from a client address of its own: the budget is the mailbox's. */ - const request = async (email: string) => - (await call(sendMagicLink, { body: { email }, headers: fromIp(randomIp()) })).response; + const request = async (email: string) => (await call(sendMagicLink, { body: { email }, headers: fromIp(randomIp()) })).response; for (let attempt = 0; attempt < 2; attempt++) expect((await request(owner.email)).status).toBe(204); @@ -74,13 +74,26 @@ describe('brute-force budgets', async () => { expect((await sessionRow(session.id)).steppedUpAt).toBeNull(); }); + it("must not spend the user's step-up budget via an impersonation's refused attempts", async () => { + const admin = await createSystemAdminUser(`step-up-limit-admin-${nanoid(8)}@security-test.com`); + const user = await createTotpUser(`step-up-limit-impersonated-${nanoid(8)}@security-test.com`); + const impersonation = await insertImpersonation(await insertSession(admin), user); + + // More refusals than the five failures the account allows: none of them is a guess at the user's factor. + for (let attempt = 0; attempt < 6; attempt++) { + const { response } = await call(stepUp, { body: { totpCode: wrongTotpCode() }, headers: impersonation.headers }); + expect(response.status).toBe(403); + } + + const own = await insertSession(user); + expect((await call(stepUp, { body: { totpCode: totpCode() }, headers: own.headers })).response.status).toBe(204); + }); + it('lets the owner through with the right code before the budget runs out (positive control)', async () => { const user = await createTotpUser(`step-up-limit-ok-${nanoid(8)}@security-test.com`); const session = await insertSession(user); - expect( - (await call(stepUp, { body: { totpCode: wrongTotpCode() }, headers: session.headers })).response.status, - ).toBe(401); + expect((await call(stepUp, { body: { totpCode: wrongTotpCode() }, headers: session.headers })).response.status).toBe(401); const { response } = await call(stepUp, { body: { totpCode: totpCode() }, headers: session.headers }); expect(response.status).toBe(204); }); @@ -116,8 +129,7 @@ describe('brute-force budgets', async () => { it('must not resume looking up addresses via check-email when the window ends inside the block', async () => { const ip = randomIp(); const known = await createTestUser(`blocked-${nanoid(6)}@security-test.com`.toLowerCase()); - const lookup = async () => - (await call(checkEmail, { body: { email: known.email }, headers: fromIp(ip) })).response.status; + const lookup = async () => (await call(checkEmail, { body: { email: known.email }, headers: fromIp(ip) })).response.status; const minutes = (count: number) => count * 60 * 1000; // Only the clock moves: 30 lookups an hour, then a 30-minute block from the lookup past the budget. @@ -181,18 +193,12 @@ describe('brute-force budgets', async () => { // One wrong code from each of five addresses: every IP budget stays far from its limit. for (let attempt = 0; attempt < 5; attempt++) { - const { response } = await call(signInWithTotp, { - body: { code: wrongTotpCode() }, - headers: { ...fromIp(randomIp()), Cookie: cookie }, - }); + const { response } = await call(signInWithTotp, { body: { code: wrongTotpCode() }, headers: { ...fromIp(randomIp()), Cookie: cookie } }); expect(response.status).toBe(401); } // The account's own budget is spent: refused even with the right code, from yet another address. - const { response } = await call(signInWithTotp, { - body: { code: totpCode() }, - headers: { ...fromIp(randomIp()), Cookie: cookie }, - }); + const { response } = await call(signInWithTotp, { body: { code: totpCode() }, headers: { ...fromIp(randomIp()), Cookie: cookie } }); expect(response.status).toBe(429); expect(cookieChange(response, 'session')).toBeUndefined(); // The owner hears of it once, when the budget ran out. @@ -204,16 +210,10 @@ describe('brute-force budgets', async () => { const cookie = authCookie('confirm-mfa', await createMfaToken(user)); for (let attempt = 0; attempt < 4; attempt++) { - const { response } = await call(signInWithTotp, { - body: { code: wrongTotpCode() }, - headers: { ...fromIp(randomIp()), Cookie: cookie }, - }); + const { response } = await call(signInWithTotp, { body: { code: wrongTotpCode() }, headers: { ...fromIp(randomIp()), Cookie: cookie } }); expect(response.status).toBe(401); } - const { response } = await call(signInWithTotp, { - body: { code: totpCode() }, - headers: { ...fromIp(randomIp()), Cookie: cookie }, - }); + const { response } = await call(signInWithTotp, { body: { code: totpCode() }, headers: { ...fromIp(randomIp()), Cookie: cookie } }); expect(response.status).toBe(204); expect(lockoutMailsTo(user.email)).toHaveLength(0); }); @@ -226,10 +226,7 @@ describe('brute-force budgets', async () => { // this size overlaps the attempts closely enough that a budget counted by read-then-write lets more through. const statuses = await Promise.all( Array.from({ length: 20 }, async () => { - const { response } = await call(signInWithTotp, { - body: { code: wrongTotpCode() }, - headers: { ...fromIp(randomIp()), Cookie: cookie }, - }); + const { response } = await call(signInWithTotp, { body: { code: wrongTotpCode() }, headers: { ...fromIp(randomIp()), Cookie: cookie } }); return response.status; }), ); @@ -238,10 +235,7 @@ describe('brute-force budgets', async () => { // One lockout, one mail. expect(lockoutMailsTo(user.email)).toHaveLength(1); - const { response } = await call(signInWithTotp, { - body: { code: totpCode() }, - headers: { ...fromIp(randomIp()), Cookie: cookie }, - }); + const { response } = await call(signInWithTotp, { body: { code: totpCode() }, headers: { ...fromIp(randomIp()), Cookie: cookie } }); expect(response.status).toBe(429); expect(cookieChange(response, 'session')).toBeUndefined(); }); @@ -270,8 +264,7 @@ describe('brute-force budgets', async () => { it('answers a browser navigation past its budget with a redirect to the error page, never JSON', async () => { const ip = randomIp(); /** A token link opened with a guessed token: a failure the link's budget counts. */ - const open = async () => - (await call(invokeToken, { path: { type: 'invitation', token: nanoid(40) }, headers: fromIp(ip) })).response; + const open = async () => (await call(invokeToken, { path: { type: 'invitation', token: nanoid(40) }, headers: fromIp(ip) })).response; for (let attempt = 0; attempt < 10; attempt++) expect((await open()).status).not.toBe(429); // Tests read the refusal as JSON, like every other error. diff --git a/backend/tests/security/cookie-integrity.test.ts b/backend/tests/security/cookie-integrity.test.ts index 67462222a..5fbbbc836 100644 --- a/backend/tests/security/cookie-integrity.test.ts +++ b/backend/tests/security/cookie-integrity.test.ts @@ -64,12 +64,8 @@ describe('cookie integrity', async () => { const name = `${appConfig.slug}-session-${appConfig.cookieVersion}`; const expiresAt = Math.floor(Date.now() / 1000) + 3600; - const mac = createHmac('sha256', 'an-attacker-secret') - .update(`${name}\n${expiresAt}\n${content}`) - .digest('base64url'); - const { response } = await meWith( - `${authCookieName('session')}=${encodeURIComponent(`${content}.${expiresAt}.${mac}`)}`, - ); + const mac = createHmac('sha256', 'an-attacker-secret').update(`${name}\n${expiresAt}\n${content}`).digest('base64url'); + const { response } = await meWith(`${authCookieName('session')}=${encodeURIComponent(`${content}.${expiresAt}.${mac}`)}`); expect(response.status).toBe(401); }); }); diff --git a/backend/tests/security/cross-org.test.ts b/backend/tests/security/cross-org.test.ts index 2ad98c2ee..10f03c9f7 100644 --- a/backend/tests/security/cross-org.test.ts +++ b/backend/tests/security/cross-org.test.ts @@ -34,21 +34,11 @@ import { attachmentBody, seedAttachmentHome } from '../hierarchy-helpers'; import { createInvitation } from '../invitations/helpers'; import { createAppClient, type TestResult } from '../test-client'; import { setTestConfig } from '../test-utils'; -import { - assumeMemberAttachmentPolicy, - clearSecurityTestData, - createOrgUser, - createSecondOrg, - createTestTenant, - type TestTenant, -} from './helpers'; +import { assumeMemberAttachmentPolicy, clearSecurityTestData, createOrgUser, createSecondOrg, createTestTenant, type TestTenant } from './helpers'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); -const renameStx = () => ({ - ...mockStxBase(`stx:${generateId()}`), - fieldTimestamps: { name: generateServerHLC('test-client') }, -}); +const renameStx = () => ({ ...mockStxBase(`stx:${generateId()}`), fieldTimestamps: { name: generateServerHLC('test-client') } }); type Session = { sessionCookie: string }; const notFound = { status: 404, type: 'not_found' }; @@ -85,12 +75,9 @@ describe('Cross-organization API isolation', async () => { const invitedByAttacker = 'cross-org-newcomer@security-test.com'; const headers = (as: Session) => ({ ...defaultHeaders, Cookie: as.sessionCookie }); - const attachmentRow = async (id: string) => - (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; - const organizationRow = async (id: string) => - (await db.select().from(organizationsTable).where(eq(organizationsTable.id, id)))[0]; - const membershipRow = async (id: string) => - (await db.select().from(membershipsTable).where(eq(membershipsTable.id, id)))[0]; + const attachmentRow = async (id: string) => (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; + const organizationRow = async (id: string) => (await db.select().from(organizationsTable).where(eq(organizationsTable.id, id)))[0]; + const membershipRow = async (id: string) => (await db.select().from(membershipsTable).where(eq(membershipsTable.id, id)))[0]; beforeAll(async () => { tenant = await createTestTenant(call, 'org-isolation'); @@ -126,9 +113,8 @@ describe('Cross-organization API isolation', async () => { .select({ id: membershipsTable.id }) .from(membershipsTable) .where(and(eq(membershipsTable.userId, userB.id), eq(membershipsTable.channelId, orgB.id))); - invitationB = ( - await createInvitation({ organization: orgB, email: 'cross-org-invitee@security-test.com', createdBy: userB.id }) - ).inactiveMembership; + invitationB = (await createInvitation({ organization: orgB, email: 'cross-org-invitee@security-test.com', createdBy: userB.id })) + .inactiveMembership; }); afterAll(async () => { @@ -139,8 +125,7 @@ describe('Cross-organization API isolation', async () => { const rows: Row[] = [ { route: 'getAttachments', - attempt: (as) => - call(getAttachments, { path: { tenantId: tenant.tenantId, organizationId: orgB.id }, headers: headers(as) }), + attempt: (as) => call(getAttachments, { path: { tenantId: tenant.tenantId, organizationId: orgB.id }, headers: headers(as) }), }, { route: 'createAttachments', @@ -153,17 +138,12 @@ describe('Cross-organization API isolation', async () => { }, { route: 'getOrganization', - attempt: (as) => - call(getOrganization, { path: { tenantId: tenant.tenantId, id: orgB.id }, headers: headers(as) }), + attempt: (as) => call(getOrganization, { path: { tenantId: tenant.tenantId, id: orgB.id }, headers: headers(as) }), }, { route: 'updateOrganization', attempt: (as) => - call(updateOrganization, { - path: { tenantId: tenant.tenantId, id: orgB.id }, - body: { name: 'Hijacked' }, - headers: headers(as), - }), + call(updateOrganization, { path: { tenantId: tenant.tenantId, id: orgB.id }, body: { name: 'Hijacked' }, headers: headers(as) }), unchanged: async () => expect((await organizationRow(orgB.id)).name).toBe(orgB.name), }, { @@ -255,9 +235,7 @@ describe('Cross-organization API isolation', async () => { headers: headers(as), }), unchanged: async () => - expect( - await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.email, invitedByAttacker)), - ).toHaveLength(0), + expect(await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.email, invitedByAttacker))).toHaveLength(0), }, { route: 'resendPendingInvitation', @@ -270,15 +248,11 @@ describe('Cross-organization API isolation', async () => { }, ]; - it.each(rows)( - "must not reach org B via $route on tenant A's path", - async ({ attempt, refusal = notFound, unchanged }) => { - // The organization is resolved inside the URL's tenant: a role in org B does not carry it over either. - for (const attacker of [tenant, insider]) - await expectRefusal(await attempt(attacker), refusal.status, refusal.type); - await unchanged?.(); - }, - ); + it.each(rows)("must not reach org B via $route on tenant A's path", async ({ attempt, refusal = notFound, unchanged }) => { + // The organization is resolved inside the URL's tenant: a role in org B does not carry it over either. + for (const attacker of [tenant, insider]) await expectRefusal(await attempt(attacker), refusal.status, refusal.type); + await unchanged?.(); + }); it("must not delete org B's attachment via a batch on tenant A's path that also names the caller's own", async () => { for (const attacker of [tenant, insider]) { @@ -313,9 +287,7 @@ describe('Cross-organization API isolation', async () => { expect(await listedFor(tenant)).not.toContain(userB.id); // Positive control: the member of both organizations shares one with user B. - expect( - (await call(getUser, { path: { relatableUserId: userB.id }, headers: headers(insider) })).response.status, - ).toBe(200); + expect((await call(getUser, { path: { relatableUserId: userB.id }, headers: headers(insider) })).response.status).toBe(200); expect(await listedFor(insider)).toContain(userB.id); }); @@ -341,10 +313,7 @@ describe('Cross-organization API isolation', async () => { ]) { expect((await call(getAttachments, { path, headers: headers(insider) })).response.status).toBe(200); } - const { response } = await call(getOrganization, { - path: { tenantId: orgB.tenantId, id: orgB.id }, - headers: headers(userB), - }); + const { response } = await call(getOrganization, { path: { tenantId: orgB.tenantId, id: orgB.id }, headers: headers(userB) }); expect(response.status).toBe(200); }); }); diff --git a/backend/tests/security/cross-tenant.test.ts b/backend/tests/security/cross-tenant.test.ts index 1bd5784f3..fabfe2923 100644 --- a/backend/tests/security/cross-tenant.test.ts +++ b/backend/tests/security/cross-tenant.test.ts @@ -1,11 +1,4 @@ -import { - createAttachments, - type GetPresignedUrlsResponse, - getAttachments, - getOrganization, - getPresignedUrls, - updateOrganization, -} from 'sdk'; +import { createAttachments, type GetPresignedUrlsResponse, getAttachments, getOrganization, getPresignedUrls, updateOrganization } from 'sdk'; import type { TestEntityHierarchyPlan } from 'shared/testing/entity-hierarchy'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { defaultHeaders } from '../fixtures'; diff --git a/backend/tests/security/csrf.test.ts b/backend/tests/security/csrf.test.ts index 5dd69512b..f622a6ed6 100644 --- a/backend/tests/security/csrf.test.ts +++ b/backend/tests/security/csrf.test.ts @@ -17,11 +17,7 @@ describe('cross-site form posts', async () => { const formPost = (path: string, cookie: string, origin?: string) => app.request(path, { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Cookie: cookie, - ...(origin === undefined ? {} : { Origin: origin }), - }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie, ...(origin === undefined ? {} : { Origin: origin }) }, body: '', }); diff --git a/backend/tests/security/defense-in-depth.test.ts b/backend/tests/security/defense-in-depth.test.ts index 6296d654c..c44978c45 100644 --- a/backend/tests/security/defense-in-depth.test.ts +++ b/backend/tests/security/defense-in-depth.test.ts @@ -27,9 +27,7 @@ describe('Defense-in-depth data isolation', async () => { [tenantA, tenantB], [tenantB, tenantA], ]) { - const { data, response } = await call(getOrganizations, { - headers: { ...defaultHeaders, Cookie: own.sessionCookie }, - }); + const { data, response } = await call(getOrganizations, { headers: { ...defaultHeaders, Cookie: own.sessionCookie } }); expect(response.status).toBe(200); const orgIds = (data as { items: { id: string }[] }).items.map((o) => o.id); expect(orgIds).toContain(own.organization.id); diff --git a/backend/tests/security/failed-query-redaction.test.ts b/backend/tests/security/failed-query-redaction.test.ts index 5ee50ff8f..89f375ace 100644 --- a/backend/tests/security/failed-query-redaction.test.ts +++ b/backend/tests/security/failed-query-redaction.test.ts @@ -34,11 +34,7 @@ describe('failed queries in telemetry', () => { let status = 0; beforeAll(async () => { - const otel = createOtelSDK({ - serviceName: 'test-api', - traceExporter: collectingExporter(exported), - autoInstrumentations: false, - }); + const otel = createOtelSDK({ serviceName: 'test-api', traceExporter: collectingExporter(exported), autoInstrumentations: false }); otel.start(); const { baseApp } = await import('#/routes'); @@ -73,9 +69,7 @@ describe('failed queries in telemetry', () => { expect(JSON.stringify(spans)).not.toContain(secret); // Positive control: the request span recorded the exception with the database's reason. - const exception = exported - .flatMap((span) => span.events) - .find((event) => event.attributes?.['exception.type'] === 'DrizzleQueryError'); + const exception = exported.flatMap((span) => span.events).find((event) => event.attributes?.['exception.type'] === 'DrizzleQueryError'); expect(exception?.attributes?.['exception.message']).toBe(reason); expect(exception?.attributes?.['exception.stacktrace']).toMatch(/^DrizzleQueryError: invalid byte sequence/); }); diff --git a/backend/tests/security/helpers.ts b/backend/tests/security/helpers.ts index 5bb330b3f..6dc5f49dc 100644 --- a/backend/tests/security/helpers.ts +++ b/backend/tests/security/helpers.ts @@ -1,19 +1,14 @@ -import { eq, sql } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { generatePasskeyChallenge, signInWithPasskey } from 'sdk'; -import { - type EntityActionPermissions, - type EntityRole, - getEntityPolicies, - getPolicyPermissions, - policyMatrix, -} from 'shared'; +import { type EntityActionPermissions, type EntityRole, getEntityPolicies, getPolicyPermissions, policyMatrix } from 'shared'; import { afterEach, beforeEach, expect } from 'vitest'; -import { baseDb as db, getAdminDb } from '#/db/db'; +import { baseDb as db } from '#/db/db'; import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; import { adminRole, defaultHeaders, memberRole } from '../fixtures'; import { createOrganizationAdminUser, createTestOrganization, createTestSession, setCookiePair } from '../helpers'; import { type PasskeyAssertion, softwarePasskey } from '../software-passkey'; import { createAppClient, type TestResult } from '../test-client'; +import { emptyTables } from '../test-utils'; export interface TestTenant { tenantId: string; @@ -48,13 +43,7 @@ export async function createSecondOrg() { return createTestOrganization(); } -export async function createOrgUser( - _call: Call, - tenantId: string, - organizationId: string, - label: string, - role: EntityRole = memberRole, -) { +export async function createOrgUser(_call: Call, tenantId: string, organizationId: string, label: string, role: EntityRole = memberRole) { const email = `${label}-user@security-test.com`; const user = await createOrganizationAdminUser(email, organizationId, role, tenantId); @@ -64,13 +53,25 @@ export async function createOrgUser( return { id: user.id, email, sessionCookie }; } -/** Truncates tenant-scoped and auth tables on the admin connection (runtime_role holds no TRUNCATE). */ +/** Empties tenant-scoped and auth tables and everything that references them. */ export async function clearSecurityTestData() { - await getAdminDb('test cleanup').execute(sql`TRUNCATE TABLE - sessions, tokens, passkeys, identities, emails, - memberships, inactive_memberships, organizations, tenants, users, api_keys, service_accounts, actors, - oidc_payloads, oauth_clients - CASCADE`); + await emptyTables([ + 'sessions', + 'tokens', + 'passkeys', + 'identities', + 'emails', + 'memberships', + 'inactive_memberships', + 'organizations', + 'tenants', + 'users', + 'api_keys', + 'service_accounts', + 'actors', + 'oidc_payloads', + 'oauth_clients', + ]); } /** @@ -110,11 +111,7 @@ export async function passkeyChallenge(type: 'authentication' | 'mfa' | 'registr } /** Answers a passkey challenge on the sign-in route from a browser holding `cookie`. */ -export async function passkeySignIn( - assertion: PasskeyAssertion, - cookie: string, - type: 'authentication' | 'mfa' = 'authentication', -) { +export async function passkeySignIn(assertion: PasskeyAssertion, cookie: string, type: 'authentication' | 'mfa' = 'authentication') { const call = await createAppClient(); const headers = cookie ? { ...defaultHeaders, Cookie: cookie } : defaultHeaders; return call(signInWithPasskey, { body: { type, assertion }, headers }); diff --git a/backend/tests/security/impersonation.test.ts b/backend/tests/security/impersonation.test.ts index 1a2ddb52c..02dff9049 100644 --- a/backend/tests/security/impersonation.test.ts +++ b/backend/tests/security/impersonation.test.ts @@ -1,22 +1,14 @@ import { eq } from 'drizzle-orm'; import { deleteUsers, getMe, revokeMySessions, signOut, startImpersonation } from 'sdk'; import { appConfig } from 'shared'; +import { generateId } from 'shared/utils/entity-id'; import { afterEach, describe, expect, it, onTestFinished } from 'vitest'; -import { baseDb, getAdminDb } from '#/db/db'; +import { getAdminDb } from '#/db/db'; import { env } from '#/env'; -import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; +import { activityBus } from '#/lib/activity-bus'; import { systemRolesTable } from '#/modules/system/system-roles-db'; import { defaultHeaders, overrideConfig } from '../fixtures'; -import { - authCookie, - cookieChange, - createSystemAdminUser, - createTestUser, - expectRefusal, - mailsTo, - sessionRow, - sessionsOf, -} from '../helpers'; +import { authCookie, cookieChange, createSystemAdminUser, createTestUser, expectRefusal, mailsTo, sessionRow, sessionsOf } from '../helpers'; import { createAppClient } from '../test-client'; import { clearSecurityTestData } from './helpers'; import { @@ -52,10 +44,7 @@ describe('impersonation lives on its admin', async () => { const admin = await createSystemAdminUser(`${label}-admin@security-test.com`); const adminSession = await insertSession(admin); const target = await createTestUser(`${label}-target@security-test.com`); - const started = await call(startImpersonation, { - body: { targetUserId: target.id }, - headers: adminSession.headers, - }); + const started = await call(startImpersonation, { body: { targetUserId: target.id }, headers: adminSession.headers }); expect(started.response.status).toBe(204); const impersonation = await impersonationSetBy(started.response, adminSession); expect(await meAs(impersonation)).toMatchObject({ status: 200, userId: target.id }); @@ -70,17 +59,11 @@ describe('impersonation lives on its admin', async () => { const alone = await meAs({ ...impersonation, headers: { ...defaultHeaders, Cookie: impersonationCookie } }); expect(alone.status).toBe(401); - const elsewhere = await meAs({ - ...impersonation, - headers: { ...defaultHeaders, Cookie: `${otherSession.cookie}; ${impersonationCookie}` }, - }); + const elsewhere = await meAs({ ...impersonation, headers: { ...defaultHeaders, Cookie: `${otherSession.cookie}; ${impersonationCookie}` } }); expect(elsewhere.status).toBe(401); // Its token signed as a session cookie, as a leaked cookie secret allows: an impersonation is never a session. const token = decodeURIComponent(impersonationCookie.slice(impersonationCookie.indexOf('=') + 1)).split('.')[0]; - const asSession = await meAs({ - ...impersonation, - headers: { ...defaultHeaders, Cookie: authCookie('session', token) }, - }); + const asSession = await meAs({ ...impersonation, headers: { ...defaultHeaders, Cookie: authCookie('session', token) } }); await expectRefusal(asSession, 401, 'unauthorized'); expect((await meAs(impersonation)).status).toBe(200); @@ -110,10 +93,7 @@ describe('impersonation lives on its admin', async () => { expect(revoked.response.status).toBe(200); await expectClosedWith(stream, 'session_replaced'); - expect(await sessionRow(impersonation.id)).toMatchObject({ - revocationReason: 'impersonation_stopped', - revokedBy: admin.id, - }); + expect(await sessionRow(impersonation.id)).toMatchObject({ revocationReason: 'impersonation_stopped', revokedBy: admin.id }); expect((await meAs(impersonation)).status).toBe(401); expectStillOpen(kept.target.id, keptStream); @@ -145,10 +125,7 @@ describe('impersonation lives on its admin', async () => { const { target, impersonation } = await impersonating('layering'); const other = await createTestUser('layering-other@security-test.com'); - const attempt = await call(startImpersonation, { - body: { targetUserId: other.id }, - headers: impersonation.headers, - }); + const attempt = await call(startImpersonation, { body: { targetUserId: other.id }, headers: impersonation.headers }); await expectRefusal(attempt, 403, 'impersonation_forbidden'); expect(cookieChange(attempt.response, 'impersonation')).toBeUndefined(); expect(await sessionsOf(other.id)).toHaveLength(0); @@ -172,9 +149,16 @@ describe('impersonation lives on its admin', async () => { const { admin, impersonation } = await impersonating('demoted'); const kept = await impersonating('kept'); - // Roles change outside the API; the change listener drops the admin's cached sessions. - await getAdminDb('test arrange').delete(systemRolesTable).where(eq(systemRolesTable.userId, admin.id)); - await invalidateCache.user(baseDb, admin.id); + // Roles change outside the API; CDC reports the delete, which drops the admin's cached sessions. + const [role] = await getAdminDb('test arrange').delete(systemRolesTable).where(eq(systemRolesTable.userId, admin.id)).returning(); + activityBus.emit({ + id: generateId(), + type: 'system_role.deleted', + action: 'delete', + resourceType: 'system_role', + entityType: null, + rowData: role, + } as never); const refused = await meAs(impersonation); await expectRefusal(refused, 401, 'unauthorized'); diff --git a/backend/tests/security/internal-listener.test.ts b/backend/tests/security/internal-listener.test.ts index 63f638dd0..a8cb0889a 100644 --- a/backend/tests/security/internal-listener.test.ts +++ b/backend/tests/security/internal-listener.test.ts @@ -48,21 +48,10 @@ function upgradeStatus(port: number, target: string, headers: Headers): Promise< } /** A JSON POST with the request target sent exactly as given; resolves the status and the parsed body, if any. */ -function post( - port: number, - target: string, - body: unknown, - headers: Headers, -): Promise<{ status: number; body?: unknown }> { +function post(port: number, target: string, body: unknown, headers: Headers): Promise<{ status: number; body?: unknown }> { return new Promise((resolve, reject) => { const req = request( - { - host: '127.0.0.1', - port, - method: 'POST', - path: target, - headers: { 'content-type': 'application/json', ...headers }, - }, + { host: '127.0.0.1', port, method: 'POST', path: target, headers: { 'content-type': 'application/json', ...headers } }, (res) => { let text = ''; res.on('data', (chunk) => { @@ -202,11 +191,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Internal listener', a }); it('must not accept the CDC socket on the internal listener without its own secret', async () => { - const attempts: Headers[] = [ - {}, - { 'x-cdc-secret': `${modeSecret('CDC_SECRET')}x` }, - { 'x-cdc-secret': modeSecret('YJS_RELAY_SECRET') }, - ]; + const attempts: Headers[] = [{}, { 'x-cdc-secret': `${modeSecret('CDC_SECRET')}x` }, { 'x-cdc-secret': modeSecret('YJS_RELAY_SECRET') }]; for (const headers of attempts) { expect(await upgradeStatus(internalPort, '/internal/cdc', headers), JSON.stringify(headers)).toBe(401); } diff --git a/backend/tests/security/last-admin.test.ts b/backend/tests/security/last-admin.test.ts index 13aee30b6..488ea1ebc 100644 --- a/backend/tests/security/last-admin.test.ts +++ b/backend/tests/security/last-admin.test.ts @@ -72,12 +72,7 @@ describe('last organization admin', async () => { it('must not leave an organization without an admin via its only admin leaving', async () => { const { org, admin, headers, membershipOf } = await orgWithOneAdmin(); - await expectLastAdmin( - await call(deleteMyMembership, { - query: { entityId: org.id, entityType: 'organization' }, - headers: headers(admin), - }), - ); + await expectLastAdmin(await call(deleteMyMembership, { query: { entityId: org.id, entityType: 'organization' }, headers: headers(admin) })); expect(await membershipOf(admin.id)).toBeDefined(); }); diff --git a/backend/tests/security/magic-link.test.ts b/backend/tests/security/magic-link.test.ts index 50abaafd7..417bfc792 100644 --- a/backend/tests/security/magic-link.test.ts +++ b/backend/tests/security/magic-link.test.ts @@ -40,10 +40,7 @@ setTestConfig({ enabledAuthStrategies: ['magic', 'passkey'] }); const failNextReadOf = (table: PgTable) => { const prototype = PgAsyncDatabase.prototype; // The `select` overloads (with and without fields) share one runtime shape: the mock forwards whatever it gets. - const original = prototype.select as ( - this: typeof prototype, - fields?: unknown, - ) => { from: (source: unknown) => unknown }; + const original = prototype.select as (this: typeof prototype, fields?: unknown) => { from: (source: unknown) => unknown }; const spy = vi.spyOn(prototype, 'select').mockImplementation(function (this: typeof prototype, fields?: unknown) { const builder = original.call(this, fields); const from = builder.from.bind(builder); @@ -88,10 +85,7 @@ describe('magic link replay', async () => { const { raw, row } = await magicLink(user); // A click in a mail client is a navigation from another site: only the Lax request marker comes along. const click = () => - call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: { ...defaultHeaders, Cookie: authCookie('magic-requested', row.id) }, - }); + call(invokeToken, { path: { type: 'magic', token: raw }, headers: { ...defaultHeaders, Cookie: authCookie('magic-requested', row.id) } }); const first = await click(); expect(first.response.status).toBe(302); @@ -124,10 +118,7 @@ describe('magic link replay', async () => { .set({ expiresAt: new Date(Date.now() - 1000).toISOString() }) .where(eq(tokensTable.id, row.id)); - const { error, response } = await call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: defaultHeaders, - }); + const { error, response } = await call(invokeToken, { path: { type: 'magic', token: raw }, headers: defaultHeaders }); await expectRefusal({ response, error }, 401, 'magic_expired'); expect(cookieChange(response, 'session')).toBeUndefined(); }); @@ -289,7 +280,8 @@ describe('magic-link sign-up', async () => { const requestLink = async (email: string) => { const { response } = await call(sendMagicLink, { body: { email }, headers: defaultHeaders }); expect(response.status).toBe(204); - const rawToken = mailedLink('magicLinkUrl').token; + const { url, token: rawToken } = mailedLink('magicLinkUrl'); + expect(url).toBe(`${appConfig.backendAuthUrl}/invoke-token/magic/${rawToken}`); return { rawToken, requestedHere: setCookiePair(response, 'magic-requested') }; }; @@ -311,9 +303,7 @@ describe('magic-link sign-up', async () => { expect(await rowsFor(email)).toEqual({ users: [], emails: [] }); expect(await actorCount()).toBe(actorsBefore); - expect(await tokensFor(email)).toEqual([ - expect.objectContaining({ type: 'magic', userId: null, createdBy: null, invokedAt: null }), - ]); + expect(await tokensFor(email)).toEqual([expect.objectContaining({ type: 'magic', userId: null, createdBy: null, invokedAt: null })]); }); it('creates the account with its address verified when the link is clicked, and signs in (positive control)', async () => { @@ -326,9 +316,7 @@ describe('magic-link sign-up', async () => { const { users, emails } = await rowsFor(email); expect(users).toHaveLength(1); - expect(emails).toEqual([ - expect.objectContaining({ userId: users[0].id, verified: true, lastVerifiedVia: 'magic' }), - ]); + expect(emails).toEqual([expect.objectContaining({ userId: users[0].id, verified: true, lastVerifiedVia: 'magic' })]); expect(await tokensFor(email)).toEqual([expect.objectContaining({ userId: users[0].id })]); }); @@ -348,10 +336,7 @@ describe('magic-link sign-up', async () => { const { users } = await rowsFor(email); expect(users).toHaveLength(1); - const [claimed] = await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + const [claimed] = await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); expect(claimed.userId).toBe(users[0].id); }); @@ -436,10 +421,7 @@ describe('magic link in a browser with a stale session cookie', async () => { const staleCookies = async (owner: { id: string }) => { const revoked = await insertTestSession(owner); - await db - .update(sessionsTable) - .set({ revokedAt: new Date().toISOString(), revocationReason: 'sign_out' }) - .where(eq(sessionsTable.id, revoked.id)); + await db.update(sessionsTable).set({ revokedAt: new Date().toISOString(), revocationReason: 'sign_out' }).where(eq(sessionsTable.id, revoked.id)); const expired = await insertTestSession(owner, { expiresInMs: -1000 }); return { revoked: revoked.cookie, expired: expired.cookie, unknown: authCookie('session', nanoid(40)) }; }; @@ -451,10 +433,7 @@ describe('magic link in a browser with a stale session cookie', async () => { const { raw, row } = await magicLink(owner); const cookies = [stale, authCookie('magic-requested', row.id)].join('; '); - const { response } = await call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: { ...defaultHeaders, Cookie: cookies }, - }); + const { response } = await call(invokeToken, { path: { type: 'magic', token: raw }, headers: { ...defaultHeaders, Cookie: cookies } }); expect(response.status, kind).toBe(302); expect(response.headers.get('location'), kind).not.toContain('/auth/error'); expect(cookieChange(response, 'session'), kind).toBe('set'); @@ -467,10 +446,7 @@ describe('magic link in a browser with a stale session cookie', async () => { const { raw, row } = await magicLink(owner); const cookies = [await createTestSession(other), authCookie('magic-requested', row.id)].join('; '); - const { error, response } = await call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: { ...defaultHeaders, Cookie: cookies }, - }); + const { error, response } = await call(invokeToken, { path: { type: 'magic', token: raw }, headers: { ...defaultHeaders, Cookie: cookies } }); await expectRefusal({ response, error }, 409, 'user_mismatch'); expect(cookieChange(response, 'session')).toBeUndefined(); }); @@ -483,10 +459,7 @@ describe('magic link in a browser with a stale session cookie', async () => { // The pool has no connection for the session read: who is signed in here is unknown, so nobody signs in. failNextReadOf(sessionsTable); - const { response } = await call(invokeToken, { - path: { type: 'magic', token: raw }, - headers: { ...defaultHeaders, Cookie: cookies }, - }); + const { response } = await call(invokeToken, { path: { type: 'magic', token: raw }, headers: { ...defaultHeaders, Cookie: cookies } }); expect(response.status).toBe(503); expect(cookieChange(response, 'session')).toBeUndefined(); expect((await tokenRow(row.id)).invokedAt).toBeNull(); diff --git a/backend/tests/security/malformed-ids.test.ts b/backend/tests/security/malformed-ids.test.ts index b0d61410e..3a4aeda35 100644 --- a/backend/tests/security/malformed-ids.test.ts +++ b/backend/tests/security/malformed-ids.test.ts @@ -42,9 +42,7 @@ describe('Malformed ids in a path', async () => { }); it('answers a well-formed id that names nothing with 404 (positive control)', async () => { - const { status } = await get( - `/${organization.tenantId}/${organization.id}/attachments/00000000-0000-4000-8000-000000000000`, - ); + const { status } = await get(`/${organization.tenantId}/${organization.id}/attachments/00000000-0000-4000-8000-000000000000`); expect(status).toBe(404); }); }); diff --git a/backend/tests/security/mark-seen.test.ts b/backend/tests/security/mark-seen.test.ts index 950474af0..4eaa3fd8d 100644 --- a/backend/tests/security/mark-seen.test.ts +++ b/backend/tests/security/mark-seen.test.ts @@ -65,8 +65,7 @@ describe.skipIf(!isTrackedProductType('attachment'))('markSeen and rows the call .from(seenByTable) .where(and(eq(seenByTable.userId, userId), eq(seenByTable.productId, productId))); - const viewCounters = (productId: string) => - adminDb.select().from(productCountersTable).where(eq(productCountersTable.productId, productId)); + const viewCounters = (productId: string) => adminDb.select().from(productCountersTable).where(eq(productCountersTable.productId, productId)); beforeAll(async () => { organization = await createTestOrganization(); diff --git a/backend/tests/security/mass-assignment.test.ts b/backend/tests/security/mass-assignment.test.ts index 4ae2b7da2..715670b74 100644 --- a/backend/tests/security/mass-assignment.test.ts +++ b/backend/tests/security/mass-assignment.test.ts @@ -52,10 +52,7 @@ describe('Columns outside the body pick', async () => { { route: 'updateMe', send: () => - call(updateMe, { - body: { firstName: 'Renamed', email: 'taken-over@security-test.com', mfaRequired: true } as never, - headers: headers(), - }), + call(updateMe, { body: { firstName: 'Renamed', email: 'taken-over@security-test.com', mfaRequired: true } as never, headers: headers() }), row: async () => (await db.select().from(usersTable).where(eq(usersTable.id, admin.id)))[0], changed: { firstName: 'Renamed' }, kept: () => ({ email: admin.email, mfaRequired: false }), @@ -68,8 +65,7 @@ describe('Columns outside the body pick', async () => { body: { name: 'Renamed', tenantId: otherTenantId, createdBy: admin.id } as never, headers: headers(), }), - row: async () => - (await db.select().from(organizationsTable).where(eq(organizationsTable.id, organization.id)))[0], + row: async () => (await db.select().from(organizationsTable).where(eq(organizationsTable.id, organization.id)))[0], changed: { name: 'Renamed' }, kept: () => ({ tenantId: organization.tenantId, createdBy: organization.createdBy }), }, diff --git a/backend/tests/security/membership-update.test.ts b/backend/tests/security/membership-update.test.ts index e69c5c81a..6e4cd9cf7 100644 --- a/backend/tests/security/membership-update.test.ts +++ b/backend/tests/security/membership-update.test.ts @@ -39,10 +39,7 @@ describe('Membership updates', async () => { headers: { ...defaultHeaders, Cookie: as.sessionCookie }, }); const updateRaw = (as: { sessionCookie: string }, membershipId: string, body: Record) => - rawJsonRequest(`/${org.tenantId}/${org.id}/memberships/${membershipId}`, as.sessionCookie, { - method: 'PUT', - body, - }); + rawJsonRequest(`/${org.tenantId}/${org.id}/memberships/${membershipId}`, as.sessionCookie, { method: 'PUT', body }); return { org, admin, member, membershipOf, update, updateRaw }; } diff --git a/backend/tests/security/mfa-challenge.test.ts b/backend/tests/security/mfa-challenge.test.ts index 866a5920a..649fb5158 100644 --- a/backend/tests/security/mfa-challenge.test.ts +++ b/backend/tests/security/mfa-challenge.test.ts @@ -1,17 +1,7 @@ import { signInWithTotp } from 'sdk'; import { afterEach, describe, expect, it } from 'vitest'; import { defaultHeaders } from '../fixtures'; -import { - authCookie, - cookieChange, - createMfaToken, - createTotpUser, - expectRefusal, - sessionsOf, - tokenRowOf, - totpCode, - wrongTotpCode, -} from '../helpers'; +import { authCookie, cookieChange, createMfaToken, createTotpUser, expectRefusal, sessionsOf, tokenRowOf, totpCode, wrongTotpCode } from '../helpers'; import { type PasskeyAssertion, softwarePasskey } from '../software-passkey'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; @@ -28,8 +18,7 @@ afterEach(async () => await clearSecurityTestData()); describe('Second-factor challenge', async () => { const call = await createAppClient(); - const totpSignIn = (code: string, cookie: string) => - call(signInWithTotp, { body: { code }, headers: { ...defaultHeaders, Cookie: cookie } }); + const totpSignIn = (code: string, cookie: string) => call(signInWithTotp, { body: { code }, headers: { ...defaultHeaders, Cookie: cookie } }); it("must not open a second session via a completed challenge's confirm-mfa cookie", async () => { const user = await createTotpUser('owner@security-test.com'); @@ -70,11 +59,7 @@ describe('Second-factor challenge', async () => { const mfaCookie = authCookie('confirm-mfa', await createMfaToken(user)); const issued = await passkeyChallenge('mfa', mfaCookie); - const first = await passkeySignIn( - passkey.assert(issued.challenge, { counter: 1 }), - `${mfaCookie}; ${issued.cookie}`, - 'mfa', - ); + const first = await passkeySignIn(passkey.assert(issued.challenge, { counter: 1 }), `${mfaCookie}; ${issued.cookie}`, 'mfa'); expect(first.response.status).toBe(204); expect(cookieChange(first.response, 'session')).toBe('set'); @@ -98,11 +83,7 @@ describe('Second-factor challenge', async () => { }; // Signed by another key under this passkey's id. - const failed = await answer((challenge) => ({ - ...softwarePasskey().assert(challenge), - id: passkey.credentialId, - rawId: passkey.credentialId, - })); + const failed = await answer((challenge) => ({ ...softwarePasskey().assert(challenge), id: passkey.credentialId, rawId: passkey.credentialId })); await expectRefusal(failed, 401, 'passkey_verification_failed'); expect(cookieChange(failed.response, 'session')).toBeUndefined(); expect(await tokenRowOf('confirm-mfa', mfaToken)).toBeDefined(); diff --git a/backend/tests/security/mfa-factor-race.test.ts b/backend/tests/security/mfa-factor-race.test.ts index f3a49f9a0..0f704d94d 100644 --- a/backend/tests/security/mfa-factor-race.test.ts +++ b/backend/tests/security/mfa-factor-race.test.ts @@ -26,10 +26,7 @@ vi.mock('#/modules/me/me-queries', async (importOriginal) => { /** Whether MFA is on, and how many factors of each kind the account holds. */ const stateOf = async (userId: string) => { - const [user] = await db - .select({ mfaRequired: usersTable.mfaRequired }) - .from(usersTable) - .where(eq(usersTable.id, userId)); + const [user] = await db.select({ mfaRequired: usersTable.mfaRequired }).from(usersTable).where(eq(usersTable.id, userId)); const totps = await db.select().from(totpsTable).where(eq(totpsTable.userId, userId)); const passkeys = await passkeysOf(userId); return { mfaRequired: user.mfaRequired, totps: totps.length, passkeys: passkeys.length }; @@ -38,7 +35,7 @@ const stateOf = async (userId: string) => { /** Whether some query waits for a lock another transaction holds. */ const aQueryWaitsForALock = async () => { const result = await getAdminDb('mfa factor race test').execute<{ waiting: number }>( - sql`select count(*)::int as waiting from pg_locks where not granted`, + sql`select count(*)::int as waiting from pg_locks join pg_stat_activity using (pid) where not granted and datname = current_database()`, ); return result.rows[0].waiting > 0; }; @@ -76,10 +73,7 @@ describe('MFA factor rules under concurrent requests', async () => { .finally(() => { settled = true; }); - await vi.waitFor(async () => expect(settled || (await aQueryWaitsForALock())).toBe(true), { - timeout: 5000, - interval: 10, - }); + await vi.waitFor(async () => expect(settled || (await aQueryWaitsForALock())).toBe(true), { timeout: 5000, interval: 10 }); }; const enabled = await call(toggleMfa, { body: { mfaRequired: true }, headers }); diff --git a/backend/tests/security/mfa-step-up.test.ts b/backend/tests/security/mfa-step-up.test.ts index df7941ebc..24e365efd 100644 --- a/backend/tests/security/mfa-step-up.test.ts +++ b/backend/tests/security/mfa-step-up.test.ts @@ -12,8 +12,7 @@ import { clearSecurityTestData, insertPasskey, passkeysOf } from './helpers'; import { insertStaleSession, insertSteppedUpSession } from './session-helpers'; const mfaRequiredOf = async (userId: string) => - (await db.select({ mfaRequired: usersTable.mfaRequired }).from(usersTable).where(eq(usersTable.id, userId)))[0] - ?.mfaRequired; + (await db.select({ mfaRequired: usersTable.mfaRequired }).from(usersTable).where(eq(usersTable.id, userId)))[0]?.mfaRequired; /** * Turning MFA on or off changes how the account is protected, so a session alone must never be enough: the session @@ -26,10 +25,7 @@ describe('MFA toggle step-up', async () => { afterEach(async () => await clearSecurityTestData()); /** A TOTP holder with a session that stepped up `via` a factor; `steppedUp: false` gives a stale session alone. */ - async function totpUserWithSession( - mfaRequired: boolean, - { withPasskey = true, steppedUp = true, via = 'totp' as StepUpProof } = {}, - ) { + async function totpUserWithSession(mfaRequired: boolean, { withPasskey = true, steppedUp = true, via = 'totp' as StepUpProof } = {}) { const user = await createTotpUser(`mfa-${nanoid(8)}@security-test.com`); if (!mfaRequired) await db.update(usersTable).set({ mfaRequired: false }).where(eq(usersTable.id, user.id)); const passkey = withPasskey ? await insertPasskey(user) : undefined; @@ -84,8 +80,6 @@ describe('MFA toggle step-up', async () => { // Stepped up with the passkey: a step-up counts while its factor is held, and the authenticator app goes first. const off = await totpUserWithSession(false, { via: 'passkey' }); expect((await call(deleteTotp, { headers: off.headers })).response.status).toBe(204); - expect((await call(deletePasskey, { path: { id: off.passkey!.id }, headers: off.headers })).response.status).toBe( - 204, - ); + expect((await call(deletePasskey, { path: { id: off.passkey!.id }, headers: off.headers })).response.status).toBe(204); }); }); diff --git a/backend/tests/security/notification-access.test.ts b/backend/tests/security/notification-access.test.ts index 6e87c9b63..bd3731222 100644 --- a/backend/tests/security/notification-access.test.ts +++ b/backend/tests/security/notification-access.test.ts @@ -5,8 +5,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { baseDb as db } from '#/db/db'; import { attachmentsTable } from '#/modules/attachment/attachment-db'; import { membershipsTable } from '#/modules/memberships/memberships-db'; -import { runDigest } from '#/modules/notification/digest/run-digest'; import { notificationPreferencesTable, notificationsTable } from '#/modules/notification/notification-db'; +import { runDigest } from '#/modules/notification/operations/run-digest'; import { sendPendingInstantEmails } from '#/modules/notification/operations/send-instant-emails'; import { organizationsTable } from '#/modules/organization/organization-db'; import { defaultHeaders, memberRole } from '../fixtures'; @@ -14,13 +14,7 @@ import { adminDb, mailsTo } from '../helpers'; import { attachmentBody, seedAttachmentHome } from '../hierarchy-helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; -import { - assumeMemberAttachmentPolicy, - clearSecurityTestData, - createOrgUser, - createTestTenant, - type TestTenant, -} from './helpers'; +import { assumeMemberAttachmentPolicy, clearSecurityTestData, createOrgUser, createTestTenant, type TestTenant } from './helpers'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); @@ -62,10 +56,7 @@ describe('Notification access', async () => { }; const inbox = async (as: { sessionCookie: string }) => { - const { data, response } = await call(getNotifications, { - query: { limit: 30 }, - headers: { ...defaultHeaders, Cookie: as.sessionCookie }, - }); + const { data, response } = await call(getNotifications, { query: { limit: 30 }, headers: { ...defaultHeaders, Cookie: as.sessionCookie } }); expect(response.status).toBe(200); return data as GetNotificationsResponse; }; @@ -90,10 +81,7 @@ describe('Notification access', async () => { }); expect(response.status).toBe(201); await adminDb.update(attachmentsTable).set({ name: 'Old item' }).where(eq(attachmentsTable.id, attachmentIds.old)); - await adminDb - .update(attachmentsTable) - .set({ name: 'Fresh item' }) - .where(eq(attachmentsTable.id, attachmentIds.fresh)); + await adminDb.update(attachmentsTable).set({ name: 'Fresh item' }).where(eq(attachmentsTable.id, attachmentIds.fresh)); const now = noon(); // The leaver was told about an item while a member; a daily digest and mention mails are on for both users. @@ -106,30 +94,16 @@ describe('Notification access', async () => { ]); // The leaver leaves; afterwards the item and the organization are renamed. - await db - .delete(membershipsTable) - .where(and(eq(membershipsTable.userId, leaver.id), eq(membershipsTable.organizationId, tenant.organization.id))); - await adminDb - .update(attachmentsTable) - .set({ name: 'Renamed secret plan' }) - .where(eq(attachmentsTable.id, attachmentIds.secret)); - await db - .update(organizationsTable) - .set({ name: 'Renamed organization' }) - .where(eq(organizationsTable.id, tenant.organization.id)); + await db.delete(membershipsTable).where(and(eq(membershipsTable.userId, leaver.id), eq(membershipsTable.organizationId, tenant.organization.id))); + await adminDb.update(attachmentsTable).set({ name: 'Renamed secret plan' }).where(eq(attachmentsTable.id, attachmentIds.secret)); + await db.update(organizationsTable).set({ name: 'Renamed organization' }).where(eq(organizationsTable.id, tenant.organization.id)); }); // Each test starts from unmailed, undigested rows and users who never had a digest. beforeEach(async () => { const users = [leaver.id, stayer.id]; - await db - .update(notificationsTable) - .set({ emailedAt: null, digestedAt: null, readAt: null }) - .where(inArray(notificationsTable.userId, users)); - await db - .update(notificationPreferencesTable) - .set({ lastDigestAt: null }) - .where(inArray(notificationPreferencesTable.userId, users)); + await db.update(notificationsTable).set({ emailedAt: null, digestedAt: null, readAt: null }).where(inArray(notificationsTable.userId, users)); + await db.update(notificationPreferencesTable).set({ lastDigestAt: null }).where(inArray(notificationPreferencesTable.userId, users)); }); afterAll(async () => { diff --git a/backend/tests/security/oauth-grants.test.ts b/backend/tests/security/oauth-grants.test.ts index f1cbde781..854e42cba 100644 --- a/backend/tests/security/oauth-grants.test.ts +++ b/backend/tests/security/oauth-grants.test.ts @@ -1,7 +1,6 @@ import { and, eq, inArray } from 'drizzle-orm'; import { importJWK, SignJWT } from 'jose'; import { nanoid } from 'nanoid'; -import pg from 'pg'; import { createApiKey, createServiceAccount, @@ -17,13 +16,13 @@ import { updateServiceAccount, } from 'sdk'; import { appConfig } from 'shared'; -import { testDatabaseUrl } from 'shared/test-db'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { baseDb as db, getAdminDb } from '#/db/db'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; -import { endSessions } from '#/modules/auth/general/helpers/end-sessions'; +import { revokeSessions } from '#/modules/auth/general/helpers/revoke-sessions'; import type { SessionEndReason } from '#/modules/auth/sessions-db'; import { stampStepUp } from '#/modules/auth/step-up/helpers/step-up'; +import { membershipsTable } from '#/modules/memberships/memberships-db'; import { DrizzleAdapter } from '#/modules/oauth-server/adapter'; import { loadSigningJwks } from '#/modules/oauth-server/keystore'; import { oauthClientsTable } from '#/modules/oauth-server/oauth-clients-db'; @@ -84,13 +83,6 @@ const cimdDocument = { response_types: ['code'], }; -/** - * The other processes (api, mcp, oauth): what they hear on `auth_invalidate`, through a listener on a connection of - * its own. Each keeps its own cached verdicts, so a revocation must reach them at once. - */ -const otherProcesses = { client: new pg.Client({ connectionString: testDatabaseUrl }), heard: [] as unknown[] }; -const toldOtherProcesses = (message: unknown) => - vi.waitFor(() => expect(otherProcesses.heard).toContainEqual(message), { timeout: 5000 }); const reasonOf = (error: unknown) => (error as ErrorResponse).meta?.reason; /** A user's grants with the codes and refresh tokens issued under them (the provider's sessions are left out). */ @@ -98,12 +90,7 @@ const grantRowsOf = (userId: string) => db .select({ type: oidcPayloadsTable.type }) .from(oidcPayloadsTable) - .where( - and( - eq(oidcPayloadsTable.accountId, userId), - inArray(oidcPayloadsTable.type, ['Grant', 'AuthorizationCode', 'RefreshToken']), - ), - ); + .where(and(eq(oidcPayloadsTable.accountId, userId), inArray(oidcPayloadsTable.type, ['Grant', 'AuthorizationCode', 'RefreshToken']))); /** * A grant is valid only while the grant policy says so: at consent, at every code exchange and refresh, and at the @@ -116,22 +103,11 @@ describe('OAuth grants', async () => { beforeAll(async () => { oauth = await startTestOauthServer(); - restoreFetch = serveClientMetadataDocuments({ - [CIMD_ID]: cimdDocument, - [SERVICE_CLAIM_ID]: { ...cimdDocument, client_kind: 'service' }, - }); - await otherProcesses.client.connect(); - // A dropped connection shows as the messages it no longer hears, never as an unhandled error event. - otherProcesses.client.on('error', () => {}); - otherProcesses.client.on('notification', ({ channel, payload }) => { - if (channel === 'auth_invalidate' && payload) otherProcesses.heard.push(JSON.parse(payload)); - }); - await otherProcesses.client.query('LISTEN auth_invalidate'); + restoreFetch = serveClientMetadataDocuments({ [CIMD_ID]: cimdDocument, [SERVICE_CLAIM_ID]: { ...cimdDocument, client_kind: 'service' } }); }); afterAll(async () => { restoreFetch(); await oauth.close(); - await otherProcesses.client.end(); }); afterEach(async () => await clearSecurityTestData()); @@ -163,32 +139,25 @@ describe('OAuth grants', async () => { return { access: String(result.body.access_token), refresh: String(result.body.refresh_token) }; } - const refresh = (refreshToken: string, clientId = APP_ID) => - refreshAccessToken(oauth.issuer, { clientId, refreshToken }); + const refresh = (refreshToken: string, clientId = APP_ID) => refreshAccessToken(oauth.issuer, { clientId, refreshToken }); const readAttachments = (ctx: Tenant, jwt: string) => - call(getAttachments, { - path: { tenantId: ctx.org.tenantId, organizationId: ctx.org.id }, - headers: bearerHeaders(jwt), - }); + call(getAttachments, { path: { tenantId: ctx.org.tenantId, organizationId: ctx.org.id }, headers: bearerHeaders(jwt) }); - /** The grant a person's access token names, as the other processes hear of its revocation. */ - async function grantOf(ctx: Tenant, jwt: string) { + /** The grant a person's access token names. */ + async function grantIdOf(ctx: Tenant, jwt: string) { const token = await verifyAccessToken(jwt, { tenantId: ctx.org.tenantId, organizationId: ctx.org.id }); if (token.kind !== 'user') throw new Error("Expected a person's token"); - return { grant: { accountId: token.actorId, grantId: token.grantId } }; + return token.grantId; } async function allowUnregisteredClients(tenantId: string, allow: boolean) { - const [tenant] = await db - .select({ restrictions: tenantsTable.restrictions }) - .from(tenantsTable) - .where(eq(tenantsTable.id, tenantId)); + const [tenant] = await db.select({ restrictions: tenantsTable.restrictions }).from(tenantsTable).where(eq(tenantsTable.id, tenantId)); await db .update(tenantsTable) .set({ restrictions: { ...normalizeRestrictions(tenant.restrictions), allowUnregisteredClients: allow } }) .where(eq(tenantsTable.id, tenantId)); - await invalidateCache.tenant(db, tenantId); + invalidateCache.tenant(tenantId); } describe('a grant ends with what it rests on', () => { @@ -213,17 +182,40 @@ describe('OAuth grants', async () => { const read = await readAttachments(ctx, access); expect(read.response.status).toBe(401); expect(reasonOf(read.error)).toBe('app_not_installed'); - await toldOtherProcesses({ - serviceAccount: { id: ctx.installationId, tenantId: ctx.org.tenantId, clientId: APP_ID }, - }); - const revoked = await grantOf(ctx, access); const refused = await refresh(String(rotated.body.refresh_token)); expect(refused.status).toBe(400); expect(refused.body.error).toBe('invalid_grant'); // The refused grant is deleted with every token issued under it: the client must ask the person again. expect(await grantRowsOf(ctx.member.id)).toEqual([]); - await toldOtherProcesses(revoked); + }); + + it('must not act via an access token once another process deleted its grant, even while its verdict is cached', async () => { + const ctx = await tenantWithApp(); + const grant = await consent(ctx); + // Positive control, which also caches the grant's verdict at the guard. + expect((await readAttachments(ctx, grant.access)).response.status).toBe(200); + + // A revoke in another process: the grant row goes, and nothing drops this process's verdict. + const grantId = await grantIdOf(ctx, grant.access); + await db.delete(oidcPayloadsTable).where(and(eq(oidcPayloadsTable.type, 'Grant'), eq(oidcPayloadsTable.id, grantId))); + + const read = await readAttachments(ctx, grant.access); + expect(read.response.status).toBe(401); + expect(reasonOf(read.error)).toBe('grant_revoked'); + }); + + it("must not act via a person's token once another process removed their membership, even while its verdict is cached", async () => { + const ctx = await tenantWithApp(); + const grant = await consent(ctx); + expect((await readAttachments(ctx, grant.access)).response.status).toBe(200); + + // A removal in another process: only the trigger on memberships records it. + await db.delete(membershipsTable).where(eq(membershipsTable.userId, ctx.member.id)); + + const read = await readAttachments(ctx, grant.access); + expect(read.response.status).toBe(401); + expect(reasonOf(read.error)).toBe('not_a_member'); }); it('must not keep a deleted account acting via its grant', async () => { @@ -364,7 +356,6 @@ describe('OAuth grants', async () => { const read = await readAttachments(ctx, grant.access); expect(read.response.status).toBe(401); expect(reasonOf(read.error)).toBe('grant_revoked'); - await toldOtherProcesses(await grantOf(ctx, grant.access)); }); it('must not act via an access token after the client revokes its refresh token', async () => { @@ -384,7 +375,6 @@ describe('OAuth grants', async () => { const read = await readAttachments(ctx, grant.access); expect(read.response.status).toBe(401); expect(reasonOf(read.error)).toBe('grant_revoked'); - await toldOtherProcesses(await grantOf(ctx, grant.access)); }); }); @@ -398,11 +388,7 @@ describe('OAuth grants', async () => { const { clientId: accountId, clientSecret, keyId } = await serviceAccountWithKey(org, admin.sessionCookie); const resource = resourceUri({ face: 'api', tenantId: org.tenantId }); const mint = (clientSecret: string) => - clientCredentialsToken( - oauth.issuer, - { clientId: accountId, clientSecret }, - { scope: 'attachment:read', resource }, - ); + clientCredentialsToken(oauth.issuer, { clientId: accountId, clientSecret }, { scope: 'attachment:read', resource }); const tokenFor = async (clientSecret: string) => { const minted = await mint(clientSecret); expect(minted.status).toBe(200); @@ -416,7 +402,7 @@ describe('OAuth grants', async () => { /** The token endpoint's answer to a key that no longer authenticates its account: no token. */ const noClient = { status: 401, body: { error: 'invalid_client' } }; - it('must not act or mint via a service token after its API key is revoked, even while its verdict is cached', async () => { + it('must not act or mint via a service token after its API key is revoked', async () => { const bot = await botWithKey(); const second = await call(createApiKey, { path: bot.path, body: { name: 'second' }, headers: bot.headers }); const secondKey = second.data as { secret: string }; @@ -430,40 +416,44 @@ describe('OAuth grants', async () => { const refused = await bot.read(jwt); expect(refused.response.status).toBe(401); expect(reasonOf(refused.error)).toBe('invalid_api_key'); - await toldOtherProcesses({ serviceAccount: { id: bot.accountId, tenantId: bot.path.tenantId, clientId: null } }); expect(await bot.mint(bot.key.secret)).toMatchObject(noClient); // Positive control: the account and its other key are untouched. expect((await bot.read(await bot.tokenFor(secondKey.secret))).response.status).toBe(200); }); - it('must not act via a service token after its account is disabled, even while its verdict is cached', async () => { + it('must not act via a service token after its account is disabled', async () => { const bot = await botWithKey(); const jwt = await bot.tokenFor(bot.key.secret); expect((await bot.read(jwt)).response.status).toBe(200); - const disabled = await call(updateServiceAccount, { - path: bot.path, - body: { status: 'disabled' }, - headers: bot.headers, - }); + const disabled = await call(updateServiceAccount, { path: bot.path, body: { status: 'disabled' }, headers: bot.headers }); expect(disabled.response.status).toBe(200); const refused = await bot.read(jwt); expect(refused.response.status).toBe(401); expect(reasonOf(refused.error)).toBe('service_account_disabled'); - await toldOtherProcesses({ serviceAccount: { id: bot.accountId, tenantId: bot.path.tenantId, clientId: null } }); }); - it('must not mint a service token via a client cached before its account was disabled', async () => { + it('must not act via a service token once another process revoked its API key', async () => { const bot = await botWithKey(); - // Positive control, which also caches the client, as the authorization server's process holds it. + const jwt = await bot.tokenFor(bot.key.secret); + expect((await bot.read(jwt)).response.status).toBe(200); + + // A revoke in another process: the key row changes, and nothing drops what this process holds. + await db.update(apiKeysTable).set({ revokedAt: new Date().toISOString() }).where(eq(apiKeysTable.id, bot.key.id)); + + const refused = await bot.read(jwt); + expect(refused.response.status).toBe(401); + expect(reasonOf(refused.error)).toBe('invalid_api_key'); + }); + + it('must not mint a service token once another process disabled its account', async () => { + const bot = await botWithKey(); + // Positive control. await bot.tokenFor(bot.key.secret); // A disable this process has not heard of yet: another process's write, or one outside the API. - await db - .update(serviceAccountsTable) - .set({ status: 'disabled' }) - .where(eq(serviceAccountsTable.id, bot.accountId)); + await db.update(serviceAccountsTable).set({ status: 'disabled' }).where(eq(serviceAccountsTable.id, bot.accountId)); expect(await bot.mint(bot.key.secret)).toMatchObject(noClient); }); @@ -563,12 +553,7 @@ describe('OAuth grants', async () => { /** The member consents in `browser` and the client exchanges the code. */ async function consentIn(ctx: Tenant, browser: CookieJar) { const { code, verifier } = await authorizationCode(oauth.issuer, { ...authorization(ctx), browser }); - const tokens = await exchangeCode(oauth.issuer, { - clientId: APP_ID, - redirectUri: REDIRECT_URI, - code: code ?? '', - verifier, - }); + const tokens = await exchangeCode(oauth.issuer, { clientId: APP_ID, redirectUri: REDIRECT_URI, code: code ?? '', verifier }); expect(tokens.status).toBe(200); return tokens.body; } @@ -613,10 +598,7 @@ describe('OAuth grants', async () => { // Consenting there gets the one signed in a grant and a token of their own. const granted = await authorizationCodeToken(oauth.issuer, { ...authorization(ctx, APP_ID, other), browser }); expect(granted.status).toBe(200); - const token = await verifyAccessToken(String(granted.body.access_token), { - tenantId: ctx.org.tenantId, - organizationId: ctx.org.id, - }); + const token = await verifyAccessToken(String(granted.body.access_token), { tenantId: ctx.org.tenantId, organizationId: ctx.org.id }); expect(token.actorId).toBe(other.id); expect(await grantRowsOf(other.id)).toContainEqual({ type: 'Grant' }); // The member's grant is theirs still, and untouched. @@ -631,10 +613,7 @@ describe('OAuth grants', async () => { // A system admin takes this browser over and impersonates the member, whose provider session is still here. const admin = await createSystemAdminUser(`impersonator-${nanoid(8)}@security-test.com`); const adminSession = await insertTestSession(admin); - const impersonation = await insertTestSession( - { id: ctx.member.id }, - { type: 'impersonation', impersonatorSessionId: adminSession.id }, - ); + const impersonation = await insertTestSession({ id: ctx.member.id }, { type: 'impersonation', impersonatorSessionId: adminSession.id }); browser.add(adminSession.cookie); browser.add(impersonation.cookie); // Positive control: in the app, this browser now acts as the member. @@ -651,9 +630,7 @@ describe('OAuth grants', async () => { const browser = new CookieJar([ctx.member.sessionCookie]); await consentIn(ctx, browser); // Positive control: with the member signed in here, a silent request gets a code. - expect( - (await startAuthorization(oauth.issuer, { ...authorization(ctx), browser, prompt: 'none' })).code, - ).toBeTruthy(); + expect((await startAuthorization(oauth.issuer, { ...authorization(ctx), browser, prompt: 'none' })).code).toBeTruthy(); const other = await createOrgUser(call, ctx.org.tenantId, ctx.org.id, `other-${nanoid(8)}`); browser.add(other.sessionCookie); @@ -667,9 +644,7 @@ describe('OAuth grants', async () => { const started = await startAuthorization(oauth.issuer, authorization(ctx)); expect(started.uid).toBeTruthy(); const details = (cookie: string) => - fetch(`${new URL(oauth.issuer).origin}/oauth/interaction/${started.uid}/details`, { - headers: { Cookie: cookie }, - }); + fetch(`${new URL(oauth.issuer).origin}/oauth/interaction/${started.uid}/details`, { headers: { Cookie: cookie } }); // Positive control: with the provider's interaction cookie, the details load. expect((await details(started.browser.header())).status).toBe(200); @@ -692,7 +667,7 @@ describe('OAuth grants', async () => { return session; }; const ending = (reason: SessionEndReason, sessionId: string) => - endSessions({ var: { db } }, { userId: user.id, sessionIds: [sessionId], reason, by: null }); + revokeSessions({ var: { db } }, { userId: user.id, sessionIds: [sessionId], reason, by: null }); for (const reason of ['sign_out', 'other_session', 'mfa_enabled'] as const) { await ending(reason, (await signIn()).id); @@ -725,8 +700,7 @@ describe('OAuth grants', async () => { it('must not mint tokens twice via a replayed code, and the replay revokes the grant', async () => { const ctx = await tenantWithApp(); const { code, verifier } = await authorizationCode(oauth.issuer, authorization(ctx)); - const exchange = () => - exchangeCode(oauth.issuer, { clientId: APP_ID, redirectUri: REDIRECT_URI, code: code ?? '', verifier }); + const exchange = () => exchangeCode(oauth.issuer, { clientId: APP_ID, redirectUri: REDIRECT_URI, code: code ?? '', verifier }); const first = await exchange(); expect(first.status).toBe(200); @@ -743,7 +717,6 @@ describe('OAuth grants', async () => { expect(read.response.status).toBe(401); expect(reasonOf(read.error)).toBe('grant_revoked'); expect((await refresh(String(first.body.refresh_token))).body.error).toBe('invalid_grant'); - await toldOtherProcesses(await grantOf(ctx, access)); }); it('must not mint tokens via a rotated refresh token, and the replay revokes the grant', async () => { @@ -764,16 +737,12 @@ describe('OAuth grants', async () => { const read = await readAttachments(ctx, access); expect(read.response.status).toBe(401); expect(reasonOf(read.error)).toBe('grant_revoked'); - await toldOtherProcesses(await grantOf(ctx, access)); }); /** Holds every consume back a moment, so each racing request has read the unspent row before any spends it. */ function widenConsumeRace() { const consume = DrizzleAdapter.prototype.consume; - return vi.spyOn(DrizzleAdapter.prototype, 'consume').mockImplementation(async function ( - this: DrizzleAdapter, - id, - ) { + return vi.spyOn(DrizzleAdapter.prototype, 'consume').mockImplementation(async function (this: DrizzleAdapter, id) { await new Promise((resolve) => setTimeout(resolve, 50)); return consume.call(this, id); }); @@ -782,17 +751,15 @@ describe('OAuth grants', async () => { it('must not mint tokens twice via two concurrent exchanges of one code', async () => { const ctx = await tenantWithApp(); const { code, verifier } = await authorizationCode(oauth.issuer, authorization(ctx)); - const exchange = () => - exchangeCode(oauth.issuer, { clientId: APP_ID, redirectUri: REDIRECT_URI, code: code ?? '', verifier }); + const exchange = () => exchangeCode(oauth.issuer, { clientId: APP_ID, redirectUri: REDIRECT_URI, code: code ?? '', verifier }); const race = widenConsumeRace(); const results = await Promise.all([exchange(), exchange()]).finally(() => race.mockRestore()); expect(results.map((result) => result.status).sort()).toEqual([200, 400]); expect(results.find((result) => result.status === 400)?.body.error).toBe('invalid_grant'); - // The replay revoked the grant, the winner's tokens with it, here and in the other processes. + // The replay revoked the grant, the winner's tokens with it. const winner = String(results.find((result) => result.status === 200)?.body.access_token); expect((await readAttachments(ctx, winner)).response.status).toBe(401); - await toldOtherProcesses(await grantOf(ctx, winner)); }); it('must not mint tokens twice via two concurrent refreshes with one refresh token', async () => { @@ -800,9 +767,7 @@ describe('OAuth grants', async () => { const grant = await consent(ctx); const race = widenConsumeRace(); - const results = await Promise.all([refresh(grant.refresh), refresh(grant.refresh)]).finally(() => - race.mockRestore(), - ); + const results = await Promise.all([refresh(grant.refresh), refresh(grant.refresh)]).finally(() => race.mockRestore()); expect(results.map((result) => result.status).sort()).toEqual([200, 400]); expect(results.find((result) => result.status === 400)?.body.error).toBe('invalid_grant'); }); @@ -815,12 +780,7 @@ describe('OAuth grants', async () => { expect(code).toBeTruthy(); expect(await stored()).not.toContain(code); - const tokens = await exchangeCode(oauth.issuer, { - clientId: APP_ID, - redirectUri: REDIRECT_URI, - code: code ?? '', - verifier, - }); + const tokens = await exchangeCode(oauth.issuer, { clientId: APP_ID, redirectUri: REDIRECT_URI, code: code ?? '', verifier }); expect(tokens.status).toBe(200); const refreshToken = String(tokens.body.refresh_token); const rows = await stored(); @@ -836,12 +796,9 @@ describe('OAuth grants', async () => { it('must not mint a service token via the client_credentials grant of a registered app', async () => { const org = await createTestOrganization(); const secret = `partner-secret-${nanoid(16)}`; - await db.insert(oauthClientsTable).values({ - id: 'grant-policy-partner', - name: 'Partner', - redirectUris: [REDIRECT_URI], - secretHash: hashToken(secret), - }); + await db + .insert(oauthClientsTable) + .values({ id: 'grant-policy-partner', name: 'Partner', redirectUris: [REDIRECT_URI], secretHash: hashToken(secret) }); const resource = resourceUri({ face: 'api', tenantId: org.tenantId }); const refused = await clientCredentialsToken( @@ -864,8 +821,7 @@ describe('OAuth grants', async () => { const other = await createTestOrganization(); const admin = await createOrgUser(call, org.tenantId, org.id, `admin-${nanoid(8)}`, adminRole); const client = await serviceAccountWithKey(org, admin.sessionCookie); - const mint = (resource: string) => - clientCredentialsToken(oauth.issuer, client, { scope: 'attachment:read', resource }); + const mint = (resource: string) => clientCredentialsToken(oauth.issuer, client, { scope: 'attachment:read', resource }); for (const resource of [ resourceUri({ face: 'api', tenantId: other.tenantId }), @@ -917,17 +873,9 @@ describe('OAuth grants', async () => { await allowUnregisteredClients(uninstalled.org.tenantId, false); const cases = [ - { - refusal: 'not_a_member', - input: authorization(installed, APP_ID, uninstalled.member), - user: uninstalled.member, - }, + { refusal: 'not_a_member', input: authorization(installed, APP_ID, uninstalled.member), user: uninstalled.member }, { refusal: 'app_not_installed', input: authorization(uninstalled), user: uninstalled.member }, - { - refusal: 'unregistered_clients_not_allowed', - input: authorization(uninstalled, CIMD_ID), - user: uninstalled.member, - }, + { refusal: 'unregistered_clients_not_allowed', input: authorization(uninstalled, CIMD_ID), user: uninstalled.member }, ]; for (const { refusal, input, user } of cases) { const result = await authorizationCode(oauth.issuer, input); @@ -947,21 +895,11 @@ describe('OAuth grants', async () => { const ctx = await tenantWithApp(); const unregistered = await authorizationCode(oauth.issuer, authorization(ctx, CIMD_ID)); - expect(unregistered.consent.client).toEqual({ - id: CIMD_ID, - name: 'mcp-client.example', - logoUri: null, - kind: 'cimd', - }); + expect(unregistered.consent.client).toEqual({ id: CIMD_ID, name: 'mcp-client.example', logoUri: null, kind: 'cimd' }); // Positive control: a registered app keeps the name and logo a system admin set. const registered = await authorizationCode(oauth.issuer, authorization(ctx)); - expect(registered.consent.client).toEqual({ - id: APP_ID, - name: 'Portfolio', - logoUri: APP_LOGO, - kind: 'registered', - }); + expect(registered.consent.client).toEqual({ id: APP_ID, name: 'Portfolio', logoUri: APP_LOGO, kind: 'registered' }); }); it('names the tenant and organization a grant reaches on the consent page', async () => { @@ -1000,11 +938,7 @@ describe('OAuth grants', async () => { .insert(systemRolesTable) .values({ id: admin.id, userId: admin.id, role: 'admin', createdAt: new Date().toISOString() }); const rename = (headers: Record) => - call(updateOrganization, { - path: { tenantId: ctx.org.tenantId, id: ctx.org.id }, - body: { name: 'Renamed organization' }, - headers, - }); + call(updateOrganization, { path: { tenantId: ctx.org.tenantId, id: ctx.org.id }, body: { name: 'Renamed organization' }, headers }); const granted = await authorizationCodeToken(oauth.issuer, { ...authorization(ctx, APP_ID, admin), @@ -1028,10 +962,7 @@ describe('OAuth grants', async () => { // A system admin's impersonation of the member, layered on the admin's own session. const admin = await createSystemAdminUser(`consent-admin-${nanoid(8)}@security-test.com`); const adminSession = await insertTestSession(admin); - const impersonating = await insertTestSession(ctx.member, { - type: 'impersonation', - impersonatorSessionId: adminSession.id, - }); + const impersonating = await insertTestSession(ctx.member, { type: 'impersonation', impersonatorSessionId: adminSession.id }); const refused = await authorizationCode(oauth.issuer, { ...authorization(ctx), sessionCookie: stale.cookie }); expect(refused.code).toBeNull(); diff --git a/backend/tests/security/oauth-metadata-fetch-limit.test.ts b/backend/tests/security/oauth-metadata-fetch-limit.test.ts index 9ae41a6db..b4476dfe2 100644 --- a/backend/tests/security/oauth-metadata-fetch-limit.test.ts +++ b/backend/tests/security/oauth-metadata-fetch-limit.test.ts @@ -4,13 +4,7 @@ import { clientMetadataFetchLimiter } from '#/middlewares/rate-limiter/limiters' import { resourceUri } from '#/modules/oauth-server/resources'; import { adminRole } from '../fixtures'; import { CookieJar, createTestOrganization, expectRefusal } from '../helpers'; -import { - authorizationCodeToken, - installApp, - registerApp, - startTestOauthServer, - type TestOauthServer, -} from '../oauth-helpers'; +import { authorizationCodeToken, installApp, registerApp, startTestOauthServer, type TestOauthServer } from '../oauth-helpers'; import { createAppClient } from '../test-client'; import { clearSecurityTestData, createOrgUser } from './helpers'; @@ -57,11 +51,7 @@ describe('authorization server fetch budget', async () => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; if (url === CIMD_ID || url === SECTOR_CLIENT_ID) { fetched.push(url); - const document = { - ...cimdDocument, - client_id: url, - ...(url === SECTOR_CLIENT_ID && { sector_identifier_uri: SECTOR_URI }), - }; + const document = { ...cimdDocument, client_id: url, ...(url === SECTOR_CLIENT_ID && { sector_identifier_uri: SECTOR_URI }) }; return new Response(JSON.stringify(document), { headers: { 'content-type': 'application/json' } }); } if (url === SECTOR_URI) { @@ -188,9 +178,7 @@ describe('authorization server fetch budget', async () => { try { const before = fetched.length; const details = (from: string) => - fetch(`${origin()}/oauth/interaction/${uid}/details`, { - headers: { Cookie: browser.header(), 'x-forwarded-for': from }, - }); + fetch(`${origin()}/oauth/interaction/${uid}/details`, { headers: { Cookie: browser.header(), 'x-forwarded-for': from } }); const refused = await details(ip); await expectRefusal(refused, 429, 'too_many_requests'); @@ -235,9 +223,7 @@ describe('authorization server fetch budget', async () => { const ip = randomIp(); await spendBudget(ip); - const discovery = await fetch(`${oauth.issuer}/.well-known/oauth-authorization-server`, { - headers: { 'x-forwarded-for': ip }, - }); + const discovery = await fetch(`${oauth.issuer}/.well-known/oauth-authorization-server`, { headers: { 'x-forwarded-for': ip } }); expect(discovery.status).toBe(200); expect((await fetch(`${oauth.issuer}/jwks`, { headers: { 'x-forwarded-for': ip } })).status).toBe(200); }); diff --git a/backend/tests/security/outside-writes.test.ts b/backend/tests/security/outside-writes.test.ts new file mode 100644 index 000000000..00d56b016 --- /dev/null +++ b/backend/tests/security/outside-writes.test.ts @@ -0,0 +1,100 @@ +import { eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { getAttachments } from 'sdk'; +import { generateId } from 'shared/utils/entity-id'; +import { afterEach, describe, expect, it } from 'vitest'; +import { baseDb as db } from '#/db/db'; +import { activityBus } from '#/lib/activity-bus'; +import { clearSessionCache } from '#/middlewares/guard/session-cache'; +import { actorsTable } from '#/modules/actors/actors-db'; +import { sessionsTable } from '#/modules/auth/sessions-db'; +import { membershipsTable } from '#/modules/memberships/memberships-db'; +import { organizationsTable } from '#/modules/organization/organization-db'; +import { defaultHeaders, memberRole } from '../fixtures'; +import { createTestOrganization, createTestUser, expectRefusal } from '../helpers'; +import { createAppClient } from '../test-client'; +import { clearSecurityTestData, createOrgUser } from './helpers'; + +/** + * Another process or a write outside the API changes access without telling this one: CDC reports, or the session + * cache's 10 seconds pass. Memberships are cached under the bindings version a trigger replaces. + */ +describe('access written outside this process', async () => { + const call = await createAppClient(); + + afterEach(async () => await clearSecurityTestData()); + + const bindingsVersionOf = async (userId: string) => + (await db.select({ version: actorsTable.bindingsVersion }).from(actorsTable).where(eq(actorsTable.id, userId)))[0]?.version; + + /** A member whose first read caches their memberships in this process. */ + async function memberReading() { + const org = await createTestOrganization(); + const member = await createOrgUser(call, org.tenantId, org.id, `member-${nanoid(8)}`, memberRole); + const read = () => + call(getAttachments, { + path: { tenantId: org.tenantId, organizationId: org.id }, + headers: { ...defaultHeaders, Cookie: member.sessionCookie }, + }); + expect((await read()).response.status).toBe(200); + return { member, read }; + } + + it('gives the bindings version a new value at every membership insert, update and delete, cascades included', async () => { + const user = await createTestUser(`versioned-${nanoid(8)}@security-test.com`); + const org = await createTestOrganization(); + const versions = [await bindingsVersionOf(user.id)]; + + await db.insert(membershipsTable).values({ + id: generateId(), + userId: user.id, + channelId: org.id, + organizationId: org.id, + tenantId: org.tenantId, + channelType: 'organization', + role: memberRole, + displayOrder: 1, + createdBy: user.id, + }); + versions.push(await bindingsVersionOf(user.id)); + await db.update(membershipsTable).set({ muted: true }).where(eq(membershipsTable.userId, user.id)); + versions.push(await bindingsVersionOf(user.id)); + // Deleting the organization takes the membership by cascade. + await db.delete(organizationsTable).where(eq(organizationsTable.id, org.id)); + versions.push(await bindingsVersionOf(user.id)); + + expect(await db.select().from(membershipsTable).where(eq(membershipsTable.userId, user.id))).toEqual([]); + expect(new Set(versions).size).toBe(4); + }); + + it('must not keep a member reading via a cached session once CDC reports their membership removed', async () => { + const { member, read } = await memberReading(); + + const [membership] = await db.delete(membershipsTable).where(eq(membershipsTable.userId, member.id)).returning(); + // What the CDC worker delivers to the API process once the delete commits. + activityBus.emit({ + id: generateId(), + type: 'membership.deleted', + action: 'delete', + resourceType: 'membership', + entityType: null, + rowData: membership, + } as never); + + // The tenant guard refuses first: the member holds no membership in the tenant any more. + await expectRefusal(await read(), 403, 'forbidden'); + }); + + it('must not keep a session reading once another process revoked it and the cached entry expired', async () => { + const { member, read } = await memberReading(); + + await db + .update(sessionsTable) + .set({ revokedAt: new Date().toISOString(), revocationReason: 'sign_out' }) + .where(eq(sessionsTable.userId, member.id)); + // Sessions are not tracked by CDC: another process's revocation counts here once the 10-second entry is gone. + clearSessionCache(); + + await expectRefusal(await read(), 401, 'session_revoked'); + }); +}); diff --git a/backend/tests/security/passkey-challenges.test.ts b/backend/tests/security/passkey-challenges.test.ts index fa48bc3fc..df601dd8f 100644 --- a/backend/tests/security/passkey-challenges.test.ts +++ b/backend/tests/security/passkey-challenges.test.ts @@ -6,16 +6,7 @@ import { baseDb as db } from '#/db/db'; import { passkeysTable } from '#/modules/auth/passkeys/passkeys-db'; import { usersTable } from '#/modules/user/user-db'; import { defaultHeaders } from '../fixtures'; -import { - authCookie, - cookieChange, - createMfaToken, - createTestSession, - createUser, - expectRefusal, - sessionsOf, - tokenRowOf, -} from '../helpers'; +import { authCookie, cookieChange, createMfaToken, createTestSession, createUser, expectRefusal, sessionsOf, tokenRowOf } from '../helpers'; import { type SoftwarePasskey, softwarePasskey } from '../software-passkey'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; @@ -38,8 +29,7 @@ vi.mock('@simplewebauthn/server', async (importOriginal) => { setTestConfig({ enabledAuthStrategies: ['passkey', 'totp'] }); -const storedPasskey = async (credentialId: string) => - (await db.select().from(passkeysTable).where(eq(passkeysTable.credentialId, credentialId)))[0]; +const storedPasskey = async (credentialId: string) => (await db.select().from(passkeysTable).where(eq(passkeysTable.credentialId, credentialId)))[0]; afterEach(async () => { hooks.afterVerify = undefined; @@ -76,9 +66,7 @@ describe('Passkey challenges', async () => { // Positive control: a fresh challenge signs in again. const fresh = await passkeyChallenge('authentication'); - expect((await passkeySignIn(passkey.assert(fresh.challenge, { counter: 0 }), fresh.cookie)).response.status).toBe( - 204, - ); + expect((await passkeySignIn(passkey.assert(fresh.challenge, { counter: 0 }), fresh.cookie)).response.status).toBe(204); expect(await sessionsOf(user.id)).toHaveLength(2); }); @@ -99,11 +87,7 @@ describe('Passkey challenges', async () => { // Positive control: a challenge issued for this MFA challenge completes it. const mfaChallenge = await passkeyChallenge('mfa', mfaCookie); - const completed = await passkeySignIn( - passkey.assert(mfaChallenge.challenge, { counter: 1 }), - `${mfaCookie}; ${mfaChallenge.cookie}`, - 'mfa', - ); + const completed = await passkeySignIn(passkey.assert(mfaChallenge.challenge, { counter: 1 }), `${mfaCookie}; ${mfaChallenge.cookie}`, 'mfa'); expect(completed.response.status).toBe(204); expect(await tokenRowOf('confirm-mfa', mfaToken)).toBeUndefined(); }); @@ -136,9 +120,7 @@ describe('Passkey challenges', async () => { // Positive control: a counter past the stored one signs in and is stored. const fresh = await passkeyChallenge('authentication'); - expect((await passkeySignIn(passkey.assert(fresh.challenge, { counter: 6 }), fresh.cookie)).response.status).toBe( - 204, - ); + expect((await passkeySignIn(passkey.assert(fresh.challenge, { counter: 6 }), fresh.cookie)).response.status).toBe(204); expect((await storedPasskey(passkey.credentialId)).counter).toBe(6); }); @@ -158,9 +140,7 @@ describe('Passkey challenges', async () => { // Positive control: without a concurrent use, the next counter signs in. hooks.afterVerify = undefined; const fresh = await passkeyChallenge('authentication'); - expect((await passkeySignIn(passkey.assert(fresh.challenge, { counter: 2 }), fresh.cookie)).response.status).toBe( - 204, - ); + expect((await passkeySignIn(passkey.assert(fresh.challenge, { counter: 2 }), fresh.cookie)).response.status).toBe(204); }); it("must not register a passkey via another account's credential id", async () => { diff --git a/backend/tests/security/passkey-verification.test.ts b/backend/tests/security/passkey-verification.test.ts index d1f16d6f8..b81c9dc37 100644 --- a/backend/tests/security/passkey-verification.test.ts +++ b/backend/tests/security/passkey-verification.test.ts @@ -21,22 +21,12 @@ describe('Passkey verification', () => { const forgeries: [string, (passkey: SoftwarePasskey, challenge: string) => PasskeyAssertion][] = [ ['a response to another challenge', (passkey) => passkey.assert(nanoid(43))], - [ - 'a response for another origin', - (passkey, challenge) => passkey.assert(challenge, { origin: 'https://evil.example' }), - ], - [ - 'a response for another relying party', - (passkey, challenge) => passkey.assert(challenge, { rpId: 'evil.example' }), - ], + ['a response for another origin', (passkey, challenge) => passkey.assert(challenge, { origin: 'https://evil.example' })], + ['a response for another relying party', (passkey, challenge) => passkey.assert(challenge, { rpId: 'evil.example' })], ['a response without user verification', (passkey, challenge) => passkey.assert(challenge, { flags: 0x01 })], [ 'a signature from another key', - (passkey, challenge) => ({ - ...softwarePasskey().assert(challenge), - id: passkey.credentialId, - rawId: passkey.credentialId, - }), + (passkey, challenge) => ({ ...softwarePasskey().assert(challenge), id: passkey.credentialId, rawId: passkey.credentialId }), ], ]; diff --git a/backend/tests/security/pending-invitations.test.ts b/backend/tests/security/pending-invitations.test.ts index ffdb4d6a4..454a4c544 100644 --- a/backend/tests/security/pending-invitations.test.ts +++ b/backend/tests/security/pending-invitations.test.ts @@ -61,12 +61,7 @@ describe('Pending invitations list', async () => { const account = await createTestUser(accountPrimary); await db.update(usersTable).set({ thumbnailUrl: accountAvatar }).where(eq(usersTable.id, account.id)); - await db.insert(emailsTable).values({ - email: accountAlternate, - userId: account.id, - verified: true, - verifiedAt: mockPastIsoDate(), - }); + await db.insert(emailsTable).values({ email: accountAlternate, userId: account.id, verified: true, verifiedAt: mockPastIsoDate() }); const { response } = await invite([accountAlternate, newcomer]); expect(response.status).toBe(200); @@ -120,9 +115,7 @@ describe('Pending invitations list', async () => { it('must not link another address to a member via the membershipInvite response', async () => { // A second address the member proved: the inviter knows members only by their listed address. const memberAlternate = 'pending-member-alternate@security-test.com'; - await db - .insert(emailsTable) - .values({ email: memberAlternate, userId: member.id, verified: true, verifiedAt: mockPastIsoDate() }); + await db.insert(emailsTable).values({ email: memberAlternate, userId: member.id, verified: true, verifiedAt: mockPastIsoDate() }); const toMemberAlternate = await invite([memberAlternate]); const toNewcomer = await invite(['pending-newcomer-3@security-test.com']); diff --git a/backend/tests/security/permission-enforcement.test.ts b/backend/tests/security/permission-enforcement.test.ts index 875679169..214d0c9b4 100644 --- a/backend/tests/security/permission-enforcement.test.ts +++ b/backend/tests/security/permission-enforcement.test.ts @@ -62,17 +62,14 @@ describe('Member escalation over HTTP', async () => { const headers = (as: User) => ({ ...defaultHeaders, Cookie: as.sessionCookie }); const counterpart = ({ admin, member }: Fixture, actor: User) => (actor.id === admin.id ? member : admin); - const attachmentRow = async (id: string) => - (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; - const organizationRow = async (id: string) => - (await db.select().from(organizationsTable).where(eq(organizationsTable.id, id)))[0]; + const attachmentRow = async (id: string) => (await adminDb.select().from(attachmentsTable).where(eq(attachmentsTable.id, id)))[0]; + const organizationRow = async (id: string) => (await db.select().from(organizationsTable).where(eq(organizationsTable.id, id)))[0]; const membershipsOf = (userId: string, organizationId: string) => db .select() .from(membershipsTable) .where(and(eq(membershipsTable.userId, userId), eq(membershipsTable.organizationId, organizationId))); - const invitationsTo = (email: string) => - db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.email, email)); + const invitationsTo = (email: string) => db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.email, email)); /** An organization with an admin, a member, the admin's attachment and a pending invitation. */ const fixture = async (label: string): Promise => { @@ -90,30 +87,15 @@ describe('Member escalation over HTTP', async () => { expect(response.status).toBe(201); return id; }; - const { inactiveMembership } = await createInvitation({ - organization: org, - email: `${label}-invitee@security-test.com`, - createdBy: admin.id, - }); - return { - org, - admin, - member, - attachment: await attachmentOf(admin), - invitation: inactiveMembership.id, - attachmentOf, - }; + const { inactiveMembership } = await createInvitation({ organization: org, email: `${label}-invitee@security-test.com`, createdBy: admin.id }); + return { org, admin, member, attachment: await attachmentOf(admin), invitation: inactiveMembership.id, attachmentOf }; }; const rows: Row[] = [ { act: 'update the organization', attempt: ({ org }, actor) => - call(updateOrganization, { - path: { tenantId: org.tenantId, id: org.id }, - body: { name: 'Hijacked' }, - headers: headers(actor), - }), + call(updateOrganization, { path: { tenantId: org.tenantId, id: org.id }, body: { name: 'Hijacked' }, headers: headers(actor) }), unchanged: async ({ org }) => expect((await organizationRow(org.id)).name).toBe(org.name), okStatus: 200, }, @@ -126,17 +108,13 @@ describe('Member escalation over HTTP', async () => { body: { emails: [`newcomer-${actor.id}@security-test.com`], role: adminRole }, headers: headers(actor), }), - unchanged: async ({ member }) => - expect(await invitationsTo(`newcomer-${member.id}@security-test.com`)).toEqual([]), + unchanged: async ({ member }) => expect(await invitationsTo(`newcomer-${member.id}@security-test.com`)).toEqual([]), okStatus: 200, }, { act: 're-send a pending invitation', attempt: ({ org, invitation }, actor) => - call(resendPendingInvitation, { - path: { tenantId: org.tenantId, organizationId: org.id, id: invitation }, - headers: headers(actor), - }), + call(resendPendingInvitation, { path: { tenantId: org.tenantId, organizationId: org.id, id: invitation }, headers: headers(actor) }), unchanged: async () => expect(mailer.prepareEmails).not.toHaveBeenCalled(), okStatus: 204, }, diff --git a/backend/tests/security/permission-engine-boundary.test.ts b/backend/tests/security/permission-engine-boundary.test.ts index edfef5a00..a29f69511 100644 --- a/backend/tests/security/permission-engine-boundary.test.ts +++ b/backend/tests/security/permission-engine-boundary.test.ts @@ -40,8 +40,7 @@ describe('permission engine boundary', () => { expect( offenders, - 'getAllDecisions must be reached only via checkPermission (the actor-guarded wrapper). ' + - `Offending files:\n ${offenders.join('\n ')}`, + `getAllDecisions must be reached only via checkPermission (the actor-guarded wrapper). Offending files:\n ${offenders.join('\n ')}`, ).toEqual([]); }); }); diff --git a/backend/tests/security/profile-image-urls.test.ts b/backend/tests/security/profile-image-urls.test.ts index febd87e23..c1738adf3 100644 --- a/backend/tests/security/profile-image-urls.test.ts +++ b/backend/tests/security/profile-image-urls.test.ts @@ -13,12 +13,7 @@ import { clearSecurityTestData } from './helpers'; const cdn = appConfig.s3.publicCDNUrl; const imageUrlsOf = async (userId: string) => - ( - await db - .select({ thumbnailUrl: usersTable.thumbnailUrl, bannerUrl: usersTable.bannerUrl }) - .from(usersTable) - .where(eq(usersTable.id, userId)) - )[0]; + (await db.select({ thumbnailUrl: usersTable.thumbnailUrl, bannerUrl: usersTable.bannerUrl }).from(usersTable).where(eq(usersTable.id, userId)))[0]; /** * Avatars and banners render as `` in every viewer's browser. Only the app's own CDN may serve them, so a @@ -39,10 +34,7 @@ describe('Profile image URLs', async () => { it('must not point an avatar or a banner at another host via a CDN-prefixed URL', async () => { const { user, headers } = await userWithSession(); - for (const body of [ - { thumbnailUrl: `${cdn}@evil.example/pixel.png` }, - { bannerUrl: `${cdn}.evil.example/banner.png` }, - ]) { + for (const body of [{ thumbnailUrl: `${cdn}@evil.example/pixel.png` }, { bannerUrl: `${cdn}.evil.example/banner.png` }]) { const { error, response } = await call(updateMe, { body, headers }); await expectRefusal({ response, error }, 400, 'invalid_cdn_url', JSON.stringify(body)); } @@ -52,14 +44,8 @@ describe('Profile image URLs', async () => { it('stores an avatar and banner on the CDN, trimmed (positive control)', async () => { const { user, headers } = await userWithSession(); - const { response } = await call(updateMe, { - body: { thumbnailUrl: ` ${cdn}/avatars/a.png `, bannerUrl: `${cdn}/banners/b.png` }, - headers, - }); + const { response } = await call(updateMe, { body: { thumbnailUrl: ` ${cdn}/avatars/a.png `, bannerUrl: `${cdn}/banners/b.png` }, headers }); expect(response.status).toBe(200); - expect(await imageUrlsOf(user.id)).toEqual({ - thumbnailUrl: `${cdn}/avatars/a.png`, - bannerUrl: `${cdn}/banners/b.png`, - }); + expect(await imageUrlsOf(user.id)).toEqual({ thumbnailUrl: `${cdn}/avatars/a.png`, bannerUrl: `${cdn}/banners/b.png` }); }); }); diff --git a/backend/tests/security/rejected-invitations.test.ts b/backend/tests/security/rejected-invitations.test.ts index b815dd9fa..84e81398a 100644 --- a/backend/tests/security/rejected-invitations.test.ts +++ b/backend/tests/security/rejected-invitations.test.ts @@ -27,10 +27,7 @@ describe('Rejected invitations', async () => { afterEach(async () => await clearSecurityTestData()); const respond = (id: string, acceptOrReject: 'accept' | 'reject', sessionCookie: string) => - call(handleMembershipInvitation, { - path: { id, acceptOrReject }, - headers: { ...defaultHeaders, Cookie: sessionCookie }, - }); + call(handleMembershipInvitation, { path: { id, acceptOrReject }, headers: { ...defaultHeaders, Cookie: sessionCookie } }); const membershipsIn = (userId: string, organizationId: string) => db @@ -40,10 +37,7 @@ describe('Rejected invitations', async () => { const rejectedAtOf = async (id: string) => ( - await db - .select({ rejectedAt: inactiveMembershipsTable.rejectedAt }) - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, id)) + await db.select({ rejectedAt: inactiveMembershipsTable.rejectedAt }).from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, id)) )[0]?.rejectedAt; const markRejected = (id: string) => @@ -53,12 +47,7 @@ describe('Rejected invitations', async () => { const organization = await createTestOrganization(); const invitee = await createTestUser('rejected-invitee@security-test.com'); const sessionCookie = await createTestSession(invitee); - const { inactiveMembership } = await createInvitation({ - organization, - email: invitee.email, - createdBy: invitee.id, - boundTo: invitee.id, - }); + const { inactiveMembership } = await createInvitation({ organization, email: invitee.email, createdBy: invitee.id, boundTo: invitee.id }); expect((await respond(inactiveMembership.id, 'reject', sessionCookie)).response.status).toBe(200); @@ -91,23 +80,13 @@ describe('Rejected invitations', async () => { it('accepts a pending invitation by id and by token (positive control)', async () => { const organization = await createTestOrganization(); const byId = await createTestUser('pending-by-id@security-test.com'); - const byIdInvitation = await createInvitation({ - organization, - email: byId.email, - createdBy: byId.id, - boundTo: byId.id, - }); + const byIdInvitation = await createInvitation({ organization, email: byId.email, createdBy: byId.id, boundTo: byId.id }); const accepted = await respond(byIdInvitation.inactiveMembership.id, 'accept', await createTestSession(byId)); expect(accepted.response.status).toBe(200); expect(await membershipsIn(byId.id, organization.id)).toHaveLength(1); const byToken = await createTestUser('pending-by-token@security-test.com'); - const { invitationCookie } = await createInvitation({ - organization, - email: byToken.email, - createdBy: byToken.id, - token: 'invoked', - }); + const { invitationCookie } = await createInvitation({ organization, email: byToken.email, createdBy: byToken.id, token: 'invoked' }); const viaToken = await call(acceptInvitationToken, { headers: { ...defaultHeaders, Cookie: [await createTestSession(byToken), invitationCookie].join('; ') }, }); @@ -118,27 +97,15 @@ describe('Rejected invitations', async () => { it('must not bind a rejected invitation via an inbox proof', async () => { const owner = await createTestUser('proven-owner@security-test.com'); // A rejected invitation that no account holds, and a pending one, both to the owner's address. - const rejected = await createInvitation({ - organization: await createTestOrganization(), - email: owner.email, - createdBy: owner.id, - }); + const rejected = await createInvitation({ organization: await createTestOrganization(), email: owner.email, createdBy: owner.id }); await markRejected(rejected.inactiveMembership.id); - const pending = await createInvitation({ - organization: await createTestOrganization(), - email: owner.email, - createdBy: owner.id, - }); + const pending = await createInvitation({ organization: await createTestOrganization(), email: owner.email, createdBy: owner.id }); expect(await markEmailVerified(db, { userId: owner.id, email: owner.email, via: 'magic' })).toBe(true); const boundUserOf = async (id: string) => - ( - await db - .select({ userId: inactiveMembershipsTable.userId }) - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, id)) - )[0]?.userId; + (await db.select({ userId: inactiveMembershipsTable.userId }).from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, id)))[0] + ?.userId; expect(await boundUserOf(rejected.inactiveMembership.id)).toBeNull(); // The pending invitation to the same address is claimed (positive control). expect(await boundUserOf(pending.inactiveMembership.id)).toBe(owner.id); @@ -173,11 +140,7 @@ describe('Rejected invitations', async () => { .set({ restrictions: { ...restrictions, quotas: { ...restrictions.quotas, user: 3 } } }) .where(eq(tenantsTable.id, organization.tenantId)); const admin = await createOrgUser(call, organization.tenantId, organization.id, 'rejected-admin', adminRole); - const rejected = await createInvitation({ - organization, - email: 'rejected-address@security-test.com', - createdBy: admin.id, - }); + const rejected = await createInvitation({ organization, email: 'rejected-address@security-test.com', createdBy: admin.id }); await markRejected(rejected.inactiveMembership.id); await createInvitation({ organization, email: 'pending-address@security-test.com', createdBy: admin.id }); diff --git a/backend/tests/security/relatable-organizations.test.ts b/backend/tests/security/relatable-organizations.test.ts index e45743483..e7011c910 100644 --- a/backend/tests/security/relatable-organizations.test.ts +++ b/backend/tests/security/relatable-organizations.test.ts @@ -6,13 +6,7 @@ import { baseDb as db } from '#/db/db'; import { invalidateCache } from '#/middlewares/guard/invalidate-cache'; import { membershipsTable } from '#/modules/memberships/memberships-db'; import { defaultHeaders, memberRole } from '../fixtures'; -import { - createSystemAdminUser, - createTestOrganization, - createTestSession, - expectRefusal, - getUserByEmail, -} from '../helpers'; +import { createSystemAdminUser, createTestOrganization, createTestSession, expectRefusal, getUserByEmail } from '../helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData, createOrgUser } from './helpers'; @@ -39,10 +33,7 @@ describe('Organizations of another user (relatableUserId)', async () => { let viewer: { id: string; email: string; sessionCookie: string }; const listAs = (as: { sessionCookie: string }, relatableUserId: string) => - call(getOrganizations, { - query: { relatableUserId, include: 'members,counts' }, - headers: { ...defaultHeaders, Cookie: as.sessionCookie }, - }); + call(getOrganizations, { query: { relatableUserId, include: 'members,counts' }, headers: { ...defaultHeaders, Cookie: as.sessionCookie } }); beforeAll(async () => { shared = await createTestOrganization(); @@ -112,9 +103,7 @@ describe('Organizations of another user (relatableUserId)', async () => { const { baseApp } = await import('#/routes'); // Raw requests: the SDK validates the query itself and would throw before the server is reached. const listRaw = (as: { sessionCookie: string }, relatableUserId: string) => - baseApp.request(`/organizations?${new URLSearchParams({ relatableUserId })}`, { - headers: { ...defaultHeaders, Cookie: as.sessionCookie }, - }); + baseApp.request(`/organizations?${new URLSearchParams({ relatableUserId })}`, { headers: { ...defaultHeaders, Cookie: as.sessionCookie } }); // Another user named by anything but a user id relates to nobody: the guard refuses before any query. const junk = await listRaw(viewer, 'not-a-user-id'); @@ -138,14 +127,12 @@ describe('Organizations of another user (relatableUserId)', async () => { // The target archived Alpha, holds another role there, and put Bravo first in their menu. await createOrgMembership(target.id, alpha, previewedRole, 2, true); await createOrgMembership(target.id, bravo, memberRole, 1); - for (const user of [viewer, target]) await invalidateCache.user(db, user.id); + // Rows written directly: drop the cached sessions as a membership operation does. + for (const user of [viewer, target]) invalidateCache.user(user.id); const pair = [alpha.id, bravo.id]; const listFor = (query: Record, as: { sessionCookie: string } = viewer) => - call(getOrganizations, { - query: { relatableUserId: target.id, ...query }, - headers: { ...defaultHeaders, Cookie: as.sessionCookie }, - }); + call(getOrganizations, { query: { relatableUserId: target.id, ...query }, headers: { ...defaultHeaders, Cookie: as.sessionCookie } }); const idsOf = (data: unknown) => (data as OrgList).items.map((org) => org.id).filter((id) => pair.includes(id)); // The listed user's archive and role are not the viewer's to filter on: refused, never dropped. diff --git a/backend/tests/security/resend-invitation.test.ts b/backend/tests/security/resend-invitation.test.ts index f752fb0ce..ecdfb75ff 100644 --- a/backend/tests/security/resend-invitation.test.ts +++ b/backend/tests/security/resend-invitation.test.ts @@ -97,9 +97,7 @@ describe('Resend an invitation', async () => { const opened = await invoke(rawFresh); expect(opened.response.status).toBe(302); - expect(opened.response.headers.get('location')).toBe( - `${appConfig.frontendUrl}/auth/authenticate?tokenId=${fresh.id}`, - ); + expect(opened.response.headers.get('location')).toBe(`${appConfig.frontendUrl}/auth/authenticate?tokenId=${fresh.id}`); const old = await invoke(rawToken); await expectRefusal(old, 401, 'invitation_not_found'); @@ -147,10 +145,7 @@ describe('Resend an invitation', async () => { it('must not re-mint a rejected invitation via resend-invitation', async () => { const { token, inactiveMembership } = await expiredInvitation(); - await db - .update(inactiveMembershipsTable) - .set({ rejectedAt: getIsoDate() }) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + await db.update(inactiveMembershipsTable).set({ rejectedAt: getIsoDate() }).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); const { response } = await resend({ tokenId: token.id }); @@ -201,11 +196,7 @@ describe('Resend an invitation', async () => { const { token } = await expiredInvitation(); const { baseApp } = await import('#/routes'); const resendByEmail = (email: string) => - baseApp.request('/auth/resend-invitation', { - method: 'POST', - headers: defaultHeaders, - body: JSON.stringify({ email }), - }); + baseApp.request('/auth/resend-invitation', { method: 'POST', headers: defaultHeaders, body: JSON.stringify({ email }) }); // An address is no key: the invited and the unknown one get the same refusal. const invited = await resendByEmail(invitedEmail); @@ -227,19 +218,10 @@ describe('Resend a pending invitation from the pending list', async () => { const setup = async () => { const organization = await createTestOrganization(); - const admin = await createOrganizationAdminUser( - 'org-admin@example.com', - organization.id, - adminRole, - organization.tenantId, - ); + const admin = await createOrganizationAdminUser('org-admin@example.com', organization.id, adminRole, organization.tenantId); const invitation = await createInvitation({ organization, email: invitedEmail, createdBy: admin.id }); const headers = { ...defaultHeaders, Cookie: await createTestSession(admin) }; - const path = { - tenantId: organization.tenantId, - organizationId: organization.id, - id: invitation.inactiveMembership.id, - }; + const path = { tenantId: organization.tenantId, organizationId: organization.id, id: invitation.inactiveMembership.id }; return { organization, headers, path, ...invitation }; }; @@ -258,10 +240,7 @@ describe('Resend a pending invitation from the pending list', async () => { it('must not re-mint a rejected invitation via the pending list', async () => { const { headers, path, token, inactiveMembership } = await setup(); - await db - .update(inactiveMembershipsTable) - .set({ rejectedAt: getIsoDate() }) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + await db.update(inactiveMembershipsTable).set({ rejectedAt: getIsoDate() }).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); const { response, error } = await call(resendPendingInvitation, { path, headers }); diff --git a/backend/tests/security/response-secrets.test.ts b/backend/tests/security/response-secrets.test.ts new file mode 100644 index 000000000..75c0d8a8a --- /dev/null +++ b/backend/tests/security/response-secrets.test.ts @@ -0,0 +1,215 @@ +import type { PgTable } from 'drizzle-orm/pg-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { baseDb as db } from '#/db/db'; +import { type SecretColumnTable, secretColumns } from '#/db/secret-columns'; +import { mockPastIsoDate } from '#/mocks'; +import { passkeyChallengesTable } from '#/modules/auth/passkeys/passkey-challenges-db'; +import { sessionsTable } from '#/modules/auth/sessions-db'; +import { tokensTable } from '#/modules/auth/tokens-db'; +import { encryptTotpSecret } from '#/modules/auth/totps/helpers/totp-secret-encryption'; +import { totpsTable } from '#/modules/auth/totps/totps-db'; +import { oauthClientsTable } from '#/modules/oauth-server/oauth-clients-db'; +import { signingKeysTable } from '#/modules/oauth-server/signing-keys-db'; +import { apiKeysTable } from '#/modules/service-accounts/api-keys-db'; +import { adminRole, memberRole } from '../fixtures'; +import { createTestOrganization, createTestSession, createTestUser, insertTestSession, rawJsonRequest, testTotpSecret } from '../helpers'; +import { createInvitation } from '../invitations/helpers'; +import { createAppClient } from '../test-client'; +import { setTestConfig } from '../test-utils'; +import { clearSecurityTestData, createOrgUser } from './helpers'; + +setTestConfig({ enabledAuthStrategies: ['passkey', 'totp'] }); + +/** The table of each `secretColumns` entry: a table added to the registry is a type error until it is listed here. */ +const secretTables = { + api_keys: apiKeysTable, + oauth_clients: oauthClientsTable, + passkey_challenges: passkeyChallengesTable, + sessions: sessionsTable, + signing_keys: signingKeysTable, + tokens: tokensTable, + totps: totpsTable, +} satisfies Record; + +/** Every value stored in a secret column at this moment, by table. */ +async function storedSecrets() { + const stored = {} as Record; + for (const [name, table] of Object.entries(secretTables) as [SecretColumnTable, PgTable][]) { + const rows: Record[] = await db.select().from(table); + const columns: readonly string[] = secretColumns[name]; + stored[name] = rows.flatMap((row) => + columns.map((column) => row[column]).filter((value): value is string => typeof value === 'string' && value !== ''), + ); + } + return stored; +} + +/** The path of every key, at any depth of `body`, that `names` holds. */ +function keysNamed(body: unknown, names: readonly string[], path = 'body'): string[] { + if (Array.isArray(body)) return body.flatMap((item, index) => keysNamed(item, names, `${path}[${index}]`)); + if (body === null || typeof body !== 'object') return []; + return Object.entries(body).flatMap(([key, value]) => [ + ...(names.includes(key) ? [`${path}.${key}`] : []), + ...keysNamed(value, names, `${path}.${key}`), + ]); +} + +interface Case { + route: string; + /** The secret-bearing tables the response is built from. */ + tables: SecretColumnTable[]; + send: () => Promise<{ status: number; body: unknown }>; + status: number; + /** Values the body must contain, so a response that left the rows out cannot pass. */ + shows: () => string[]; +} + +/** + * Response schemas omit every `secretColumns` entry (db/utils/drizzle-schema.ts), but nothing holds a response to its + * schema at runtime, and a spread row type-checks with its secret still on it. These read the raw bodies the routes send + * and look in them for every value stored in a secret column, and for the secret column names of the tables they read. + */ +describe('Secret columns in responses', async () => { + const call = await createAppClient(); + let organization: { id: string; tenantId: string }; + let admin: { id: string; sessionCookie: string }; + let invitee: { sessionCookie: string; otherSessionId: string; inactiveMembershipId: string; tokenId: string; invitationCookie: string }; + let serviceAccountId: string; + let apiKeyId: string; + let rolledKeyId: string; + + const serviceAccounts = () => `/${organization.tenantId}/${organization.id}/service-accounts`; + + /** A raw request as the admin that hands its body to `keep`, which stores the ids a later case needs. */ + const sendAndKeep = async (path: string, init: { method: string; body?: unknown }, keep: (body: Record) => void) => { + const result = await rawJsonRequest(path, admin.sessionCookie, init); + if (result.status < 300) keep(result.body as Record); + return result; + }; + + beforeAll(async () => { + organization = await createTestOrganization(); + admin = await createOrgUser(call, organization.tenantId, organization.id, 'secrets-admin', adminRole); + + // The invitee holds the TOTP: on the admin it would make key creation ask for a step-up. + const inviteeUser = await createTestUser('secrets-invitee@security-test.com'); + await db.insert(totpsTable).values({ userId: inviteeUser.id, secret: encryptTotpSecret(testTotpSecret), createdAt: mockPastIsoDate() }); + const invitation = await createInvitation({ + organization, + email: inviteeUser.email, + createdBy: admin.id, + boundTo: inviteeUser.id, + token: 'invoked', + }); + invitee = { + sessionCookie: await createTestSession(inviteeUser), + otherSessionId: (await insertTestSession(inviteeUser)).id, + inactiveMembershipId: invitation.inactiveMembership.id, + tokenId: invitation.token.id, + invitationCookie: invitation.invitationCookie, + }; + }); + + afterAll(async () => await clearSecurityTestData()); + + // In order: later cases revoke or roll what earlier ones listed and created. + const cases: Case[] = [ + { + route: 'GET /me/auth', + tables: ['sessions', 'totps'], + send: () => rawJsonRequest('/me/auth', invitee.sessionCookie), + status: 200, + shows: () => [invitee.otherSessionId], + }, + { + route: 'DELETE /me/sessions', + tables: ['sessions'], + send: () => rawJsonRequest('/me/sessions', invitee.sessionCookie, { method: 'DELETE', body: { ids: [invitee.otherSessionId] } }), + status: 200, + shows: () => [invitee.otherSessionId], + }, + { + route: 'POST service-accounts', + tables: ['api_keys'], + send: () => + sendAndKeep( + serviceAccounts(), + { method: 'POST', body: { name: 'CI bot', role: memberRole, key: { name: 'deploy', scopes: null } } }, + (body) => { + serviceAccountId = (body.serviceAccount as { id: string }).id; + apiKeyId = (body.apiKey as { id: string }).id; + }, + ), + status: 201, + shows: () => [serviceAccountId, apiKeyId], + }, + { + route: 'GET service-accounts/{id}/keys', + tables: ['api_keys'], + send: () => rawJsonRequest(`${serviceAccounts()}/${serviceAccountId}/keys`, admin.sessionCookie), + status: 200, + shows: () => [apiKeyId], + }, + { + route: 'POST service-accounts/{id}/keys', + tables: ['api_keys'], + send: () => + sendAndKeep(`${serviceAccounts()}/${serviceAccountId}/keys`, { method: 'POST', body: { name: 'rolled', rollFrom: apiKeyId } }, (body) => { + rolledKeyId = body.id as string; + }), + status: 201, + shows: () => [rolledKeyId], + }, + { + route: 'DELETE service-accounts/{id}/keys/{keyId}', + tables: ['api_keys'], + send: () => rawJsonRequest(`${serviceAccounts()}/${serviceAccountId}/keys/${apiKeyId}`, admin.sessionCookie, { method: 'DELETE' }), + status: 200, + shows: () => [apiKeyId], + }, + { + route: 'GET /me/invitations', + tables: ['tokens'], + send: () => rawJsonRequest('/me/invitations', invitee.sessionCookie), + status: 200, + shows: () => [invitee.inactiveMembershipId], + }, + { + route: 'GET /auth/token/invitation/{id}', + tables: ['tokens'], + send: () => rawJsonRequest(`/auth/token/invitation/${invitee.tokenId}`, invitee.invitationCookie), + status: 200, + shows: () => [invitee.inactiveMembershipId], + }, + { + route: 'POST /auth/passkey/generate-challenge', + tables: ['passkey_challenges'], + send: () => rawJsonRequest('/auth/passkey/generate-challenge', '', { method: 'POST', body: { type: 'authentication' } }), + status: 200, + shows: () => [], + }, + ]; + + it.each(cases)('must not send a stored secret via $route', async ({ send, status, tables, shows }) => { + const { status: actual, body } = await send(); + expect(actual, JSON.stringify(body)).toBe(status); + + // Positive controls: the rows are in the body, and the tables behind them hold secrets to leak. + const text = JSON.stringify(body); + for (const value of shows()) expect(text).toContain(value); + const stored = await storedSecrets(); + for (const table of tables) expect(stored[table].length, `${table} holds no secret`).toBeGreaterThan(0); + + expect( + Object.values(stored) + .flat() + .filter((secret) => text.includes(secret)), + ).toEqual([]); + expect( + keysNamed( + body, + tables.flatMap((table) => secretColumns[table]), + ), + ).toEqual([]); + }); +}); diff --git a/backend/tests/security/route-guards.test.ts b/backend/tests/security/route-guards.test.ts index f0bc530d4..f936d4483 100644 --- a/backend/tests/security/route-guards.test.ts +++ b/backend/tests/security/route-guards.test.ts @@ -1,38 +1,29 @@ import type { OpenAPIHono } from '@hono/zod-openapi'; import { getMe } from 'sdk'; -import { appConfig } from 'shared'; +import { type ConfigSwitch, isSwitchOn } from 'shared'; import { generateId } from 'shared/utils/entity-id'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { Env } from '#/core/context'; import { defaultHeaders } from '../fixtures'; -import { - createSystemAdminUser, - createTestOrganization, - createTestSession, - createTestUser, - expectRefusal, -} from '../helpers'; +import { createSystemAdminUser, createTestOrganization, createTestSession, createTestUser, expectRefusal } from '../helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData } from './helpers'; -// Every sign-in method on, so a route's strategy gate lets the request through to the guard under test. -setTestConfig({ - enabledAuthStrategies: ['passkey', 'totp', 'oauth', 'magic'], - enabledOAuthProviders: ['github', 'google', 'microsoft'], -}); +// Every sign-in method on, so a route's config switch lets the request through to the guard under test. +setTestConfig({ enabledAuthStrategies: ['passkey', 'totp', 'oauth', 'magic'], enabledOAuthProviders: ['github', 'google', 'microsoft'] }); interface Operation { operationId: string; method: string; path: string; guards: string[]; - service?: string; + enabledBy?: ConfigSwitch; } const httpMethods = ['get', 'post', 'put', 'patch', 'delete'] as const; -/** Every operation of the API with the guard chain it declares (`x-guard`), from the app's own OpenAPI document. */ +/** Every operation of the API with its guard chain (`x-guard`) and config switch (`x-enabled-by`), from the app's own OpenAPI document. */ const operationsOf = (app: OpenAPIHono): Operation[] => { const { paths = {} } = app.getOpenAPI31Document({ openapi: '3.1.0', info: { title: 'guards', version: '0' } }); return Object.entries(paths).flatMap(([path, item]) => @@ -45,16 +36,15 @@ const operationsOf = (app: OpenAPIHono): Operation[] => { method: method.toUpperCase(), path, guards: (operation['x-guard'] as string[] | undefined) ?? [], - service: operation['x-service'] as string | undefined, + enabledBy: operation['x-enabled-by'] as ConfigSwitch | undefined, }, ]; }), ); }; -/** A route of a disabled service answers 404 before any guard runs. */ -const serviceEnabled = ({ service }: Operation) => - !service || appConfig.services[service as keyof typeof appConfig.services]?.enabled !== false; +/** A route whose config switch is off is refused before any guard runs (`x-enabled-by`). */ +const switchIsOn = ({ enabledBy }: Operation) => !enabledBy || isSwitchOn(enabledBy); /** * Function-level access follows the guard chain a route declares, so the table is the API itself: every route without @@ -64,7 +54,7 @@ const serviceEnabled = ({ service }: Operation) => describe('Route guards', async () => { const call = await createAppClient(); const { baseApp } = await import('#/routes'); - const operations = operationsOf(baseApp).filter(serviceEnabled); + const operations = operationsOf(baseApp).filter(switchIsOn); const nonPublic = operations.filter(({ guards }) => !guards.includes('publicGuard')); const sysAdminOnly = operations.filter(({ guards }) => guards.includes('sysAdminGuard')); @@ -92,9 +82,7 @@ describe('Route guards', async () => { const organization = await createTestOrganization(); tenant = { id: organization.tenantId, organizationId: organization.id }; user = { sessionCookie: await createTestSession(await createTestUser('route-guards-user@security-test.com')) }; - sysAdmin = { - sessionCookie: await createTestSession(await createSystemAdminUser('route-guards-sysadmin@security-test.com')), - }; + sysAdmin = { sessionCookie: await createTestSession(await createSystemAdminUser('route-guards-sysadmin@security-test.com')) }; }); afterAll(async () => await clearSecurityTestData()); @@ -112,9 +100,7 @@ describe('Route guards', async () => { expect(status, nameOf(operation)).toBe(401); } // Positive control: a session reaches a route behind userGuard. - expect((await call(getMe, { headers: { ...defaultHeaders, Cookie: user.sessionCookie } })).response.status).toBe( - 200, - ); + expect((await call(getMe, { headers: { ...defaultHeaders, Cookie: user.sessionCookie } })).response.status).toBe(200); }); it('must not reach any system-admin route via a session without the system role', async () => { diff --git a/backend/tests/security/session-endings.test.ts b/backend/tests/security/session-endings.test.ts index e165f0215..cd2061931 100644 --- a/backend/tests/security/session-endings.test.ts +++ b/backend/tests/security/session-endings.test.ts @@ -1,13 +1,5 @@ import { eq } from 'drizzle-orm'; -import { - deleteMe, - deleteUsers, - revokeMySessions, - signOut, - startImpersonation, - stopImpersonation, - toggleMfa, -} from 'sdk'; +import { deleteMe, deleteUsers, revokeMySessions, signOut, startImpersonation, stopImpersonation, toggleMfa } from 'sdk'; import { appConfig } from 'shared'; import { nanoid } from 'shared/utils/nanoid'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; @@ -192,10 +184,7 @@ describe('Ending a session closes its stream and its cached entry', async () => const elsewhere = await signIn(user, nanoid(24)); await warmSession(earlier); await warmSession(elsewhere); - const [earlierStream, elsewhereStream] = [ - await openStream(user.id, earlier), - await openStream(user.id, elsewhere), - ]; + const [earlierStream, elsewhereStream] = [await openStream(user.id, earlier), await openStream(user.id, elsewhere)]; const later = await signIn(user, deviceId); @@ -216,10 +205,7 @@ describe('Ending a session closes its stream and its cached entry', async () => const adminSession = await insertSession(admin); const targetOwn = await insertSession(target); - const started = await call(startImpersonation, { - body: { targetUserId: target.id }, - headers: adminSession.headers, - }); + const started = await call(startImpersonation, { body: { targetUserId: target.id }, headers: adminSession.headers }); expect(started.response.status).toBe(204); const impersonation = await impersonationSetBy(started.response, adminSession); await warmSession(impersonation); @@ -268,12 +254,7 @@ describe('Ending a session closes its stream and its cached entry', async () => it('keeps the sessions of an account whose deletion was refused (positive control)', async () => { const org = await createTestOrganization(); // The only admin of an organization: the database refuses to delete the account. - const soleAdmin = await createOrganizationAdminUser( - 'sole-admin@security-test.com', - org.id, - adminRole, - org.tenantId, - ); + const soleAdmin = await createOrganizationAdminUser('sole-admin@security-test.com', org.id, adminRole, org.tenantId); const session = await insertSession(soleAdmin); await warmSession(session); const stream = await openStream(soleAdmin.id, session); diff --git a/backend/tests/security/session-helpers.ts b/backend/tests/security/session-helpers.ts index 4ba7ba8ef..93012639f 100644 --- a/backend/tests/security/session-helpers.ts +++ b/backend/tests/security/session-helpers.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm'; import { getMe, invokeToken, sendStepUpLink } from 'sdk'; +import { appConfig } from 'shared'; import type { SessionLostType } from 'shared/utils/session-lost'; import { expect, vi } from 'vitest'; import { baseDb as db } from '#/db/db'; @@ -19,11 +20,7 @@ export interface TestSession { headers: Record; } -export const asSession = (id: string, cookie: string): TestSession => ({ - id, - cookie, - headers: { ...defaultHeaders, Cookie: cookie }, -}); +export const asSession = (id: string, cookie: string): TestSession => ({ id, cookie, headers: { ...defaultHeaders, Cookie: cookie } }); /** A live session row and the signed cookie that presents it; the options are `insertTestSession`'s. */ export async function insertSession( @@ -49,17 +46,16 @@ export async function askStepUpLink(session: TestSession, redirect?: string) { const call = await createAppClient(); const asked = await call(sendStepUpLink, { body: redirect ? { redirect } : {}, headers: session.headers }); expect(asked.response.status).toBe(204); - return { browser: cookiesAfter(session.cookie, asked.response), rawToken: mailedLink('stepUpUrl').token }; + const { url, token: rawToken } = mailedLink('stepUpUrl'); + expect(url).toBe(`${appConfig.backendAuthUrl}/invoke-token/step-up/${rawToken}`); + return { browser: cookiesAfter(session.cookie, asked.response), rawToken }; } /** A click on the mailed step-up link: the mail app starts the navigation, so the Strict session cookie stays home. */ export async function openStepUpLink(rawToken: string, browser: string) { const call = await createAppClient(); const marker = browser.split('; ').filter((pair) => pair.startsWith(`${authCookieName('step-up-requested')}=`)); - return call(invokeToken, { - path: { type: 'step-up', token: rawToken }, - headers: { ...defaultHeaders, Cookie: marker.join('; ') }, - }); + return call(invokeToken, { path: { type: 'step-up', token: rawToken }, headers: { ...defaultHeaders, Cookie: marker.join('; ') } }); } /** A step-up through the emailed link, opened in the browser that asked; returns that browser's session. */ @@ -70,16 +66,13 @@ export async function stepUpByEmail(session: TestSession) { } /** GET /me from a browser holding `cookie`. */ -const meWith = async (cookie: string) => - (await createAppClient())(getMe, { headers: { ...defaultHeaders, Cookie: cookie } }); +const meWith = async (cookie: string) => (await createAppClient())(getMe, { headers: { ...defaultHeaders, Cookie: cookie } }); /** Warms the auth cache for a session: the next request hits the cached entry, not the database. */ -export const warmSession = async ({ cookie }: { cookie: string }) => - expect((await meWith(cookie)).response.status).toBe(200); +export const warmSession = async ({ cookie }: { cookie: string }) => expect((await meWith(cookie)).response.status).toBe(200); /** A browser holding `cookie` is refused as signed out, with this error type, one the client signs out on. */ -export const expectSignedOut = async (cookie: string, type: SessionLostType) => - expectRefusal(await meWith(cookie), 401, type); +export const expectSignedOut = async (cookie: string, type: SessionLostType) => expectRefusal(await meWith(cookie), 401, type); /** An impersonation of `target` layered on an admin's session, presented as the admin's browser does. */ export async function insertImpersonation(admin: TestSession, target: { id: string }): Promise { diff --git a/backend/tests/security/session-model.test.ts b/backend/tests/security/session-model.test.ts index caa0e74d5..4bd871ffc 100644 --- a/backend/tests/security/session-model.test.ts +++ b/backend/tests/security/session-model.test.ts @@ -117,10 +117,7 @@ describe('session model', async () => { const adminSession = await insertSession(admin); const target = await createTestUser('impersonated@security-test.com'); - const started = await call(startImpersonation, { - body: { targetUserId: target.id }, - headers: adminSession.headers, - }); + const started = await call(startImpersonation, { body: { targetUserId: target.id }, headers: adminSession.headers }); expect(started.response.status).toBe(204); const [impersonation] = await db .select() @@ -142,10 +139,7 @@ describe('session model', async () => { expect(((await me(browser)).data as { user: { id: string } }).user.id).toBe(target.id); const genuine = await call(stopImpersonation, { headers: { ...defaultHeaders, Cookie: browser } }); expect(genuine.response.status).toBe(204); - expect(await sessionRow(impersonation.id)).toMatchObject({ - revocationReason: 'impersonation_stopped', - revokedBy: admin.id, - }); + expect(await sessionRow(impersonation.id)).toMatchObject({ revocationReason: 'impersonation_stopped', revokedBy: admin.id }); const back = await me(cookiesAfter(browser, genuine.response)); expect((back.data as { user: { id: string } }).user.id).toBe(admin.id); await warmSession(victimSession); diff --git a/backend/tests/security/session-sweep.test.ts b/backend/tests/security/session-sweep.test.ts index 20e0d93ee..620142c1f 100644 --- a/backend/tests/security/session-sweep.test.ts +++ b/backend/tests/security/session-sweep.test.ts @@ -82,10 +82,7 @@ describe('The stream sweep closes streams whose session no longer holds', () => }); it('must not keep streaming to a user deleted outside the API via their open stream', async () => { - const [user, bystander] = [ - await createTestUser('removed@security-test.com'), - await createTestUser('bystander@security-test.com'), - ]; + const [user, bystander] = [await createTestUser('removed@security-test.com'), await createTestUser('bystander@security-test.com')]; const [removed, kept] = [await insertSession(user), await insertSession(bystander)]; const removedStream = await openStream(user.id, removed); const keptStream = await openStream(bystander.id, kept); @@ -100,10 +97,7 @@ describe('The stream sweep closes streams whose session no longer holds', () => }); it('must not keep system-admin reads on a stream via a system role that was removed', async () => { - const [demoted, admin] = [ - await createSystemAdminUser('demoted@security-test.com'), - await createSystemAdminUser('still-admin@security-test.com'), - ]; + const [demoted, admin] = [await createSystemAdminUser('demoted@security-test.com'), await createSystemAdminUser('still-admin@security-test.com')]; const [demotedSession, adminSession] = [await insertSession(demoted), await insertSession(admin)]; const demotedStream = await openStream(demoted.id, demotedSession); const adminStream = await openStream(admin.id, adminSession); @@ -119,10 +113,7 @@ describe('The stream sweep closes streams whose session no longer holds', () => }); it('tells a stream to reconnect once its user gains the system role, so it gets system-admin reads', async () => { - const [promoted, regular] = [ - await createTestUser('promoted@security-test.com'), - await createTestUser('regular@security-test.com'), - ]; + const [promoted, regular] = [await createTestUser('promoted@security-test.com'), await createTestUser('regular@security-test.com')]; const [promotedSession, regularSession] = [await insertSession(promoted), await insertSession(regular)]; const promotedStream = await openStream(promoted.id, promotedSession); const regularStream = await openStream(regular.id, regularSession); @@ -143,10 +134,7 @@ describe('The stream sweep closes streams whose session no longer holds', () => const admin = await createSystemAdminUser('remote-admin@security-test.com'); const [adminSession, revoked] = [await insertSession(admin), await insertSession(admin)]; - const fromElsewhere = (session: TestSession) => ({ - ...session, - headers: { ...session.headers, 'x-forwarded-for': '10.0.0.2' }, - }); + const fromElsewhere = (session: TestSession) => ({ ...session, headers: { ...session.headers, 'x-forwarded-for': '10.0.0.2' } }); const adminStream = await openStream(admin.id, fromElsewhere(adminSession)); const revokedStream = await openStream(admin.id, fromElsewhere(revoked)); @@ -159,10 +147,7 @@ describe('The stream sweep closes streams whose session no longer holds', () => }); it('must not stall the sweep of every stream via a client that stopped reading', async () => { - const [stalled, other] = [ - await createTestUser('stalled@security-test.com'), - await createTestUser('other@security-test.com'), - ]; + const [stalled, other] = [await createTestUser('stalled@security-test.com'), await createTestUser('other@security-test.com')]; const [stalledSession, otherSession] = [await insertSession(stalled), await insertSession(other)]; // Opened first, so the sweep meets it first. const stalledStream = await openUnreadStream(stalled.id, stalledSession); @@ -226,11 +211,7 @@ describe('The stream sweep closes streams whose session no longer holds', () => await createSystemAdminUser('impersonator@security-test.com'), ]; const target = await createTestUser('impersonated@security-test.com'); - const [demotedSession, expiringSession, adminSession] = [ - await insertSession(demoted), - await insertSession(expiring), - await insertSession(admin), - ]; + const [demotedSession, expiringSession, adminSession] = [await insertSession(demoted), await insertSession(expiring), await insertSession(admin)]; const demotedStream = await openStream(target.id, await insertImpersonation(demotedSession, target)); const expiringStream = await openStream(target.id, await insertImpersonation(expiringSession, target)); const keptStream = await openStream(target.id, await insertImpersonation(adminSession, target)); diff --git a/backend/tests/security/sign-out-magic-link.test.ts b/backend/tests/security/sign-out-magic-link.test.ts index 671185a75..f45c66a8e 100644 --- a/backend/tests/security/sign-out-magic-link.test.ts +++ b/backend/tests/security/sign-out-magic-link.test.ts @@ -4,15 +4,7 @@ import { nanoid } from 'shared/utils/nanoid'; import { afterEach, describe, expect, it } from 'vitest'; import { authCookieName } from '#/modules/auth/general/helpers/cookie'; import { defaultHeaders } from '../fixtures'; -import { - authCookie, - cookieChange, - cookiesAfter, - createTestUser, - expectRefusal, - insertTestToken, - tokenRow, -} from '../helpers'; +import { authCookie, cookieChange, cookiesAfter, createTestUser, expectRefusal, insertTestToken, tokenRow } from '../helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData } from './helpers'; @@ -103,9 +95,7 @@ describe('Sign-out after a magic-link sign-in', async () => { // The owner signs in another way in this browser, and later signs out. const session = await insertSession(owner); - const signedOut = await call(signOut, { - headers: { ...defaultHeaders, Cookie: `${heldCookie}; ${session.cookie}` }, - }); + const signedOut = await call(signOut, { headers: { ...defaultHeaders, Cookie: `${heldCookie}; ${session.cookie}` } }); expect(signedOut.response.status).toBe(204); expect(cookieChange(signedOut.response, 'magic-pending')).toBe('cleared'); expect(await tokenRow(row.id)).toBeUndefined(); @@ -127,14 +117,8 @@ describe('Sign-out after a magic-link sign-in', async () => { const ownerLink = await requestedMagicLink(owner); const otherLink = await requestedMagicLink(other); - const ownerBrowser = cookiesAfter( - ownerLink.requestedHere, - (await openLink(ownerLink.raw, ownerLink.requestedHere)).response, - ); - const otherBrowser = cookiesAfter( - otherLink.requestedHere, - (await openLink(otherLink.raw, otherLink.requestedHere)).response, - ); + const ownerBrowser = cookiesAfter(ownerLink.requestedHere, (await openLink(ownerLink.raw, ownerLink.requestedHere)).response); + const otherBrowser = cookiesAfter(otherLink.requestedHere, (await openLink(otherLink.raw, otherLink.requestedHere)).response); expect((await call(signOut, { headers: { ...defaultHeaders, Cookie: ownerBrowser } })).response.status).toBe(204); diff --git a/backend/tests/security/sign-out-mfa-challenge.test.ts b/backend/tests/security/sign-out-mfa-challenge.test.ts index e0896ae82..a09655e62 100644 --- a/backend/tests/security/sign-out-mfa-challenge.test.ts +++ b/backend/tests/security/sign-out-mfa-challenge.test.ts @@ -1,16 +1,7 @@ import { getMe, signInWithTotp, signOut } from 'sdk'; import { afterEach, describe, expect, it } from 'vitest'; import { defaultHeaders } from '../fixtures'; -import { - authCookie, - createMfaToken, - createTestSession, - createTotpUser, - expectRefusal, - sessionsOf, - tokenRowOf, - totpCode, -} from '../helpers'; +import { authCookie, createMfaToken, createTestSession, createTotpUser, expectRefusal, sessionsOf, tokenRowOf, totpCode } from '../helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData } from './helpers'; @@ -34,9 +25,7 @@ describe('Sign-out with a pending MFA challenge', async () => { // Cache the session first, so the refusal below also proves the cache entry was dropped. expect((await call(getMe, { headers: sessionHeaders })).response.status).toBe(200); - const { response } = await call(signOut, { - headers: { ...defaultHeaders, Cookie: `${sessionCookie}; ${mfaCookie}` }, - }); + const { response } = await call(signOut, { headers: { ...defaultHeaders, Cookie: `${sessionCookie}; ${mfaCookie}` } }); expect(response.status).toBe(204); const afterwards = await call(getMe, { headers: sessionHeaders }); @@ -44,17 +33,12 @@ describe('Sign-out with a pending MFA challenge', async () => { // The challenge is spent: its row is gone, and even the right code no longer completes it. expect(await tokenRowOf('confirm-mfa', mfaToken)).toBeUndefined(); - const completed = await call(signInWithTotp, { - body: { code: totpCode() }, - headers: { ...defaultHeaders, Cookie: mfaCookie }, - }); + const completed = await call(signInWithTotp, { body: { code: totpCode() }, headers: { ...defaultHeaders, Cookie: mfaCookie } }); await expectRefusal(completed, 401, 'confirm-mfa_not_found'); // Only this browser signed out: exactly one session is revoked, and the user's other session still works. const sessions = await sessionsOf(user.id); - expect(sessions.filter((session) => session.revokedAt)).toEqual([ - expect.objectContaining({ revokedBy: user.id, revocationReason: 'sign_out' }), - ]); + expect(sessions.filter((session) => session.revokedAt)).toEqual([expect.objectContaining({ revokedBy: user.id, revocationReason: 'sign_out' })]); const other = await call(getMe, { headers: { ...defaultHeaders, Cookie: otherSessionCookie } }); expect(other.response.status).toBe(200); }); @@ -63,9 +47,7 @@ describe('Sign-out with a pending MFA challenge', async () => { const user = await createTotpUser('owner@security-test.com'); const mfaToken = await createMfaToken(user); - const { response } = await call(signOut, { - headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) }, - }); + const { response } = await call(signOut, { headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) } }); expect(response.status).toBe(204); expect(await tokenRowOf('confirm-mfa', mfaToken)).toBeUndefined(); diff --git a/backend/tests/security/step-up-db-failure.test.ts b/backend/tests/security/step-up-db-failure.test.ts index 4858281fd..cc805348e 100644 --- a/backend/tests/security/step-up-db-failure.test.ts +++ b/backend/tests/security/step-up-db-failure.test.ts @@ -9,8 +9,8 @@ import { insertStaleSession } from './session-helpers'; /** The error the next TOTP check throws, as the database driver would; null checks the code as usual. */ const nextCheck = vi.hoisted(() => ({ failure: null as Error | null })); -vi.mock('#/modules/auth/totps/helpers/totps', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('#/modules/auth/totps/operations/verify-totp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, verifyTotp: (...args: Parameters) => { diff --git a/backend/tests/security/step-up-routes.test.ts b/backend/tests/security/step-up-routes.test.ts index 7e2babd79..74cded137 100644 --- a/backend/tests/security/step-up-routes.test.ts +++ b/backend/tests/security/step-up-routes.test.ts @@ -36,14 +36,7 @@ import { softwarePasskey } from '../software-passkey'; import { createAppClient, type TestResult } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData, createOrgUser, insertPasskey, passkeyChallenge, passkeysOf } from './helpers'; -import { - asSession, - insertImpersonation, - insertSession, - insertStaleSession, - stepUpByEmail, - type TestSession, -} from './session-helpers'; +import { asSession, insertImpersonation, insertSession, insertStaleSession, stepUpByEmail, type TestSession } from './session-helpers'; setTestConfig({ enabledAuthStrategies: ['passkey', 'totp', 'oauth', 'magic'], enabledOAuthProviders: ['github'] }); @@ -67,9 +60,7 @@ describe('account-security routes need a step-up', async () => { }; const stepUpWithTotp = async (session: TestSession) => - expect((await call(stepUp, { body: { totpCode: totpCode() }, headers: session.headers })).response.status).toBe( - 204, - ); + expect((await call(stepUp, { body: { totpCode: totpCode() }, headers: session.headers })).response.status).toBe(204); it('must not add a passkey via a stale session', async () => { const user = await totpHolder('add-passkey'); @@ -102,9 +93,7 @@ describe('account-security routes need a step-up', async () => { expect(await passkeysOf(user.id)).toHaveLength(1); await stepUpWithTotp(session); - expect((await call(deletePasskey, { path: { id: passkey.id }, headers: session.headers })).response.status).toBe( - 204, - ); + expect((await call(deletePasskey, { path: { id: passkey.id }, headers: session.headers })).response.status).toBe(204); expect(await passkeysOf(user.id)).toHaveLength(0); }); @@ -152,9 +141,7 @@ describe('account-security routes need a step-up', async () => { expect(await mfaOf()).toBe(false); await stepUpWithTotp(session); - expect((await call(toggleMfa, { body: { mfaRequired: true }, headers: session.headers })).response.status).toBe( - 200, - ); + expect((await call(toggleMfa, { body: { mfaRequired: true }, headers: session.headers })).response.status).toBe(200); expect(await mfaOf()).toBe(true); }); @@ -259,10 +246,7 @@ describe('account-security routes need a step-up', async () => { const admin = await createSystemAdminUser('key-impersonator@security-test.com'); const impersonation = await insertImpersonation(await insertSession(admin), minting.admin); - for (const attempt of [ - await minting.createAccount(impersonation), - await minting.createKey(impersonation, accountId), - ]) { + for (const attempt of [await minting.createAccount(impersonation), await minting.createKey(impersonation, accountId)]) { await expectRefusal(attempt, 403, 'impersonation_forbidden'); } expect(await minting.accounts()).toHaveLength(1); diff --git a/backend/tests/security/step-up.test.ts b/backend/tests/security/step-up.test.ts index 9730317c0..7a7daa467 100644 --- a/backend/tests/security/step-up.test.ts +++ b/backend/tests/security/step-up.test.ts @@ -21,14 +21,7 @@ import { import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData, insertPasskey, issuedChallenge, passkeyChallenge } from './helpers'; -import { - askStepUpLink, - insertImpersonation, - insertSession, - insertStaleSession, - openStepUpLink, - type TestSession, -} from './session-helpers'; +import { askStepUpLink, insertImpersonation, insertSession, insertStaleSession, openStepUpLink, type TestSession } from './session-helpers'; setTestConfig({ enabledAuthStrategies: ['passkey', 'totp', 'magic'] }); @@ -84,9 +77,7 @@ describe('step-up', async () => { await expectRefusal({ response, error }, 401, 'invalid_token'); expect((await sessionRow(session.id)).steppedUpAt).toBeNull(); - expect((await call(stepUp, { body: { totpCode: totpCode() }, headers: session.headers })).response.status).toBe( - 204, - ); + expect((await call(stepUp, { body: { totpCode: totpCode() }, headers: session.headers })).response.status).toBe(204); expect(await sessionRow(session.id)).toMatchObject({ steppedUpVia: 'totp' }); expect(await stateOf(session)).toEqual({ steppedUp: true, methods: ['totp'] }); }); @@ -141,10 +132,7 @@ describe('step-up', async () => { const otherBrowser = await insertStaleSession(user); const { browser, rawToken } = await askStepUpLink(asking, '/account'); - const elsewhere = await call(invokeToken, { - path: { type: 'step-up', token: rawToken }, - headers: otherBrowser.headers, - }); + const elsewhere = await call(invokeToken, { path: { type: 'step-up', token: rawToken }, headers: otherBrowser.headers }); await expectRefusal(elsewhere, 403, 'step_up_other_browser'); expect((await sessionRow(asking.id)).steppedUpAt).toBeNull(); expect((await sessionRow(otherBrowser.id)).steppedUpAt).toBeNull(); diff --git a/backend/tests/security/storage-keys.test.ts b/backend/tests/security/storage-keys.test.ts index 6eea74ed9..7ce540dc0 100644 --- a/backend/tests/security/storage-keys.test.ts +++ b/backend/tests/security/storage-keys.test.ts @@ -29,11 +29,8 @@ describe('Attachment storage keys', async () => { let attackerPlan: TestEntityHierarchyPlan; /** A create body in the attacker's organization; `claims` are storage fields the client may send, the server decides. */ - const bodyFor = ( - id: string, - keys: { original: string; preview?: string }, - claims: { bucketName?: string; publicBucket?: boolean } = {}, - ) => attachmentBody(id, attackerPlan, { keys, ...claims }); + const bodyFor = (id: string, keys: { original: string; preview?: string }, claims: { bucketName?: string; publicBucket?: boolean } = {}) => + attachmentBody(id, attackerPlan, { keys, ...claims }); const create = (body: Record) => call(createAttachments, { @@ -50,8 +47,7 @@ describe('Attachment storage keys', async () => { }); const rowExists = async (id: string) => - (await adminDb.select({ id: attachmentsTable.id }).from(attachmentsTable).where(eq(attachmentsTable.id, id))) - .length > 0; + (await adminDb.select({ id: attachmentsTable.id }).from(attachmentsTable).where(eq(attachmentsTable.id, id))).length > 0; const storageOf = async (id: string) => ( @@ -66,10 +62,7 @@ describe('Attachment storage keys', async () => { beforeAll(async () => { victim = await createTestTenant(call, 'storage-victim'); attacker = await createTestTenant(call, 'storage-attacker'); - attackerPlan = await seedAttachmentHome( - { id: attacker.organization.id, tenantId: attacker.tenantId }, - attacker.user.id, - ); + attackerPlan = await seedAttachmentHome({ id: attacker.organization.id, tenantId: attacker.tenantId }, attacker.user.id); }); afterAll(async () => await clearSecurityTestData()); @@ -83,9 +76,7 @@ describe('Attachment storage keys', async () => { it('must not reach outside the prefix via dot segments or a variant key', async () => { const traversal = generateId(); - const dots = await create( - bodyFor(traversal, { original: `${attacker.organization.id}/../${keyOf(victim, 'x.pdf')}` }), - ); + const dots = await create(bodyFor(traversal, { original: `${attacker.organization.id}/../${keyOf(victim, 'x.pdf')}` })); expect(dots.response.status).toBe(400); const variant = generateId(); @@ -111,10 +102,7 @@ describe('Attachment storage keys', async () => { }); it('must not sign a blob: key via getPresignedUrls', async () => { - for (const planted of [ - `blob:/../${keyOf(victim, 'contract.pdf')}`, - 'blob:http://localhost:3000/0199a1b2-c3d4-7e5f-8a6b-7c8d9e0f1a2c', - ]) { + for (const planted of [`blob:/../${keyOf(victim, 'contract.pdf')}`, 'blob:http://localhost:3000/0199a1b2-c3d4-7e5f-8a6b-7c8d9e0f1a2c']) { const id = generateId(); expect((await create(bodyFor(id, { original: keyOf(attacker, 'own.pdf') }))).response.status).toBe(201); await adminDb diff --git a/backend/tests/security/tenant-access.test.ts b/backend/tests/security/tenant-access.test.ts index 02a03a580..6efcd31cb 100644 --- a/backend/tests/security/tenant-access.test.ts +++ b/backend/tests/security/tenant-access.test.ts @@ -9,14 +9,7 @@ import { organizationsTable } from '#/modules/organization/organization-db'; import { mockOrganization } from '#/modules/organization/organization-mocks'; import { tenantsTable } from '#/modules/tenants/tenants-db'; import { defaultHeaders, memberRole } from '../fixtures'; -import { - createTestOrganization, - createTestSession, - createTestUser, - type ErrorResponse, - expectRefusal, - refusalOf, -} from '../helpers'; +import { createTestOrganization, createTestSession, createTestUser, type ErrorResponse, expectRefusal, refusalOf } from '../helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; import { clearSecurityTestData, createOrgUser, createTestTenant } from './helpers'; @@ -38,10 +31,7 @@ describe('Tenant access', async () => { headers: { ...defaultHeaders, Cookie: sessionCookie }, body: JSON.stringify({ slug: 'tenant-access-free-slug', entityType: 'organization' }), }); - return { - status: response.status, - error: response.status === 204 ? null : ((await response.json()) as ErrorResponse), - }; + return { status: response.status, error: response.status === 204 ? null : ((await response.json()) as ErrorResponse) }; }; const setStatus = async (tenantId: string, status: 'active' | 'suspended') => { @@ -69,9 +59,7 @@ describe('Tenant access', async () => { } const answers = await Promise.all( - [missingTenantId, inactive.tenantId, foreign.tenantId].map((tenantId) => - checkSlug(tenantId, outsider.sessionCookie), - ), + [missingTenantId, inactive.tenantId, foreign.tenantId].map((tenantId) => checkSlug(tenantId, outsider.sessionCookie)), ); for (const { status, error } of answers) await expectRefusal({ status, body: error }, 403, 'forbidden'); const [missing, inactiveTenant, foreignTenant] = answers.map(({ error }) => refusalOf(error as ErrorResponse)); @@ -81,13 +69,7 @@ describe('Tenant access', async () => { it('tells a member that their tenant is inactive, and admits them while it is active (positive control)', async () => { const organization = await createTestOrganization(); - const member = await createOrgUser( - call, - organization.tenantId, - organization.id, - 'tenant-access-member', - memberRole, - ); + const member = await createOrgUser(call, organization.tenantId, organization.id, 'tenant-access-member', memberRole); expect((await checkSlug(organization.tenantId, member.sessionCookie)).status).toBe(204); await setStatus(organization.tenantId, 'suspended'); diff --git a/backend/tests/security/totp-replay.test.ts b/backend/tests/security/totp-replay.test.ts index 417de6988..3cc025c1c 100644 --- a/backend/tests/security/totp-replay.test.ts +++ b/backend/tests/security/totp-replay.test.ts @@ -36,10 +36,7 @@ describe('TOTP replay', async () => { /** A second-factor challenge of its own, as each sign-in gets one, answered with `code`. */ const answerChallenge = async (user: { id: string; email: string }, code: string) => { const mfaToken = await createMfaToken(user); - const result = await call(signInWithTotp, { - body: { code }, - headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) }, - }); + const result = await call(signInWithTotp, { body: { code }, headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) } }); return { ...result, mfaToken }; }; diff --git a/backend/tests/security/trace-redaction.test.ts b/backend/tests/security/trace-redaction.test.ts index 2df9646f0..d477f67c4 100644 --- a/backend/tests/security/trace-redaction.test.ts +++ b/backend/tests/security/trace-redaction.test.ts @@ -37,11 +37,7 @@ const expectNoSecret = (text: string) => { describe('telemetry redaction', () => { it('must not export a token via a request span', async () => { const exported: ExportedSpan[] = []; - const otel = createOtelSDK({ - serviceName: 'test-api', - traceExporter: collectingExporter(exported), - autoInstrumentations: false, - }); + const otel = createOtelSDK({ serviceName: 'test-api', traceExporter: collectingExporter(exported), autoInstrumentations: false }); otel.start(); const { baseApp } = await import('#/routes'); @@ -59,14 +55,7 @@ describe('telemetry redaction', () => { ]), ); expectNoSecret( - JSON.stringify( - exported.map((span) => ({ - name: span.name, - attributes: span.attributes, - events: span.events, - status: span.status, - })), - ), + JSON.stringify(exported.map((span) => ({ name: span.name, attributes: span.attributes, events: span.events, status: span.status }))), ); }); diff --git a/backend/tests/security/unsubscribe-tokens.test.ts b/backend/tests/security/unsubscribe-tokens.test.ts index d78518c64..396ed70a7 100644 --- a/backend/tests/security/unsubscribe-tokens.test.ts +++ b/backend/tests/security/unsubscribe-tokens.test.ts @@ -22,20 +22,14 @@ import { clearSecurityTestData } from './helpers'; /** A user created the way sign-up creates one, subscribed to the newsletter. */ const signUp = async (label: string) => { - const user = await handleCreateUser( - { var: { db: baseDb } }, - { newUser: mockUser({ email: `${label}@example.test` }), via: 'magic' }, - ); + const user = await handleCreateUser({ var: { db: baseDb } }, { newUser: mockUser({ email: `${label}@example.test` }), via: 'magic' }); await adminDb.update(usersTable).set({ newsletter: true }).where(eq(usersTable.id, user.id)); return user; }; /** Everything an unsubscribe link may switch, read past RLS: the newsletter flag on the user and the email preferences. */ const emailSettings = async (userId: string) => { - const [user] = await adminDb - .select({ newsletter: usersTable.newsletter }) - .from(usersTable) - .where(eq(usersTable.id, userId)); + const [user] = await adminDb.select({ newsletter: usersTable.newsletter }).from(usersTable).where(eq(usersTable.id, userId)); const [preferences] = await adminDb .select({ digest: notificationPreferencesTable.digest, mentionEmail: notificationPreferencesTable.mentionEmail }) .from(notificationPreferencesTable) @@ -50,8 +44,7 @@ const openLink = async (link: string) => { return { status: response.status, location: new URL(response.headers.get('location') ?? '', appConfig.frontendUrl) }; }; -const tokenOf = (userId: string, category: UnsubscribeCategory) => - new URL(buildUnsubscribeLink(userId, category)).searchParams.get('token') ?? ''; +const tokenOf = (userId: string, category: UnsubscribeCategory) => new URL(buildUnsubscribeLink(userId, category)).searchParams.get('token') ?? ''; const linkFor = (userId: string, category: UnsubscribeCategory, token: string) => `/notifications/unsubscribe?user=${userId}&category=${category}&token=${token}`; @@ -92,7 +85,7 @@ describe('Unsubscribe links', () => { ['digest', { ...everythingOn, digest: 'off' }], ] as const) { const owner = await signUp(`${category}-reader`); - await findOrCreatePreferences({ var: { db: baseDb } }, owner.id); + await findOrCreatePreferences({ var: { db: baseDb } }, { userId: owner.id }); const { status, location } = await openLink(buildUnsubscribeLink(owner.id, category)); @@ -105,12 +98,7 @@ describe('Unsubscribe links', () => { it('must not skip a member who signed up long ago, and must not mail one who unsubscribed', async () => { const organization = await createTestOrganization(); const member = async (label: string, newsletter: boolean) => { - const user = await createOrganizationAdminUser( - `${label}@example.test`, - organization.id, - memberRole, - organization.tenantId, - ); + const user = await createOrganizationAdminUser(`${label}@example.test`, organization.id, memberRole, organization.tenantId); await adminDb.update(usersTable).set({ newsletter }).where(eq(usersTable.id, user.id)); return user; }; @@ -122,12 +110,7 @@ describe('Unsubscribe links', () => { const response = await baseApp.request('/system/newsletter?toSelf=false', { method: 'POST', headers: { ...defaultHeaders, Cookie: await createTestSession(admin) }, - body: JSON.stringify({ - organizationIds: [organization.id], - roles: [memberRole], - subject: 'News', - content: '

    News

    ', - }), + body: JSON.stringify({ organizationIds: [organization.id], roles: [memberRole], subject: 'News', content: '

    News

    ' }), }); expect(response.status).toBe(204); diff --git a/backend/tests/security/upload-visibility.test.ts b/backend/tests/security/upload-visibility.test.ts index b0f28598c..374e25e88 100644 --- a/backend/tests/security/upload-visibility.test.ts +++ b/backend/tests/security/upload-visibility.test.ts @@ -40,9 +40,7 @@ describe('Upload visibility', async () => { /** Raw request: the client-chosen `publicBucket` is no longer part of the typed query. */ const requestToken = async (query: Record, cookie = tenant.sessionCookie) => { const response = await baseApp.fetch( - new Request(`http://localhost/me/upload-token?${new URLSearchParams(query)}`, { - headers: { ...defaultHeaders, Cookie: cookie }, - }), + new Request(`http://localhost/me/upload-token?${new URLSearchParams(query)}`, { headers: { ...defaultHeaders, Cookie: cookie } }), ); return { status: response.status, body: (await response.json()) as UploadTokenBody }; }; @@ -58,11 +56,7 @@ describe('Upload visibility', async () => { }); it('must not store a public file via choosing the public bucket for an attachment', async () => { - const { status, body } = await requestToken({ - templateId: 'attachment', - organizationId: tenant.organization.id, - publicBucket: 'true', - }); + const { status, body } = await requestToken({ templateId: 'attachment', organizationId: tenant.organization.id, publicBucket: 'true' }); expect(status).toBe(200); expect(body.publicBucket).toBe(false); @@ -75,10 +69,7 @@ describe('Upload visibility', async () => { expect(status, templateId).toBe(200); expect(body.publicBucket, templateId).toBe(true); - expect(body.params?.steps.exported, templateId).toMatchObject({ - acl: 'public-read', - credentials: appConfig.s3.publicBucket, - }); + expect(body.params?.steps.exported, templateId).toMatchObject({ acl: 'public-read', credentials: appConfig.s3.publicBucket }); } }); @@ -115,10 +106,7 @@ describe('Upload visibility', async () => { const stored = [steps.exported?.use ?? []].flat(); expect(stored.length).toBeGreaterThan(0); for (const step of stored) { - expect(steps[step], step).toMatchObject({ - robot: '/image/resize', - format: expect.stringMatching(/^(?:jpg|png|webp)$/), - }); + expect(steps[step], step).toMatchObject({ robot: '/image/resize', format: expect.stringMatching(/^(?:jpg|png|webp)$/) }); } } }); diff --git a/backend/tests/security/user-list-system-role.test.ts b/backend/tests/security/user-list-system-role.test.ts index b7b098129..415f111a9 100644 --- a/backend/tests/security/user-list-system-role.test.ts +++ b/backend/tests/security/user-list-system-role.test.ts @@ -30,9 +30,7 @@ describe('System roles in the user list', async () => { member = await createOrgUser(call, organization.tenantId, organization.id, 'role-list-member'); // A system admin who is also a member here, so the member's list includes them. sysAdmin = await createOrgUser(call, organization.tenantId, organization.id, 'role-list-sysadmin'); - await getAdminDb('test setup') - .insert(systemRolesTable) - .values({ userId: sysAdmin.id, role: 'admin', createdAt: mockPastIsoDate() }); + await getAdminDb('test setup').insert(systemRolesTable).values({ userId: sysAdmin.id, role: 'admin', createdAt: mockPastIsoDate() }); }); afterAll(async () => await clearSecurityTestData()); diff --git a/backend/tests/security/yjs-helpers.ts b/backend/tests/security/yjs-helpers.ts index 8afdddcd9..74ec1edc0 100644 --- a/backend/tests/security/yjs-helpers.ts +++ b/backend/tests/security/yjs-helpers.ts @@ -7,20 +7,13 @@ import { cleanupEntityHierarchy, insertAttachmentRow, seedAttachmentHome } from /** A BlockNote document of one paragraph, as the relay materializes it. */ export const paragraph = (text: string) => - JSON.stringify([ - { id: generateId(), type: 'paragraph', props: {}, content: [{ type: 'text', text, styles: {} }], children: [] }, - ]); + JSON.stringify([{ id: generateId(), type: 'paragraph', props: {}, content: [{ type: 'text', text, styles: {} }], children: [] }]); /** * An attachment in an organization. Attachments sit under RLS, so the row is arranged and read back on the admin * connection: under runtime_role a check on the test's own connection would pass vacuously. */ -export async function seedAttachment(opts: { - tenantId: string; - organizationId: string; - createdBy: string; - description: string; -}) { +export async function seedAttachment(opts: { tenantId: string; organizationId: string; createdBy: string; description: string }) { const id = generateId(); const plan = await seedAttachmentHome({ id: opts.organizationId, tenantId: opts.tenantId }, opts.createdBy); const row = buildInsertableProduct( diff --git a/backend/tests/security/yjs-materialize.test.ts b/backend/tests/security/yjs-materialize.test.ts index 3629cbe6d..1f05a33e0 100644 --- a/backend/tests/security/yjs-materialize.test.ts +++ b/backend/tests/security/yjs-materialize.test.ts @@ -44,13 +44,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs materialize scope text: string, editors: string[] = [owner.user.id], entityId = attachment.id, - ) => ({ - entityType: 'attachment', - entityId, - ...scope, - editors, - description: paragraph(text), - }); + ) => ({ entityType: 'attachment', entityId, ...scope, editors, description: paragraph(text) }); const ownScope = () => ({ tenantId: owner.tenantId, organizationId: owner.organization.id }); @@ -78,9 +72,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs materialize scope it('must not write without the relay secret or with a wrong one', async () => { const refused = await materialize(bodyFor(ownScope(), 'no secret'), null); await expectRefusal(refused, 401, 'unauthorized'); - expect((await materialize(bodyFor(ownScope(), 'wrong secret'), `${modeSecret('YJS_RELAY_SECRET')}x`)).status).toBe( - 401, - ); + expect((await materialize(bodyFor(ownScope(), 'wrong secret'), `${modeSecret('YJS_RELAY_SECRET')}x`)).status).toBe(401); expect((await stored())?.description).toBe(original); }); @@ -92,9 +84,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs materialize scope it("must not write through a body that names another tenant's organization", async () => { for (const organizationId of [other.organization.id, null]) { - const { status, body } = await materialize( - bodyFor({ tenantId: owner.tenantId, organizationId }, 'forged organization'), - ); + const { status, body } = await materialize(bodyFor({ tenantId: owner.tenantId, organizationId }, 'forged organization')); await expectRefusal({ status, body }, 403, 'forbidden', String(organizationId)); } expect((await stored())?.description).toBe(original); @@ -102,9 +92,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs materialize scope it('must not write through a body that names another tenant', async () => { // The entity is not in the named tenant: for that document it is gone. - const { status, body } = await materialize( - bodyFor({ tenantId: other.tenantId, organizationId: owner.organization.id }, 'forged tenant'), - ); + const { status, body } = await materialize(bodyFor({ tenantId: other.tenantId, organizationId: owner.organization.id }, 'forged tenant')); await expectRefusal({ status, body }, 410, 'not_found'); expect((await stored())?.description).toBe(original); }); diff --git a/backend/tests/security/yjs-token.test.ts b/backend/tests/security/yjs-token.test.ts index a57f6c675..8341e5e52 100644 --- a/backend/tests/security/yjs-token.test.ts +++ b/backend/tests/security/yjs-token.test.ts @@ -10,13 +10,7 @@ import { adminRole, defaultHeaders } from '../fixtures'; import { createOrganizationAdminUser, createSystemAdminUser, createTestSession, expectRefusal } from '../helpers'; import { createAppClient } from '../test-client'; import { setTestConfig } from '../test-utils'; -import { - assumeMemberAttachmentPolicy, - clearSecurityTestData, - createOrgUser, - createTestTenant, - type TestTenant, -} from './helpers'; +import { assumeMemberAttachmentPolicy, clearSecurityTestData, createOrgUser, createTestTenant, type TestTenant } from './helpers'; import { paragraph, seedAttachment } from './yjs-helpers'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); @@ -39,11 +33,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs token security', let otherTenantAttachment: Awaited>; const tokenFor = (cookie: string, scope: { tenantId: string; organizationId: string }, entityId: string) => - call(getYjsToken, { - path: scope, - query: { entityType: 'attachment', entityId }, - headers: { ...defaultHeaders, Cookie: cookie }, - }); + call(getYjsToken, { path: scope, query: { entityType: 'attachment', entityId }, headers: { ...defaultHeaders, Cookie: cookie } }); const ownScope = () => ({ tenantId: owner.tenantId, organizationId: owner.organization.id }); @@ -52,12 +42,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs token security', other = await createTestTenant(call, 'yjs-token-other'); member = await createOrgUser(call, owner.tenantId, owner.organization.id, 'yjs-token-member'); const attachmentIn = (tenant: TestTenant, createdBy: string) => - seedAttachment({ - tenantId: tenant.tenantId, - organizationId: tenant.organization.id, - createdBy, - description: paragraph('original'), - }); + seedAttachment({ tenantId: tenant.tenantId, organizationId: tenant.organization.id, createdBy, description: paragraph('original') }); ownersAttachment = await attachmentIn(owner, owner.user.id); membersAttachment = await attachmentIn(owner, member.id); otherTenantAttachment = await attachmentIn(other, other.user.id); @@ -122,15 +107,8 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs token security', expect(data).toBeUndefined(); // Positive control: a system admin whose membership grants update gets one, as the relay would accept. - const member = await createOrganizationAdminUser( - 'yjs-token-sysadmin-member@security-test.com', - owner.organization.id, - adminRole, - owner.tenantId, - ); - await getAdminDb('yjs token test') - .insert(systemRolesTable) - .values({ id: member.id, userId: member.id, role: 'admin' }); + const member = await createOrganizationAdminUser('yjs-token-sysadmin-member@security-test.com', owner.organization.id, adminRole, owner.tenantId); + await getAdminDb('yjs token test').insert(systemRolesTable).values({ id: member.id, userId: member.id, role: 'admin' }); const memberCookie = await createTestSession(member); expect((await tokenFor(memberCookie, ownScope(), ownersAttachment.id)).response.status).toBe(200); }); diff --git a/backend/tests/service-accounts.test.ts b/backend/tests/service-accounts.test.ts index 5711f5414..7a2cdd906 100644 --- a/backend/tests/service-accounts.test.ts +++ b/backend/tests/service-accounts.test.ts @@ -33,7 +33,7 @@ type Scope = NonNullable { const call = await createAppClient(); - // fork: organization roles, since raak's role registry also spans the channel-only `guest` + // Organization roles: the role registry can also span channel-only roles. async function orgWithAdmin(role: OrganizationRole = adminRole) { const org = await createTestOrganization(); const user = await createOrgUser(call, org.tenantId, org.id, `${role}-${nanoid(8)}`, role); @@ -44,18 +44,11 @@ describe('Service accounts and API keys', async () => { const ctx = await orgWithAdmin(); const { data, response } = await call(createServiceAccount, { path: { tenantId: ctx.org.tenantId, organizationId: ctx.org.id }, - body: { - name: 'CI bot', - role: opts.role ?? memberRole, - key: { name: 'deploy', scopes: opts.scopes ?? null, expiresAt: opts.expiresAt }, - }, + body: { name: 'CI bot', role: opts.role ?? memberRole, key: { name: 'deploy', scopes: opts.scopes ?? null, expiresAt: opts.expiresAt } }, headers: ctx.headers, }); expect(response.status).toBe(201); - const created = data as { - serviceAccount: { id: string }; - apiKey: { id: string; secret: string; prefix: string }; - }; + const created = data as { serviceAccount: { id: string }; apiKey: { id: string; secret: string; prefix: string } }; return { ...ctx, account: created.serviceAccount, apiKey: created.apiKey, key: created.apiKey.secret }; } @@ -82,19 +75,13 @@ describe('Service accounts and API keys', async () => { headers: member.headers, }); await expectRefusal({ response, error }, 403, 'forbidden'); - const accounts = await db - .select() - .from(serviceAccountsTable) - .where(eq(serviceAccountsTable.tenantId, member.org.tenantId)); + const accounts = await db.select().from(serviceAccountsTable).where(eq(serviceAccountsTable.tenantId, member.org.tenantId)); expect(accounts).toHaveLength(0); }); it('authenticates a key as the service account and reads inside its organization', async () => { const { org, key } = await issueKey(); - const { response } = await call(getAttachments, { - path: { tenantId: org.tenantId, organizationId: org.id }, - headers: bearerHeaders(key), - }); + const { response } = await call(getAttachments, { path: { tenantId: org.tenantId, organizationId: org.id }, headers: bearerHeaders(key) }); expect(response.status).toBe(200); }); @@ -124,10 +111,7 @@ describe('Service accounts and API keys', async () => { it('rejects a revoked key, a browser origin, and a foreign tenant', async () => { const { org, key, account, apiKey, headers } = await issueKey(); - const foreign = await call(getAttachments, { - path: { tenantId: 'other01', organizationId: org.id }, - headers: bearerHeaders(key), - }); + const foreign = await call(getAttachments, { path: { tenantId: 'other01', organizationId: org.id }, headers: bearerHeaders(key) }); expect(foreign.response.status).toBe(403); const browser = await call(getAttachments, { @@ -136,16 +120,10 @@ describe('Service accounts and API keys', async () => { }); expect(browser.response.status).toBe(403); - const revoked = await call(revokeApiKey, { - path: { tenantId: org.tenantId, organizationId: org.id, id: account.id, keyId: apiKey.id }, - headers, - }); + const revoked = await call(revokeApiKey, { path: { tenantId: org.tenantId, organizationId: org.id, id: account.id, keyId: apiKey.id }, headers }); expect(revoked.response.status).toBe(200); - const afterRevoke = await call(getAttachments, { - path: { tenantId: org.tenantId, organizationId: org.id }, - headers: bearerHeaders(key), - }); + const afterRevoke = await call(getAttachments, { path: { tenantId: org.tenantId, organizationId: org.id }, headers: bearerHeaders(key) }); expect(afterRevoke.response.status).toBe(401); }); @@ -166,17 +144,11 @@ describe('Service accounts and API keys', async () => { it('lists accounts and their keys for an admin, never the hash or the plaintext', async () => { const { org, headers, account, key } = await issueKey(); - const list = await call(getServiceAccounts, { - path: { tenantId: org.tenantId, organizationId: org.id }, - headers, - }); + const list = await call(getServiceAccounts, { path: { tenantId: org.tenantId, organizationId: org.id }, headers }); expect(list.response.status).toBe(200); expect((list.data as { items: { id: string }[] }).items.map((item) => item.id)).toContain(account.id); - const keys = await call(getApiKeys, { - path: { tenantId: org.tenantId, organizationId: org.id, id: account.id }, - headers, - }); + const keys = await call(getApiKeys, { path: { tenantId: org.tenantId, organizationId: org.id, id: account.id }, headers }); expect(keys.response.status).toBe(200); const serialized = JSON.stringify(keys.data); expect(serialized).not.toContain(key); @@ -188,10 +160,7 @@ describe('Service accounts and API keys', async () => { it('must not accept a key that expired half an hour ago, nor refuse one with half an hour left', async () => { const halfAnHour = 30 * 60 * 1000; const read = async (issued: Awaited>) => - call(getAttachments, { - path: { tenantId: issued.org.tenantId, organizationId: issued.org.id }, - headers: bearerHeaders(issued.key), - }); + call(getAttachments, { path: { tenantId: issued.org.tenantId, organizationId: issued.org.id }, headers: bearerHeaders(issued.key) }); const expired = await read(await issueKey({ expiresAt: new Date(Date.now() - halfAnHour).toISOString() })); expect(expired.response.status).toBe(401); @@ -204,10 +173,7 @@ describe('Service accounts and API keys', async () => { it('refuses a key of a disabled account', async () => { const disabled = await issueKey(); const readAsDisabled = () => - call(getAttachments, { - path: { tenantId: disabled.org.tenantId, organizationId: disabled.org.id }, - headers: bearerHeaders(disabled.key), - }); + call(getAttachments, { path: { tenantId: disabled.org.tenantId, organizationId: disabled.org.id }, headers: bearerHeaders(disabled.key) }); // A read first, so the key and its account are cached at the guard when the account is disabled. expect((await readAsDisabled()).response.status).toBe(200); const update = await call(updateServiceAccount, { @@ -229,17 +195,9 @@ describe('Service accounts and API keys', async () => { .set({ restrictions: { ...tenant.restrictions, quotas: { ...tenant.restrictions.quotas, serviceAccount: 1 } } }) .where(eq(tenantsTable.id, ctx.org.tenantId)); const path = { tenantId: ctx.org.tenantId, organizationId: ctx.org.id }; - const first = await call(createServiceAccount, { - path, - body: { name: 'one', role: memberRole }, - headers: ctx.headers, - }); + const first = await call(createServiceAccount, { path, body: { name: 'one', role: memberRole }, headers: ctx.headers }); expect(first.response.status).toBe(201); - const second = await call(createServiceAccount, { - path, - body: { name: 'two', role: memberRole }, - headers: ctx.headers, - }); + const second = await call(createServiceAccount, { path, body: { name: 'two', role: memberRole }, headers: ctx.headers }); expect(second.response.status).toBe(403); }); @@ -265,12 +223,7 @@ describe('Service accounts and API keys', async () => { }); expect(crossed.response.status).toBe(404); - const path = { - tenantId: a.org.tenantId, - organizationId: a.org.id, - id: a.account.id, - keyId: a.apiKey.id, - }; + const path = { tenantId: a.org.tenantId, organizationId: a.org.id, id: a.account.id, keyId: a.apiKey.id }; expect((await call(revokeApiKey, { path, headers: a.headers })).response.status).toBe(200); const [{ revokedAt }] = await db.select().from(apiKeysTable).where(eq(apiKeysTable.id, a.apiKey.id)); expect((await call(revokeApiKey, { path, headers: a.headers })).response.status).toBe(404); @@ -281,10 +234,7 @@ describe('Service accounts and API keys', async () => { it('refuses a service account without a grant at the tenant door', async () => { const { org, account, key } = await issueKey(); await db.update(serviceAccountsTable).set({ bindings: [] }).where(eq(serviceAccountsTable.id, account.id)); - const { response, error } = await call(getAttachments, { - path: { tenantId: org.tenantId, organizationId: org.id }, - headers: bearerHeaders(key), - }); + const { response, error } = await call(getAttachments, { path: { tenantId: org.tenantId, organizationId: org.id }, headers: bearerHeaders(key) }); expect(response.status).toBe(403); // tenantGuard's refusal, before any organization is resolved; orgGuard's would name the organization. expect((error as ErrorResponse).meta).toEqual({ resource: 'tenant' }); diff --git a/backend/tests/setup.ts b/backend/tests/setup.ts index a14c69d62..fe95286af 100644 --- a/backend/tests/setup.ts +++ b/backend/tests/setup.ts @@ -1,10 +1,16 @@ +import { testDatabaseName, withDatabase } from 'shared/test-db'; import { vi } from 'vitest'; +// Each worker runs on its own database (global-setup.ts prepares one per worker), so test files run in parallel without +// seeing each other's rows. The config's URLs name the shared database; swapped before any app module reads the env. +for (const key of ['DATABASE_URL', 'DATABASE_ADMIN_URL'] as const) { + const url = process.env[key]; + if (url) process.env[key] = withDatabase(url, testDatabaseName); +} + // Every limiter passes every request; a test of a real limiter calls `vi.unmock('#/middlewares/rate-limiter/core')`. vi.mock('#/middlewares/rate-limiter/core', async () => (await import('./test-utils')).rateLimiterCoreMock()); -vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => - (await import('./test-utils')).rateLimiterHelpersMock(importOriginal), -); +vi.mock('#/middlewares/rate-limiter/helpers', async (importOriginal) => (await import('./test-utils')).rateLimiterHelpersMock(importOriginal)); // Every mail renders for real and is recorded for `sentMails`; test mode sends none. The config clears the record // before each test (`clearMocks`). diff --git a/backend/tests/sign-in/device-id.test.ts b/backend/tests/sign-in/device-id.test.ts index c900914ab..a31b6c687 100644 --- a/backend/tests/sign-in/device-id.test.ts +++ b/backend/tests/sign-in/device-id.test.ts @@ -6,7 +6,7 @@ import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; // The device id is under test, not the authenticator code: every TOTP check passes. -vi.mock('#/modules/auth/totps/helpers/totps', () => ({ verifyTotp: vi.fn().mockResolvedValue(0) })); +vi.mock('#/modules/auth/totps/operations/verify-totp', () => ({ verifyTotp: vi.fn().mockResolvedValue(0) })); setTestConfig({ enabledAuthStrategies: ['passkey', 'totp'] }); @@ -18,10 +18,7 @@ describe('device id on sign-in', async () => { const signIn = async (user: { id: string; email: string }, deviceCookie?: string) => { const mfaToken = await createMfaToken(user); const cookies = [authCookie('confirm-mfa', mfaToken), deviceCookie].filter(Boolean).join('; '); - const { response } = await call(signInWithTotp, { - body: { code: '123456' }, - headers: { ...defaultHeaders, Cookie: cookies }, - }); + const { response } = await call(signInWithTotp, { body: { code: '123456' }, headers: { ...defaultHeaders, Cookie: cookies } }); expect(response.status).toBe(204); return response; }; diff --git a/backend/tests/sign-in/devices-enroll-failure.test.ts b/backend/tests/sign-in/devices-enroll-failure.test.ts index 2415a1ca1..ea55be8c7 100644 --- a/backend/tests/sign-in/devices-enroll-failure.test.ts +++ b/backend/tests/sign-in/devices-enroll-failure.test.ts @@ -5,9 +5,7 @@ import { signUpUser } from '../fixtures'; import { createTestUser, sessionsOf } from '../helpers'; import { clearDatabase } from '../test-utils'; -vi.mock('#/modules/auth/general/helpers/enroll-device', () => ({ - enrollDevice: vi.fn().mockRejectedValue(new Error('devices table unavailable')), -})); +vi.mock('#/modules/auth/general/helpers/enroll-device', () => ({ enrollDevice: vi.fn().mockRejectedValue(new Error('devices table unavailable')) })); afterEach(async () => await clearDatabase()); diff --git a/backend/tests/sign-in/devices.test.ts b/backend/tests/sign-in/devices.test.ts index e56302c07..fefebb36e 100644 --- a/backend/tests/sign-in/devices.test.ts +++ b/backend/tests/sign-in/devices.test.ts @@ -13,20 +13,12 @@ import type { AuthStrategy } from '#/modules/auth/sessions-db'; import { userCountersTable } from '#/modules/user/user-counters-db'; import { hashDeviceIdForUser } from '#/utils/hash-pii'; import { defaultHeaders, signUpUser } from '../fixtures'; -import { - authCookie, - createMfaToken, - createTestSession, - createTestUser, - createTotpUser, - sentMails, - setCookiePair, -} from '../helpers'; +import { authCookie, createMfaToken, createTestSession, createTestUser, createTotpUser, sentMails, setCookiePair } from '../helpers'; import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; // New-device notices are under test, not authenticator codes: every TOTP check passes. -vi.mock('#/modules/auth/totps/helpers/totps', () => ({ verifyTotp: vi.fn().mockResolvedValue(0) })); +vi.mock('#/modules/auth/totps/operations/verify-totp', () => ({ verifyTotp: vi.fn().mockResolvedValue(0) })); setTestConfig({ enabledAuthStrategies: ['passkey', 'totp'] }); @@ -53,11 +45,7 @@ const notices = () => .filter((statics) => statics.type === 'new-sign-in'); /** A full sign-in without a request: the session, then the notice its new device calls for. */ -const signIn = async ( - user: Awaited>, - context: SignInContext, - strategy: AuthStrategy = 'passkey', -) => { +const signIn = async (user: Awaited>, context: SignInContext, strategy: AuthStrategy = 'passkey') => { const { newDevice } = await createSession(user, context, strategy); if (newDevice) await notifyNewSignIn({ user, context, strategy, newDevice }); return newDevice; @@ -112,12 +100,7 @@ describe('new sign-in notice', () => { await signIn(user, browser()); expect(notices()).toHaveLength(1); - expect(notices()[0].details).toMatchObject({ - browser: 'Firefox', - os: 'macOS', - country: 'Netherlands', - strategy: 'Passkey', - }); + expect(notices()[0].details).toMatchObject({ browser: 'Firefox', os: 'macOS', country: 'Netherlands', strategy: 'Passkey' }); expect(notices()[0].details.accountUrl).toMatch(/\/account$/); expect(notices()[0].details.timestamp).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC$/); @@ -138,22 +121,19 @@ describe('new sign-in notice', () => { expect(await devicesOf(user.id)).toHaveLength(1); }); - it.each(['magic', 'email'] as const)( - 'enrolls but does not mail a %s sign-in, which went through the inbox', - async (strategy) => { - const user = await createTestUser(signUpUser.email); - await seedEarlierSignIn(user.id); - const context = browser(); + it.each(['magic', 'email'] as const)('enrolls but does not mail a %s sign-in, which went through the inbox', async (strategy) => { + const user = await createTestUser(signUpUser.email); + await seedEarlierSignIn(user.id); + const context = browser(); - await signIn(user, context, strategy); - // The browser proved itself through the inbox, so a later passkey sign-in from it is familiar. - const later = await signIn(user, context, 'passkey'); + await signIn(user, context, strategy); + // The browser proved itself through the inbox, so a later passkey sign-in from it is familiar. + const later = await signIn(user, context, 'passkey'); - expect(later).toBeNull(); - expect(notices()).toHaveLength(0); - expect(await devicesOf(user.id)).toHaveLength(1); - }, - ); + expect(later).toBeNull(); + expect(notices()).toHaveLength(0); + expect(await devicesOf(user.id)).toHaveLength(1); + }); it('never enrolls or mails an impersonation session', async () => { const user = await createTestUser(signUpUser.email); @@ -192,10 +172,7 @@ describe('new sign-in notice through the sign-in endpoint', async () => { const signInWithMfa = async (user: { id: string; email: string }, deviceCookie?: string) => { const mfaToken = await createMfaToken(user); const cookies = [authCookie('confirm-mfa', mfaToken), deviceCookie].filter(Boolean).join('; '); - const { response } = await call(signInWithTotp, { - body: { code: '123456' }, - headers: { ...defaultHeaders, Cookie: cookies }, - }); + const { response } = await call(signInWithTotp, { body: { code: '123456' }, headers: { ...defaultHeaders, Cookie: cookies } }); expect(response.status).toBe(204); return setCookiePair(response, 'device-id'); }; @@ -221,9 +198,7 @@ describe('sessions list flags sessions from a new browser', async () => { const call = await createAppClient(); const sessionsOf = async (user: { id: string }) => { - const { data, response } = await call(getMyAuth, { - headers: { ...defaultHeaders, Cookie: await createTestSession(user) }, - }); + const { data, response } = await call(getMyAuth, { headers: { ...defaultHeaders, Cookie: await createTestSession(user) } }); expect(response.status).toBe(200); // The test client types data as unknown; the SDK already consumed the body. return (data as MeAuthData).sessions; diff --git a/backend/tests/sign-in/magic.test.ts b/backend/tests/sign-in/magic.test.ts index bc60158fd..bb4330ac8 100644 --- a/backend/tests/sign-in/magic.test.ts +++ b/backend/tests/sign-in/magic.test.ts @@ -10,14 +10,7 @@ import { inactiveMembershipsTable } from '#/modules/memberships/inactive-members import { userCountersTable } from '#/modules/user/user-counters-db'; import { usersTable } from '#/modules/user/user-db'; import { defaultHeaders, signUpUser } from '../fixtures'; -import { - authCookie, - cookieChange, - createTestOrganization, - createUser, - enableMFAForUser, - insertTestToken, -} from '../helpers'; +import { authCookie, cookieChange, createTestOrganization, createUser, enableMFAForUser, insertTestToken } from '../helpers'; import { createInvitation } from '../invitations/helpers'; import { createAppClient } from '../test-client'; import { clearDatabase, setTestConfig } from '../test-utils'; @@ -74,10 +67,7 @@ describe('Magic link authentication', async () => { ])('should drop an invalid redirect ($name)', async ({ redirect }) => { const user = await createUser(signUpUser.email); - const { response: res } = await call(sendMagicLink, { - body: { email: signUpUser.email, redirect }, - headers: defaultHeaders, - }); + const { response: res } = await call(sendMagicLink, { body: { email: signUpUser.email, redirect }, headers: defaultHeaders }); expect(res.status).toBe(204); expect((await getMagicToken(user.id)).redirectPath).toBeNull(); @@ -225,10 +215,7 @@ describe('Magic link authentication', async () => { const inviter = await createUser('inviter@example.com'); await createInvitation({ organization, email: 'invited@example.com', createdBy: inviter.id }); - const { response: res } = await call(sendMagicLink, { - body: { email: 'invited@example.com' }, - headers: defaultHeaders, - }); + const { response: res } = await call(sendMagicLink, { body: { email: 'invited@example.com' }, headers: defaultHeaders }); expect(res.status).toBe(204); // The link goes out without an account: the account is created when the link is clicked. @@ -239,10 +226,7 @@ describe('Magic link authentication', async () => { it('creates nothing for an address that was not invited, with the same response', async () => { closeRegistration(); - const { response: res } = await call(sendMagicLink, { - body: { email: 'stranger@example.com' }, - headers: defaultHeaders, - }); + const { response: res } = await call(sendMagicLink, { body: { email: 'stranger@example.com' }, headers: defaultHeaders }); expect(res.status).toBe(204); expect(await magicLinksFor('stranger@example.com')).toHaveLength(0); @@ -253,11 +237,7 @@ describe('Magic link authentication', async () => { closeRegistration(); const organization = await createTestOrganization(); const inviter = await createUser('inviter@example.com'); - const { inactiveMembership, token } = await createInvitation({ - organization, - email: 'declined@example.com', - createdBy: inviter.id, - }); + const { inactiveMembership, token } = await createInvitation({ organization, email: 'declined@example.com', createdBy: inviter.id }); await db .update(inactiveMembershipsTable) .set({ rejectedAt: new Date().toISOString() }) @@ -276,10 +256,7 @@ describe('Magic link authentication', async () => { const user = await createUser(signUpUser.email); await addProvenEmail(db, { userId: user.id, email: 'work@example.com', via: 'github' }); - const { response: res } = await call(sendMagicLink, { - body: { email: 'work@example.com' }, - headers: defaultHeaders, - }); + const { response: res } = await call(sendMagicLink, { body: { email: 'work@example.com' }, headers: defaultHeaders }); expect(res.status).toBe(204); const token = await getMagicToken(user.id); diff --git a/backend/tests/sign-in/mark-email-verified.test.ts b/backend/tests/sign-in/mark-email-verified.test.ts index c67a8ab1e..896fb7535 100644 --- a/backend/tests/sign-in/mark-email-verified.test.ts +++ b/backend/tests/sign-in/mark-email-verified.test.ts @@ -1,11 +1,7 @@ import { eq } from 'drizzle-orm'; import { afterEach, describe, expect, it } from 'vitest'; import { baseDb as db } from '#/db/db'; -import { - addProvenEmail, - markEmailVerified, - requireEmailVerified, -} from '#/modules/auth/general/helpers/mark-email-verified'; +import { addProvenEmail, markEmailVerified, requireEmailVerified } from '#/modules/auth/general/helpers/mark-email-verified'; import { emailsTable } from '#/modules/user/emails-db'; import { createTestUser } from '../helpers'; import { clearDatabase } from '../test-utils'; @@ -54,14 +50,8 @@ describe('markEmailVerified', () => { it('fails a verification flow on an address the account does not hold', async () => { const user = await createTestUser('owner@example.com'); - await expect( - requireEmailVerified(db, { userId: user.id, email: 'nobody@example.com', via: 'magic' }), - ).rejects.toMatchObject({ - status: 500, - }); - await expect( - requireEmailVerified(db, { userId: user.id, email: user.email, via: 'magic' }), - ).resolves.toBeUndefined(); + await expect(requireEmailVerified(db, { userId: user.id, email: 'nobody@example.com', via: 'magic' })).rejects.toMatchObject({ status: 500 }); + await expect(requireEmailVerified(db, { userId: user.id, email: user.email, via: 'magic' })).resolves.toBeUndefined(); }); }); diff --git a/backend/tests/sign-in/oauth.test.ts b/backend/tests/sign-in/oauth.test.ts index cad0310cd..ae5a95716 100644 --- a/backend/tests/sign-in/oauth.test.ts +++ b/backend/tests/sign-in/oauth.test.ts @@ -7,7 +7,7 @@ import { baseDb as db } from '#/db/db'; import { mailer } from '#/lib/mailer'; import { resolveSession } from '#/modules/auth/general/helpers/session'; import { identitiesTable } from '#/modules/auth/identities-db'; -import { githubAuth, googleAuth, microsoftAuth } from '#/modules/auth/oauth/helpers/providers'; +import { githubAuth, googleAuth, microsoftAuth, OAuthCodeExchangeError } from '#/modules/auth/oauth/helpers/providers'; import { sessionsTable } from '#/modules/auth/sessions-db'; import { tokensTable } from '#/modules/auth/tokens-db'; import { inactiveMembershipsTable } from '#/modules/memberships/inactive-memberships-db'; @@ -33,13 +33,10 @@ import { clearCookieStore, clearDatabase, mockCookieStore, setTestConfig } from vi.mock('oauth4webapi', async () => (await import('../test-utils')).oauth4webapiMock()); -setTestConfig({ - enabledAuthStrategies: ['oauth'], - enabledOAuthProviders: ['github', 'google', 'microsoft'], - selfRegistration: true, -}); +setTestConfig({ enabledAuthStrategies: ['oauth'], enabledOAuthProviders: ['github', 'google', 'microsoft'], selfRegistration: true }); -vi.mock('#/modules/auth/oauth/helpers/providers', () => ({ +vi.mock('#/modules/auth/oauth/helpers/providers', async (importOriginal) => ({ + OAuthCodeExchangeError: (await importOriginal()).OAuthCodeExchangeError, githubAuth: { createAuthorizationURL: vi.fn().mockReturnValue(new URL('https://github.com/login/oauth/authorize')), validateAuthorizationCode: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }), @@ -49,9 +46,7 @@ vi.mock('#/modules/auth/oauth/helpers/providers', () => ({ validateAuthorizationCode: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }), }, microsoftAuth: { - createAuthorizationURL: vi - .fn() - .mockReturnValue(new URL('https://login.microsoftonline.com/common/oauth2/v2.0/authorize')), + createAuthorizationURL: vi.fn().mockReturnValue(new URL('https://login.microsoftonline.com/common/oauth2/v2.0/authorize')), validateAuthorizationCode: vi.fn().mockResolvedValue({ accessToken: 'mock-access-token' }), }, })); @@ -78,9 +73,7 @@ vi.mock('#/modules/auth/oauth/helpers/transform-user-data', () => ({ })), })); vi.mock('#/modules/auth/general/helpers/cookie', async () => (await import('../test-utils')).cookieMock()); -vi.mock('#/modules/auth/general/helpers/session', async (importOriginal) => - (await import('../test-utils')).sessionMock(importOriginal), -); +vi.mock('#/modules/auth/general/helpers/session', async (importOriginal) => (await import('../test-utils')).sessionMock(importOriginal)); afterEach(async () => { await clearDatabase(); clearCookieStore(); @@ -115,10 +108,7 @@ describe('OAuth Authentication', async () => { it('should handle OAuth flow with redirect parameter', async () => { const redirectAfter = '/dashboard'; - const { response: res } = await call(github, { - query: { type: 'auth', redirectAfter }, - headers: defaultHeaders, - }); + const { response: res } = await call(github, { query: { type: 'auth', redirectAfter }, headers: defaultHeaders }); expect(res.status).toBe(302); const [[state]] = vi.mocked(githubAuth.createAuthorizationURL).mock.calls; @@ -134,12 +124,9 @@ describe('OAuth Authentication', async () => { await linkIdentity(user); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); expect(cookieChange(res, 'session')).toBe('set'); @@ -150,12 +137,9 @@ describe('OAuth Authentication', async () => { const identity = await linkIdentity(user, { email: 'old-address@example.com' }); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); expect(cookieChange(res, 'session')).toBe('set'); @@ -164,9 +148,7 @@ describe('OAuth Authentication', async () => { expect(used.email).toBe('github-user@example.com'); expect(used.lastUsedAt).not.toBeNull(); // The snapshot is display only: no email row appears for it. - expect(await db.select().from(emailsTable).where(eq(emailsTable.email, 'github-user@example.com'))).toHaveLength( - 0, - ); + expect(await db.select().from(emailsTable).where(eq(emailsTable.email, 'github-user@example.com'))).toHaveLength(0); }); it('never matches an identity of another kind that shares the issuer slug and subject', async () => { @@ -174,12 +156,9 @@ describe('OAuth Authentication', async () => { const ssoIdentity = await linkIdentity(user, { kind: 'sso' }); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); // The callback treats the GitHub user as new: no session as the SSO identity's user, and that identity is untouched. expect(res.status).toBe(302); @@ -198,12 +177,9 @@ describe('OAuth Authentication', async () => { const identity = await linkIdentity(user, { verified: false, email: user.email }); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res, error } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 409, 'oauth_conflict'); expect(cookieChange(res, 'session')).toBeUndefined(); @@ -217,12 +193,9 @@ describe('OAuth Authentication', async () => { const identity = await linkIdentity(user, { verified: false, email: 'old-address@example.com' }); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); expect(res.headers.get('location')).toContain('/auth/email-verification'); @@ -230,6 +203,8 @@ describe('OAuth Authentication', async () => { expect(token.email).toBe('github-user@example.com'); const [refreshed] = await db.select().from(identitiesTable).where(eq(identitiesTable.id, identity.id)); expect(refreshed.email).toBe('github-user@example.com'); + // An identity on an account reads as connecting, under the provider's name. + expect(mailsTo('github-user@example.com').at(-1)?.statics).toMatchObject({ isNewUser: false, providerName: 'GitHub' }); }); it('should redirect to email verification for unverified OAuth account', async () => { @@ -242,12 +217,9 @@ describe('OAuth Authentication', async () => { await linkIdentity(user, { verified: false }); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); const location = res.headers.get('location'); @@ -267,7 +239,7 @@ describe('OAuth Authentication', async () => { it('should reject callback with OAuth error', async () => { const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); const { response: res, error } = await call(githubCallback, { query: { state, code: 'error-code', error: 'access_denied', error_description: 'User denied access' }, @@ -277,26 +249,37 @@ describe('OAuth Authentication', async () => { await expectRefusal({ response: res, error }, 400, 'oauth_failed'); }); - it('should reject callback with missing code', async () => { - const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + // A provider denial carries `error` and `state` but no `code` (RFC 6749 §4.1.2.1). + it.each([ + { name: 'github', fn: githubCallback }, + { name: 'google', fn: googleCallback }, + { name: 'microsoft', fn: microsoftCallback }, + ])('refuses a $name denial that arrives without a code as oauth_failed', async ({ name, fn }) => { + const state = 'mock-state-denied'; + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: name, type: 'auth', codeVerifier: 'verifier' })); - const { response: res, error } = await call(githubCallback, { - query: { state, code: '' }, + const { response: res, error } = await call(fn, { + query: { state, error: 'access_denied', error_description: 'User denied access' }, headers: defaultHeaders, }); await expectRefusal({ response: res, error }, 400, 'oauth_failed'); }); + + it('should reject callback with missing code', async () => { + const state = 'mock-state-test'; + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); + + const { response: res, error } = await call(githubCallback, { query: { state, code: '' }, headers: defaultHeaders }); + + await expectRefusal({ response: res, error }, 400, 'oauth_failed'); + }); }); describe('Security & Input Validation', () => { it('should handle very long redirect URL', async () => { const longRedirect = 'a'.repeat(2000); - const { response: res } = await call(github, { - query: { type: 'auth', redirectAfter: longRedirect }, - headers: defaultHeaders, - }); + const { response: res } = await call(github, { query: { type: 'auth', redirectAfter: longRedirect }, headers: defaultHeaders }); expect(res.status).toBe(302); }); @@ -310,12 +293,9 @@ describe('OAuth Authentication', async () => { await createUser('github-user@example.com'); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res, error } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 409, 'oauth_email_exists'); }); @@ -337,12 +317,11 @@ describe('OAuth Authentication', async () => { }; const connectCallback = (payload: Record = {}) => { - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'connect', ...payload })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'connect', ...payload })); return call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); }; - const identitiesOf = (userId: string) => - db.select().from(identitiesTable).where(eq(identitiesTable.userId, userId)); + const identitiesOf = (userId: string) => db.select().from(identitiesTable).where(eq(identitiesTable.userId, userId)); it('links a provider account on another address without making that address a user email', async () => { const user = await createUser('local-account@example.com'); @@ -361,7 +340,7 @@ describe('OAuth Authentication', async () => { }); it("sends a provider's refusal of a connect back to the account page, with the error to show", async () => { - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'connect' })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'connect' })); const denied = () => call(githubCallback, { query: { state, code: 'error-code', error: 'access_denied', error_description: 'User denied access' }, @@ -424,10 +403,7 @@ describe('OAuth Authentication', async () => { const sessionId = await pinConnect(user); // Signed out, or revoked from another device, while the provider's page stayed open in this browser. - await db - .update(sessionsTable) - .set({ revokedAt: new Date().toISOString() }) - .where(eq(sessionsTable.id, sessionId)); + await db.update(sessionsTable).set({ revokedAt: new Date().toISOString() }).where(eq(sessionsTable.id, sessionId)); const ended = await connectCallback(); await expectRefusal(ended, 401, 'oauth-connect_not_found'); expect(await identitiesOf(user.id)).toHaveLength(0); @@ -475,7 +451,7 @@ describe('OAuth Authentication', async () => { const started = await call(github, { query: { type: 'connect' }, headers: defaultHeaders }); expect(started.response.status).toBe(302); const statePayload = [...mockCookieStore.entries()].find(([name]) => name.startsWith('oauth-state-'))?.[1]; - expect(JSON.parse(statePayload ?? '{}')).toEqual({ type: 'connect' }); + expect(JSON.parse(statePayload ?? '{}')).toEqual({ provider: 'github', type: 'connect' }); }); }); @@ -495,7 +471,7 @@ describe('OAuth Authentication', async () => { { identityId: oauthAccount.id, openedWith: rawSingleUse, expiresInMs: 5 * 60 * 1000 }, ); mockCookieStore.set('oauth-verification', rawSingleUse); - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'verify', tokenId: token.id })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'verify', tokenId: token.id })); return { user, oauthAccount }; }; @@ -503,10 +479,7 @@ describe('OAuth Authentication', async () => { it('adds the provider address to the account as a proven inbox', async () => { const { user, oauthAccount } = await connectedUnverified(); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); const [verifiedAccount] = await db.select().from(identitiesTable).where(eq(identitiesTable.id, oauthAccount.id)); @@ -523,10 +496,7 @@ describe('OAuth Authentication', async () => { const { user } = await connectedUnverified(); await createUser(providerEmail); - const { response: res, error } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 409, 'oauth_conflict'); const [row] = await db.select().from(emailsTable).where(eq(emailsTable.email, providerEmail)); @@ -540,20 +510,121 @@ describe('OAuth Authentication', async () => { it.each([ { name: 'google', fn: googleCallback }, { name: 'microsoft', fn: microsoftCallback }, - ])('should reject $name callback when codeVerifier is missing from the state cookie', async ({ fn }) => { + ])('should reject $name callback when codeVerifier is missing from the state cookie', async ({ name, fn }) => { const state = 'mock-state-test'; // Cookie present, but WITHOUT a PKCE code verifier. - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth' })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: name, type: 'auth' })); - const { response: res, error } = await call(fn, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(fn, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 401, 'invalid_state'); }); }); + describe('Callbacks per provider', () => { + const providers = [ + { provider: 'github', fn: githubCallback, client: githubAuth, pkce: false }, + { provider: 'google', fn: googleCallback, client: googleAuth, pkce: true }, + { provider: 'microsoft', fn: microsoftCallback, client: microsoftAuth, pkce: true }, + ] as const; + + /** A state cookie as the provider's own start writes it: a PKCE provider's holds a code verifier and a nonce. */ + const pendingState = (provider: string, pkce: boolean) => { + const state = `mock-state-${nanoid(6)}`; + const flow = pkce ? { codeVerifier: 'verifier', nonce: 'nonce' } : {}; + const payload = { provider, type: 'auth', ...flow }; + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify(payload)); + return state; + }; + + const callback = (fn: (typeof providers)[number]['fn'], state: string) => + call(fn, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); + + it.each(providers)('refuses a $provider callback without a state cookie', async ({ fn, client }) => { + await expectRefusal(await callback(fn, 'no-such-state'), 401, 'invalid_state'); + expect(client.validateAuthorizationCode).not.toHaveBeenCalled(); + }); + + it.each( + providers.flatMap((entry) => [ + { ...entry, thrown: 'a refused code', error: () => new OAuthCodeExchangeError(new Error('bad code')), type: 'invalid_credentials' }, + { ...entry, thrown: 'any other failure', error: () => new Error('network down'), type: 'oauth_failed' }, + ]), + )('answers $thrown at the $provider code exchange with 401 $type', async ({ provider, fn, client, pkce, error, type }) => { + vi.mocked(client.validateAuthorizationCode).mockRejectedValueOnce(error()); + await expectRefusal(await callback(fn, pendingState(provider, pkce)), 401, type); + }); + + it.each(providers)('refuses a $provider callback while the provider is off', async ({ provider, fn, pkce }) => { + const enabled = ['github', 'google', 'microsoft'] as const; + setTestConfig({ enabledOAuthProviders: enabled.filter((name) => name !== provider) }); + onTestFinished(() => setTestConfig({ enabledOAuthProviders: [...enabled] })); + + const refused = await callback(fn, pendingState(provider, pkce)); + await expectRefusal(refused, 400, 'unsupported_oauth'); + expect((refused.error as ErrorResponse & { meta: Record }).meta.strategy).toBe(provider); + }); + + it.each([ + { ...providers[0], urls: ['https://api.github.com/user', 'https://api.github.com/user/emails'] }, + { ...providers[1], urls: ['https://openidconnect.googleapis.com/v1/userinfo'] }, + { ...providers[2], urls: ['https://graph.microsoft.com/oidc/userinfo'] }, + ])('reads the $provider profile from its userinfo endpoints, all at once', async ({ provider, fn, client, pkce, urls }) => { + // Each request records how many were in flight when it started: parallel requests overlap. + const started: { url: string; authorization: string | null; inFlight: number }[] = []; + let inFlight = 0; + const stub = vi.mocked(fetch); + const original = stub.getMockImplementation(); + onTestFinished(() => { + if (original) stub.mockImplementation(original); + }); + stub.mockImplementation(async (input, init) => { + inFlight++; + started.push({ url: String(input), authorization: new Headers(init?.headers).get('authorization'), inFlight }); + await new Promise((resolve) => setTimeout(resolve, 0)); + inFlight--; + return { ok: true, status: 200, json: async () => ({}), text: async () => '' } as unknown as Response; + }); + + const state = pendingState(provider, pkce); + const { response } = await callback(fn, state); + + expect(response.status).toBe(302); + expect(started).toEqual(urls.map((url, index) => ({ url, authorization: 'Bearer mock-access-token', inFlight: index + 1 }))); + const [[code, exchangedState, options]] = vi.mocked(client.validateAuthorizationCode).mock.calls; + expect([code, exchangedState]).toEqual(['mock-auth-code', state]); + if (pkce) expect(options).toEqual({ codeVerifier: 'verifier', nonce: 'nonce' }); + else expect(options?.codeVerifier ?? options?.nonce).toBeUndefined(); + }); + + // GitHub has no PKCE, so its state cookie holds no code verifier: a PKCE provider's callback never accepts it. + it.each([ + { provider: 'google', fn: googleCallback, client: googleAuth }, + { provider: 'microsoft', fn: microsoftCallback, client: microsoftAuth }, + ])('must not accept a GitHub-minted state at the $provider callback', async ({ fn, client }) => { + const { response: started } = await call(github, { query: { type: 'auth' }, headers: defaultHeaders }); + expect(started.status).toBe(302); + const [[state]] = vi.mocked(githubAuth.createAuthorizationURL).mock.calls; + expect(mockCookieStore.get(`oauth-state-${state}`)).toBeTruthy(); + + await expectRefusal(await callback(fn, state), 401, 'invalid_state'); + expect(client.validateAuthorizationCode).not.toHaveBeenCalled(); + }); + + // A state belongs to the provider whose start minted it, whatever else its cookie holds. + it.each([ + { from: 'google', start: google, minter: googleAuth, to: 'github', fn: githubCallback, client: githubAuth }, + { from: 'microsoft', start: microsoft, minter: microsoftAuth, to: 'google', fn: googleCallback, client: googleAuth }, + ])('must not accept a $from-minted state at the $to callback', async ({ start, minter, fn, client }) => { + const { response: started } = await call(start, { query: { type: 'auth' }, headers: defaultHeaders }); + expect(started.status).toBe(302); + const [[state]] = vi.mocked(minter.createAuthorizationURL).mock.calls; + + await expectRefusal(await callback(fn, state), 401, 'invalid_state'); + expect(client.validateAuthorizationCode).not.toHaveBeenCalled(); + }); + }); + describe('Verified redirect honors only validated same-origin paths', () => { const linkVerifiedAccount = async () => { const userEmail = 'github-user@example.com'; @@ -568,13 +639,10 @@ describe('OAuth Authentication', async () => { const state = 'mock-state-test'; mockCookieStore.set( `oauth-state-${state}`, - JSON.stringify({ type: 'auth', redirectAfter: '/orgs/acme?tab=files', codeVerifier: undefined }), + JSON.stringify({ provider: 'github', type: 'auth', redirectAfter: '/orgs/acme?tab=files', codeVerifier: undefined }), ); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(`${appConfig.frontendUrl}/orgs/acme?tab=files`); @@ -587,15 +655,9 @@ describe('OAuth Authentication', async () => { for (const redirectAfter of ['//evil.example', '/..//evil.example']) { const state = 'mock-state-test'; - mockCookieStore.set( - `oauth-state-${state}`, - JSON.stringify({ type: 'auth', redirectAfter, codeVerifier: undefined }), - ); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', redirectAfter, codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status, redirectAfter).toBe(302); const location = res.headers.get('location'); @@ -615,12 +677,9 @@ describe('OAuth Authentication', async () => { await linkIdentity(user); const state = 'mock-state-test'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); const location = res.headers.get('location'); @@ -641,12 +700,11 @@ describe('OAuth Authentication', async () => { const invitation = await createInvitation({ organization, email, createdBy: inviter.id, token: 'invoked' }); // The cookie mock keeps plain values: this browser's single-use cookie for the opened link. mockCookieStore.set('invitation', invitation.rawSingleUseToken); - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'invite' })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'invite' })); return invitation; }; - const inviteCallback = () => - call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); + const inviteCallback = () => call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); it('creates the account verified and signs in, with no second verification mail', async () => { const { inactiveMembership } = await openedInvitation(providerEmail); @@ -664,10 +722,7 @@ describe('OAuth Authentication', async () => { expect(identity).toMatchObject({ issuer: 'github', subject: 'github-user-id', verified: true }); // The invitation waiting for the address is the new account's, answered in the app. - const [claimed] = await db - .select() - .from(inactiveMembershipsTable) - .where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); + const [claimed] = await db.select().from(inactiveMembershipsTable).where(eq(inactiveMembershipsTable.id, inactiveMembership.id)); expect(claimed.userId).toBe(account.id); }); @@ -733,7 +788,7 @@ describe('OAuth Authentication', async () => { const signUpCallback = () => { const state = 'mock-state-sign-up'; - mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ type: 'auth', codeVerifier: undefined })); + mockCookieStore.set(`oauth-state-${state}`, JSON.stringify({ provider: 'github', type: 'auth', codeVerifier: undefined })); return call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); }; @@ -743,7 +798,7 @@ describe('OAuth Authentication', async () => { /** The provider's callback for the verify round trip, in the browser that opened the link. */ const verifyCallback = () => { - mockCookieStore.set(`oauth-state-${verifyState}`, JSON.stringify({ type: 'verify' })); + mockCookieStore.set(`oauth-state-${verifyState}`, JSON.stringify({ provider: 'github', type: 'verify' })); return call(githubCallback, { query: { state: verifyState, code: 'mock-auth-code' }, headers: defaultHeaders }); }; @@ -769,11 +824,15 @@ describe('OAuth Authentication', async () => { pendingSignUp: expect.objectContaining({ issuer: 'github', subject: 'github-user-id' }), }), ]); + // The mail asks to finish signing up, not to connect a provider to an account the visitor does not have. + expect(mailsTo(providerEmail).at(-1)?.statics).toMatchObject({ isNewUser: true, providerName: 'GitHub' }); }); it('creates the account once the mailed link and the same provider account prove it (positive control)', async () => { await signUpCallback(); - const opened = await openVerificationLink(mailedLink('verificationLink').token); + const { url, token } = mailedLink('verificationLink'); + expect(url).toBe(`${appConfig.backendAuthUrl}/invoke-token/oauth-verification/${token}`); + const opened = await openVerificationLink(token); expect(opened.response.status).toBe(302); const verifyStart = new URL(opened.response.headers.get('location') ?? ''); expect(`${verifyStart.origin}${verifyStart.pathname}`).toBe(`${appConfig.backendAuthUrl}/github`); @@ -784,15 +843,10 @@ describe('OAuth Authentication', async () => { const statesBefore = new Set(mockCookieStore.keys()); const started = await call(github, { query: { type: 'verify' }, headers: defaultHeaders }); expect(started.response.status).toBe(302); - const stateKey = [...mockCookieStore.keys()].find( - (key) => key.startsWith('oauth-state-') && !statesBefore.has(key), - ); + const stateKey = [...mockCookieStore.keys()].find((key) => key.startsWith('oauth-state-') && !statesBefore.has(key)); const state = stateKey?.replace('oauth-state-', '') ?? ''; - const { response: res } = await call(githubCallback, { - query: { state, code: 'mock-auth-code' }, - headers: defaultHeaders, - }); + const { response: res } = await call(githubCallback, { query: { state, code: 'mock-auth-code' }, headers: defaultHeaders }); expect(res.status).toBe(302); expect(cookieChange(res, 'session')).toBe('set'); diff --git a/backend/tests/sign-in/passkey.test.ts b/backend/tests/sign-in/passkey.test.ts index d1aac65d7..97c50f108 100644 --- a/backend/tests/sign-in/passkey.test.ts +++ b/backend/tests/sign-in/passkey.test.ts @@ -50,10 +50,7 @@ describe('Passkey Authentication', async () => { const user = await createUser(signUpUser.email); await db.update(usersTable).set({ mfaRequired: true }).where(eq(usersTable.id, user.id)); - const { response: res, error } = await call(generatePasskeyChallenge, { - body: { type: 'mfa' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(generatePasskeyChallenge, { body: { type: 'mfa' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 401, 'confirm-mfa_not_found'); }); }); @@ -99,8 +96,7 @@ describe('Passkey Authentication', async () => { const victimPasskey = await insertPasskey(victim); const stored = () => db.select().from(passkeysTable).where(eq(passkeysTable.id, victimPasskey.id)); const remove = async (cookie: string) => - (await call(deletePasskey, { path: { id: victimPasskey.id }, headers: { ...defaultHeaders, Cookie: cookie } })) - .response.status; + (await call(deletePasskey, { path: { id: victimPasskey.id }, headers: { ...defaultHeaders, Cookie: cookie } })).response.status; // The delete is scoped to the caller, so it is a no-op for the attacker. expect(await remove(await createTestSession(attacker))).toBe(204); diff --git a/backend/tests/sign-in/session-cap.test.ts b/backend/tests/sign-in/session-cap.test.ts index ec4621f09..7b7579c44 100644 --- a/backend/tests/sign-in/session-cap.test.ts +++ b/backend/tests/sign-in/session-cap.test.ts @@ -15,21 +15,14 @@ overrideConfig(appConfig, { maxSessionsPerUser: TEST_CAP }); afterEach(async () => await clearDatabase()); /** A session row of `type` signed in `ageMs` ago, inserted past setUserSession so no cap applies yet. */ -const insertSession = async (user: { id: string }, type: SessionTypes, ageMs: number) => - (await insertTestSession(user, { type, ageMs })).id; +const insertSession = async (user: { id: string }, type: SessionTypes, ageMs: number) => (await insertTestSession(user, { type, ageMs })).id; /** Ids of the user's sessions that still authenticate: revoked rows stay in the table but no longer count. */ const liveIds = (userId: string, type?: SessionTypes) => db .select({ id: sessionsTable.id }) .from(sessionsTable) - .where( - and( - eq(sessionsTable.userId, userId), - isNull(sessionsTable.revokedAt), - type ? eq(sessionsTable.type, type) : undefined, - ), - ) + .where(and(eq(sessionsTable.userId, userId), isNull(sessionsTable.revokedAt), type ? eq(sessionsTable.type, type) : undefined)) .then((rows) => new Set(rows.map((r) => r.id))); describe('per-user session cap (A1)', () => { diff --git a/backend/tests/sign-in/sign-out.test.ts b/backend/tests/sign-in/sign-out.test.ts index 9980985b4..fa6d0e6a6 100644 --- a/backend/tests/sign-in/sign-out.test.ts +++ b/backend/tests/sign-in/sign-out.test.ts @@ -29,9 +29,7 @@ describe('Sign-out scoping', async () => { const forgedContent = `${forgedSecret}.${victimSessionId}.`; const forgedCookie = authCookie('session', forgedContent); - const { response: res } = await call(signOut, { - headers: { ...defaultHeaders, Cookie: forgedCookie }, - }); + const { response: res } = await call(signOut, { headers: { ...defaultHeaders, Cookie: forgedCookie } }); // The forged secret matches no session row → fail closed. expect(res.status).toBe(401); diff --git a/backend/tests/sign-in/totp.test.ts b/backend/tests/sign-in/totp.test.ts index 03d769623..aa2012036 100644 --- a/backend/tests/sign-in/totp.test.ts +++ b/backend/tests/sign-in/totp.test.ts @@ -36,9 +36,7 @@ describe('TOTP Authentication', async () => { const sessionCookie = await createTestSession(user); - const { response: res, data } = await call(generateTotpKey, { - headers: { ...defaultHeaders, Cookie: sessionCookie }, - }); + const { response: res, data } = await call(generateTotpKey, { headers: { ...defaultHeaders, Cookie: sessionCookie } }); expect(res.status).toBe(200); const response = data as { totpUri: string; manualKey: string }; @@ -52,9 +50,7 @@ describe('TOTP Authentication', async () => { const sessionCookie = await createTestSession(user); - const { response: generateRes, data: generateData } = await call(generateTotpKey, { - headers: { ...defaultHeaders, Cookie: sessionCookie }, - }); + const { response: generateRes, data: generateData } = await call(generateTotpKey, { headers: { ...defaultHeaders, Cookie: sessionCookie } }); expect(generateRes.status).toBe(200); const generatedTotp = generateData as { manualKey: string }; @@ -76,6 +72,17 @@ describe('TOTP Authentication', async () => { }); }); + describe('TOTP Setup with an authenticator app in place', () => { + it('should refuse a new key and a new authenticator app with 409', async () => { + const user = await createTotpUser(signUpUser.email); + const headers = { ...defaultHeaders, Cookie: await createTestSession(user, { authStrategy: 'totp' }) }; + + await expectRefusal(await call(generateTotpKey, { headers }), 409, 'resource_already_exists'); + await expectRefusal(await call(createTotp, { body: { code: '123456' }, headers }), 409, 'resource_already_exists'); + expect(await db.select().from(totpsTable).where(eq(totpsTable.userId, user.id))).toHaveLength(1); + }); + }); + describe('TOTP Sign-In Flow', () => { it('should sign in with valid TOTP code', async () => { const user = await createTotpUser(signUpUser.email); @@ -83,10 +90,7 @@ describe('TOTP Authentication', async () => { const { response: res } = await call(signInWithTotp, { body: { code: totpCode() }, - headers: { - ...defaultHeaders, - Cookie: authCookie('confirm-mfa', mfaToken), - }, + headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) }, }); expect(res.status).toBe(204); @@ -99,20 +103,14 @@ describe('TOTP Authentication', async () => { const { response: res, error } = await call(signInWithTotp, { body: { code: wrongTotpCode() }, - headers: { - ...defaultHeaders, - Cookie: authCookie('confirm-mfa', mfaToken), - }, + headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) }, }); await expectRefusal({ response: res, error }, 401, 'invalid_token'); }); it('should reject TOTP verification for non-existent user', async () => { - const { response: res, error } = await call(signInWithTotp, { - body: { code: '123456' }, - headers: defaultHeaders, - }); + const { response: res, error } = await call(signInWithTotp, { body: { code: '123456' }, headers: defaultHeaders }); await expectRefusal({ response: res, error }, 401, 'confirm-mfa_not_found'); }); @@ -126,10 +124,7 @@ describe('TOTP Authentication', async () => { // No TOTP registered for the user. const { response: res, error } = await call(signInWithTotp, { body: { code: totpCode() }, - headers: { - ...defaultHeaders, - Cookie: authCookie('confirm-mfa', mfaToken), - }, + headers: { ...defaultHeaders, Cookie: authCookie('confirm-mfa', mfaToken) }, }); await expectRefusal({ response: res, error }, 404, 'not_found'); diff --git a/backend/tests/software-passkey.ts b/backend/tests/software-passkey.ts index 393f73c0e..1e36f3667 100644 --- a/backend/tests/software-passkey.ts +++ b/backend/tests/software-passkey.ts @@ -2,7 +2,7 @@ import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto' import { isoCBOR } from '@simplewebauthn/server/helpers'; import { appConfig } from 'shared'; -/** The relying party ID the backend verifies against (`passkeys/helpers/passkey.ts`). */ +/** The relying party ID the backend verifies against (`passkeys/operations/passkey-challenges.ts`). */ const appRpId = appConfig.mode === 'development' ? 'localhost' : appConfig.domain; const sha256 = (data: string | Buffer) => createHash('sha256').update(data).digest(); @@ -61,9 +61,7 @@ export function softwarePasskey({ credentialId = randomBytes(16).toString('base6 const attest = (challenge: string, options: Omit = {}) => { // Flags: user present, user verified, attested credential data included. const { origin = appConfig.frontendUrl, rpId = appRpId, flags = 0x45 } = options; - const clientDataJSON = Buffer.from( - JSON.stringify({ type: 'webauthn.create', challenge, origin, crossOrigin: false }), - ); + const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge, origin, crossOrigin: false })); const idBytes = Buffer.from(credentialId, 'base64url'); const idLength = Buffer.alloc(2); idLength.writeUInt16BE(idBytes.length); diff --git a/backend/tests/task-attachments-derivation.test.ts b/backend/tests/task-attachments-derivation.test.ts index 0c4477241..744e05cf0 100644 --- a/backend/tests/task-attachments-derivation.test.ts +++ b/backend/tests/task-attachments-derivation.test.ts @@ -21,11 +21,7 @@ setTestConfig({ enabledAuthStrategies: ['passkey'] }); const projectId = generateId(); const taskId = generateId(); -const attachmentIds = { - referenced: generateId(), - keyReferenced: generateId(), - unreferenced: generateId(), -}; +const attachmentIds = { referenced: generateId(), keyReferenced: generateId(), unreferenced: generateId() }; // UUID-shaped id with no attachment row behind it (doctored block prop) const unknownId = generateId(); @@ -36,10 +32,7 @@ const mediaBlock = (type: string, url: string, attachmentId: string) => ({ children: [], }); -const updateStx = () => ({ - ...mockStxBase(`stx:${generateId()}`), - fieldTimestamps: { description: generateServerHLC('test-client') }, -}); +const updateStx = () => ({ ...mockStxBase(`stx:${generateId()}`), fieldTimestamps: { description: generateServerHLC('test-client') } }); // Covers the derived host array: task.attachments mirrors description media blocks // (attachmentId props), filtered to live in-org rows. The delete cascade is CDC-owned, @@ -136,10 +129,7 @@ describe('Task attachments derivation (owned embedding host array)', async () => const result = await putDescription(description); expect(result.response.status).toBe(200); - const [task] = await db - .select({ attachments: tasksTable.attachments }) - .from(tasksTable) - .where(eq(tasksTable.id, taskId)); + const [task] = await db.select({ attachments: tasksTable.attachments }).from(tasksTable).where(eq(tasksTable.id, taskId)); expect([...task.attachments].sort()).toEqual([attachmentIds.referenced, attachmentIds.keyReferenced].sort()); }); @@ -147,10 +137,7 @@ describe('Task attachments derivation (owned embedding host array)', async () => const result = await putDescription(''); expect(result.response.status).toBe(200); - const [task] = await db - .select({ attachments: tasksTable.attachments }) - .from(tasksTable) - .where(eq(tasksTable.id, taskId)); + const [task] = await db.select({ attachments: tasksTable.attachments }).from(tasksTable).where(eq(tasksTable.id, taskId)); expect(task.attachments).toEqual([]); }); @@ -168,10 +155,7 @@ describe('Task attachments derivation (owned embedding host array)', async () => .where(eq(attachmentsTable.projectId, projectId)); for (const row of rows) expect(row.deletedAt).toBeNull(); - const [task] = await db - .select({ deletedAt: tasksTable.deletedAt }) - .from(tasksTable) - .where(eq(tasksTable.id, taskId)); + const [task] = await db.select({ deletedAt: tasksTable.deletedAt }).from(tasksTable).where(eq(tasksTable.id, taskId)); expect(task.deletedAt).not.toBeNull(); }); }); diff --git a/backend/tests/task-mentions-derivation.test.ts b/backend/tests/task-mention-notifications.test.ts similarity index 59% rename from backend/tests/task-mentions-derivation.test.ts rename to backend/tests/task-mention-notifications.test.ts index 9bb6670e6..93891137a 100644 --- a/backend/tests/task-mentions-derivation.test.ts +++ b/backend/tests/task-mention-notifications.test.ts @@ -1,10 +1,14 @@ -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { updateTask } from 'sdk'; +import { appConfig } from 'shared'; import { generateId } from 'shared/utils/entity-id'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { generateServerHLC } from '#/core/stx'; import { getSeedDb } from '#/db/db'; +import type { ActivityEvent } from '#/lib/activity-bus'; import { membershipsTable } from '#/modules/memberships/memberships-db'; +import { notificationsTable } from '#/modules/notification/notification-db'; +import { fanOutNotifications } from '#/modules/notification/operations/fan-out'; import { projectsTable } from '#/modules/project/project-db'; import { tasksTable } from '#/modules/task/task-db'; import { TaskStatus } from '#/modules/task/task-properties'; @@ -32,20 +36,23 @@ const paragraphWithMentions = (ids: string[]) => ({ children: [], }); -const updateStx = () => ({ - ...mockStxBase(`stx:${generateId()}`), - fieldTimestamps: { description: generateServerHLC('test-client') }, -}); +const updateStx = () => ({ ...mockStxBase(`stx:${generateId()}`), fieldTimestamps: { description: generateServerHLC('test-client') } }); + +const nullAncestorScopes = Object.fromEntries( + appConfig.channelEntityTypes + .filter((channelType) => channelType !== 'organization') + .map((channelType) => [appConfig.entityIdColumnKeys[channelType], null]), +); -// Covers the task notification source: `task.mentions` is derived server-side from the stored -// description on every write and keeps only users who may read the task. -describe('Task mentions derivation (notification source)', async () => { +// Covers the task notification source: the fan-out reads mentions from the stored description and +// keeps only users who may read the task. +describe('Task mention notifications', async () => { const call = await createAppClient(); let tenant: TestTenant; let member: { id: string }; beforeAll(async () => { - tenant = await createTestTenant(call, 'task-mentions-derivation'); + tenant = await createTestTenant(call, 'task-mention-notifications'); member = await createOrgUser(call, tenant.tenantId, tenant.organization.id, 'task-mentions-member'); await db.insert(projectsTable).values({ @@ -87,6 +94,7 @@ describe('Task mentions derivation (notification source)', async () => { }); afterAll(async () => { + await db.delete(notificationsTable).where(eq(notificationsTable.subjectId, taskId)); await db.delete(tasksTable).where(eq(tasksTable.id, taskId)); await db.delete(projectsTable).where(eq(projectsTable.id, projectId)); await clearSecurityTestData(); @@ -99,20 +107,49 @@ describe('Task mentions derivation (notification source)', async () => { headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, }); - const storedMentions = async () => { - const [task] = await db.select({ mentions: tasksTable.mentions }).from(tasksTable).where(eq(tasksTable.id, taskId)); - return task.mentions; - }; + const updatedEvent = (): ActivityEvent => + // Test mock: the CDC worker fills the remaining columns; the fan-out reads only these. + ({ + id: `act:${generateId()}`, + type: 'task.updated', + action: 'update', + entityType: 'task', + resourceType: null, + tableName: 'tasks', + subjectId: taskId, + userId: tenant.user.id, + tenantId: tenant.tenantId, + organizationId: tenant.organization.id, + ...nullAncestorScopes, + projectId, + rowData: null, + seq: null, + batchUntilSeq: null, + count: null, + propagation: null, + trace: null, + stx: null, + changedFields: ['description'], + }) as unknown as ActivityEvent; + + const inboxOf = (userId: string) => + db + .select({ type: notificationsTable.type }) + .from(notificationsTable) + .where(and(eq(notificationsTable.userId, userId), eq(notificationsTable.subjectId, taskId))); - it('stores readable mentioned users and drops ids without read access', async () => { + it('mentions readable users and drops ids without read access', async () => { const result = await putDescription(JSON.stringify([paragraphWithMentions([member.id, strangerId])])); expect(result.response.status).toBe(200); - expect(await storedMentions()).toEqual([member.id]); + expect(await fanOutNotifications(updatedEvent())).toBe(true); + expect(await inboxOf(member.id)).toEqual([{ type: 'mention' }]); + expect(await inboxOf(strangerId)).toEqual([]); }); - it('clears mentions once the description no longer carries them', async () => { + it('adds no mention once the description no longer carries one', async () => { const result = await putDescription(JSON.stringify([paragraphWithMentions([])])); expect(result.response.status).toBe(200); - expect(await storedMentions()).toEqual([]); + expect(await fanOutNotifications(updatedEvent())).toBe(false); + expect(await inboxOf(member.id)).toEqual([{ type: 'mention' }]); }); }); diff --git a/backend/tests/task-seq-reads.test.ts b/backend/tests/task-seq-reads.test.ts index 3b1605a1c..786ebb86d 100644 --- a/backend/tests/task-seq-reads.test.ts +++ b/backend/tests/task-seq-reads.test.ts @@ -15,13 +15,7 @@ import { setTestConfig } from './test-utils'; setTestConfig({ enabledAuthStrategies: ['passkey'] }); const projectId = generateId(); -const taskIds = { - seq10: generateId(), - seq20: generateId(), - seq30Deleted: generateId(), - seq40OldAccepted: generateId(), - seq50: generateId(), -}; +const taskIds = { seq10: generateId(), seq20: generateId(), seq30Deleted: generateId(), seq40OldAccepted: generateId(), seq50: generateId() }; const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); @@ -68,14 +62,7 @@ describe('Task seq reads', async () => { }; const rows = [ { ...baseTask, id: taskIds.seq50, name: 'seq 50', seq: 50 }, - { - ...baseTask, - id: taskIds.seq40OldAccepted, - name: 'seq 40 old accepted', - seq: 40, - status: TaskStatus.Accepted, - updatedAt: daysAgo(30), - }, + { ...baseTask, id: taskIds.seq40OldAccepted, name: 'seq 40 old accepted', seq: 40, status: TaskStatus.Accepted, updatedAt: daysAgo(30) }, { ...baseTask, id: taskIds.seq30Deleted, name: 'seq 30 tombstone', seq: 30, deletedAt: daysAgo(1) }, { ...baseTask, id: taskIds.seq20, name: 'seq 20', seq: 20 }, { ...baseTask, id: taskIds.seq10, name: 'seq 10', seq: 10 }, diff --git a/backend/tests/tenants.test.ts b/backend/tests/tenants.test.ts new file mode 100644 index 000000000..c7dbe0354 --- /dev/null +++ b/backend/tests/tenants.test.ts @@ -0,0 +1,107 @@ +import { eq } from 'drizzle-orm'; +import { getTenants, selfCreateTenant, type Tenant, updateTenant } from 'sdk'; +import { nanoid } from 'shared/utils/nanoid'; +import { describe, expect, it } from 'vitest'; +import { baseDb as db } from '#/db/db'; +import { defaultRestrictions, type Restrictions } from '#/modules/tenants/tenant-restrictions'; +import { tenantsTable } from '#/modules/tenants/tenants-db'; +import { defaultHeaders } from './fixtures'; +import { createSystemAdminUser, createTestOrganization, createTestSession } from './helpers'; +import { createAppClient } from './test-client'; + +/** + * Stored restrictions can predate a field the schema gained. Every tenant response merges them with the current + * defaults, so one stale row cannot fail the response validator of a whole list. + */ +describe('tenant responses with stored restrictions that lack a field', async () => { + const call = await createAppClient(); + + /** A system admin session, and a tenant stored before `allowUnregisteredClients` existed. */ + const staleTenant = async () => { + const admin = await createSystemAdminUser(`tenants-${nanoid(8)}@test.com`); + const headers = { ...defaultHeaders, Cookie: await createTestSession(admin) }; + const { quotas, rateLimits } = defaultRestrictions(); + const name = `Stale tenant ${nanoid(8)}`; + const [tenant] = await db + .insert(tenantsTable) + .values({ name, restrictions: { quotas, rateLimits } as Restrictions }) + .returning(); + return { tenant, headers }; + }; + + it('lists a tenant with the default for a missing restriction', async () => { + const { tenant, headers } = await staleTenant(); + + const { data, error, response } = await call(getTenants, { query: { q: tenant.name }, headers }); + + // The SDK validates the response: a missing field fails the whole list, as it does in the frontend. + expect(error).toBeUndefined(); + expect(response.status).toBe(200); + const { items } = data as { items: { id: string; restrictions: Restrictions }[] }; + expect(items.map(({ id }) => id)).toEqual([tenant.id]); + expect(items[0].restrictions).toEqual(defaultRestrictions()); + }); + + it('updates a tenant and answers with the default for a missing restriction', async () => { + const { tenant, headers } = await staleTenant(); + const quotas = { ...defaultRestrictions().quotas, organization: 7 }; + + const renamed = await call(updateTenant, { path: { tenantId: tenant.id }, body: { name: 'Renamed' }, headers }); + const requoted = await call(updateTenant, { + path: { tenantId: tenant.id }, + body: { restrictions: { quotas } }, + headers, + }); + + expect(renamed.error).toBeUndefined(); + expect(requoted.error).toBeUndefined(); + expect((renamed.data as { restrictions: Restrictions }).restrictions).toEqual(defaultRestrictions()); + expect((requoted.data as { restrictions: Restrictions }).restrictions).toEqual({ ...defaultRestrictions(), quotas }); + // A restrictions update stores the full shape. + const [stored] = await db.select().from(tenantsTable).where(eq(tenantsTable.id, tenant.id)); + expect(stored.restrictions).toEqual({ ...defaultRestrictions(), quotas }); + }); +}); + +describe('tenant responses with the organization the tenant holds', async () => { + const call = await createAppClient(); + + const adminHeaders = async () => { + const admin = await createSystemAdminUser(`tenants-${nanoid(8)}@test.com`); + return { ...defaultHeaders, Cookie: await createTestSession(admin) }; + }; + + it('lists and updates a tenant together with its organization', async () => { + const headers = await adminHeaders(); + const organization = await createTestOrganization(); + const name = `Held tenant ${nanoid(8)}`; + + const updated = await call(updateTenant, { path: { tenantId: organization.tenantId }, body: { name }, headers }); + const listed = await call(getTenants, { query: { q: name }, headers }); + + const expected = { + id: organization.id, + name: organization.name, + slug: organization.slug, + thumbnailUrl: organization.thumbnailUrl, + entityType: 'organization', + }; + expect(updated.error).toBeUndefined(); + expect(listed.error).toBeUndefined(); + expect((updated.data as Tenant).organization).toEqual(expected); + expect((listed.data as { items: Tenant[] }).items.map((tenant) => tenant.organization)).toEqual([expected]); + }); + + it('answers a self-created tenant without an organization', async () => { + const headers = await adminHeaders(); + + const created = await call(selfCreateTenant, { body: { name: `Own tenant ${nanoid(8)}` }, headers }); + // A retry reuses the orphan tenant and answers with it again. + const retried = await call(selfCreateTenant, { body: { name: `Own tenant ${nanoid(8)}` }, headers }); + + expect(created.error).toBeUndefined(); + expect((created.data as Tenant).organization).toBeNull(); + expect((retried.data as Tenant).id).toBe((created.data as Tenant).id); + expect((retried.data as Tenant).organization).toBeNull(); + }); +}); diff --git a/backend/tests/test-client.ts b/backend/tests/test-client.ts index 5ead9b630..ada62eaa6 100644 --- a/backend/tests/test-client.ts +++ b/backend/tests/test-client.ts @@ -10,10 +10,7 @@ export type TestResult = /** SDK client wired to Hono's in-process app.fetch(); no HTTP server. */ export function createTestClient(app: AppLike): Client { return createClient( - createConfig({ - baseUrl: 'http://localhost', - fetch: ((req: Request | string | URL) => app.fetch(req as Request)) as typeof fetch, - }), + createConfig({ baseUrl: 'http://localhost', fetch: ((req: Request | string | URL) => app.fetch(req as Request)) as typeof fetch }), ); } diff --git a/backend/tests/test-utils.ts b/backend/tests/test-utils.ts index 7b21a8e01..fc8de674e 100644 --- a/backend/tests/test-utils.ts +++ b/backend/tests/test-utils.ts @@ -10,20 +10,58 @@ import { overrideConfig } from './fixtures'; type AuthStrategy = 'passkey' | 'oauth' | 'totp' | 'magic'; type OAuthProvider = 'github' | 'google' | 'microsoft'; -type ConfigOverride = { - enabledAuthStrategies?: AuthStrategy[]; - enabledOAuthProviders?: OAuthProvider[]; - selfRegistration?: boolean; -}; +type ConfigOverride = { enabledAuthStrategies?: AuthStrategy[]; enabledOAuthProviders?: OAuthProvider[]; selfRegistration?: boolean }; -/** TRUNCATE CASCADE on the admin connection (runtime_role holds no TRUNCATE), plus a mock-enforcer reset so unique values do not conflict across tests. */ +/** Empties the auth tables and everything that references them, plus a mock-enforcer reset so unique values do not conflict across tests. */ export async function clearDatabase() { resetUserMockEnforcers(); resetOrganizationMockEnforcers(); - await getAdminDb('test cleanup').execute(sql`TRUNCATE TABLE - sessions, tokens, passkeys, identities, emails, users, api_keys, service_accounts, actors, oidc_payloads, oauth_clients - CASCADE`); + await emptyTables([ + 'sessions', + 'tokens', + 'passkeys', + 'identities', + 'emails', + 'users', + 'api_keys', + 'service_accounts', + 'actors', + 'oidc_payloads', + 'oauth_clients', + ]); +} + +/** Per root list: the roots and every table a chain of foreign keys ties to them, the set `TRUNCATE ... CASCADE` reaches. */ +const cascadeSets = new Map(); + +/** + * Empties `roots` and every table referencing them, on the admin connection (runtime_role may not). Deleting a test's + * few rows takes ~10ms; TRUNCATE gives each of the ~25 tables new files and takes ~400ms, after every test. + * `replica` skips row triggers and foreign key checks, as TRUNCATE does. + */ +export async function emptyTables(roots: string[]) { + const db = getAdminDb('test cleanup'); + const key = roots.join(); + let tables = cascadeSets.get(key); + if (!tables) { + const { rows } = await db.execute<{ name: string }>(sql` + WITH RECURSIVE cascade_set(oid) AS ( + SELECT oid FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relname IN ${roots} + UNION + SELECT con.conrelid FROM pg_constraint con JOIN cascade_set ON con.confrelid = cascade_set.oid WHERE con.contype = 'f' + ) + SELECT DISTINCT format('%I.%I', n.nspname, c.relname) AS name + FROM cascade_set JOIN pg_class c ON c.oid = cascade_set.oid JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT c.relispartition`); + tables = rows.map((row) => row.name); + cascadeSets.set(key, tables); + } + const statements = tables.map((table) => `DELETE FROM ${table};`).join('\n'); + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL session_replication_role = replica`); + await tx.execute(sql.raw(statements)); + }); } /** Vitest hoists vi.mock(), so call at top level: vi.mock('#/middlewares/rate-limiter/core', rateLimiterCoreMock) */ @@ -98,10 +136,7 @@ export const sessionMock = async (importOriginal: () => Promise) => ({ ...(await importOriginal()), setUserSession: vi.fn().mockImplementation(async (ctx, _user, _provider) => { const sessionToken = 'mock-session-token'; - ctx.res.headers.append( - 'set-cookie', - `${mockCookieName('session')}=${sessionToken}; Path=/; HttpOnly; SameSite=Lax`, - ); + ctx.res.headers.append('set-cookie', `${mockCookieName('session')}=${sessionToken}; Path=/; HttpOnly; SameSite=Lax`); return sessionToken; }), resolveSession: vi.fn().mockResolvedValue({ user: { id: 'test-user-id' }, session: { id: 'test-session-id' } }), diff --git a/backend/tests/yjs-retire.test.ts b/backend/tests/yjs-retire.test.ts index 86772d486..8cd6e1ada 100644 --- a/backend/tests/yjs-retire.test.ts +++ b/backend/tests/yjs-retire.test.ts @@ -52,10 +52,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs document retireme .select({ generation: yjsDocumentsTable.generation }) .from(yjsDocumentsTable) .where(eq(yjsDocumentsTable.entityId, attachment.id)); - const log = await adminDb - .select({ id: yjsUpdatesTable.id }) - .from(yjsUpdatesTable) - .where(eq(yjsUpdatesTable.entityId, attachment.id)); + const log = await adminDb.select({ id: yjsUpdatesTable.id }).from(yjsUpdatesTable).where(eq(yjsUpdatesTable.entityId, attachment.id)); return { docs: docs.length, log: log.length }; }; @@ -64,10 +61,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs document retireme path: { organizationId: tenant.organization.id, tenantId: tenant.tenantId, id: attachment.id }, body: { ops: { description }, - stx: { - ...mockStxBase(`stx:${generateId()}`), - fieldTimestamps: { description: generateServerHLC('test-client') }, - }, + stx: { ...mockStxBase(`stx:${generateId()}`), fieldTimestamps: { description: generateServerHLC('test-client') } }, }, headers: { ...defaultHeaders, Cookie: tenant.sessionCookie }, }); @@ -109,11 +103,7 @@ describe.skipIf(appConfig.services.yjs.enabled === false)('Yjs document retireme it("keeps the document across the relay's own write and an update that leaves the description as it is (positive control)", async () => { await seedDocument(); - await materializeDescriptionOp({ - ...scope(), - description: paragraph('written by the relay'), - editors: [tenant.user.id], - }); + await materializeDescriptionOp({ ...scope(), description: paragraph('written by the relay'), editors: [tenant.user.id] }); expect(await documentRows()).toEqual({ docs: 1, log: 1 }); const stored = (await attachment.read())?.description; diff --git a/backend/tsup.config.ts b/backend/tsup.config.ts index 1413d9a0f..f9b3ab85c 100644 --- a/backend/tsup.config.ts +++ b/backend/tsup.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from 'tsup'; import { keepOnDisk } from '../shared/src/keep-on-disk.ts'; import { appKeepOnDisk } from './src/bundle-config.ts'; +import pkg from './package.json' with { type: 'json' }; // @ngrok/ngrok: native addon, loaded by platform-specific .node file. -const { noExternal, external } = keepOnDisk(['@ngrok/ngrok', ...appKeepOnDisk]); +const { noExternal, external } = keepOnDisk(['@ngrok/ngrok', ...appKeepOnDisk], pkg.dependencies); export default defineConfig({ entry: { diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index eb8c66528..3fd3af9e2 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -27,11 +27,12 @@ export default defineConfig({ clearMocks: true, testTimeout: 30000, hookTimeout: 30000, - fileParallelism: false, pool: 'threads', include: includePatterns, exclude: excludePatterns, env: { + // Files run in parallel, each worker on its own database (tests/global-setup.ts, tests/setup.ts). + TEST_DB_PER_WORKER: 'true', PINO_LOG_LEVEL: 'silent', NODE_ENV: 'test', COOKIE_SECRET: 'test-cookie-secret-for-unit-tests', diff --git a/bench/README.md b/bench/README.md index b209fafa2..ff7c45055 100644 --- a/bench/README.md +++ b/bench/README.md @@ -27,13 +27,14 @@ Start these first (bench checks they are reachable and exits with guidance if no | `pnpm db:seed` | Seed test data (idempotent, cleans first) | | `pnpm db:teardown` | Remove all bench data (baselines are kept) | -`--all` adds a short cooldown between scenarios. A single-scenario run stays verbose with a live comparison table. The Vitest smoke test `bench/src/tests/all-scenarios.test.ts` runs `--all --short` to catch broken scenarios and skips itself when the stack is down. +`--all` waits 15 seconds between scenarios so a saturating one does not slow the next. A single-scenario run stays verbose with a live comparison table. The Vitest smoke test `bench/src/tests/all-scenarios.test.ts` runs `--all --short` to catch broken scenarios and skips itself when the stack is down. ## Interpreting results Bench measures the live dev stack. Before calling a result a regression: -- **Cache warm-up.** The auth guard caches sessions in-process (1 min TTL) and memberships separately (5 min TTL). Runs shorter than the session TTL include cold-cache `validateSession` hits. +- **Auth reads.** A session is read once per 10 seconds per browser and a token at every request. Memberships are cached per process until they change, so the first request of each user in a run also reads its memberships. - **Per-mutation RLS transactions.** Each write wraps permission check + update in one short transaction that also sets tenant/user GUCs. The write ceiling is pool size (`DATABASE_POOL_MAX`) and DB round-trip latency, not handler CPU alone. -- **Rate limiting is effectively off.** The seeded bench tenant has a very high `apiPointsPerHour`, and the points limiter has an in-process fast path. +- **Rate limiting is effectively off.** The seeded bench tenant has a very high `apiPointsPerHour`, the points limiter has an in-process fast path, and every scenario starts with the bench users' per-user budgets (stream connects, sync reads) cleared. +- **Saturation.** At their configured arrival rates `attachment-edit`, `cdc-attachment` and `page-load` saturate a laptop that also runs the stack, so their thresholds fail on most runs. Compare medians and the trend between runs. - **Telemetry is off without a key.** OpenTelemetry exports only when `MAPLE_SECRET_INGEST_KEY` is set. diff --git a/bench/package.json b/bench/package.json index 27ffe56bb..18b0fdfc7 100644 --- a/bench/package.json +++ b/bench/package.json @@ -2,6 +2,7 @@ "name": "bench", "version": "0.0.1", "private": true, + "type": "module", "description": "Artillery load-testing scenarios and tooling", "scripts": { "bench": "tsx src/bench-cli.ts", @@ -13,7 +14,7 @@ "@inquirer/prompts": "^8.7.2", "nanoid": "^6.0.1", "ora": "^9.4.1", - "pg": "^8.23.0", + "pg": "^8.23.1", "picocolors": "^1.1.1", "shared": "workspace:*", "uuidv7": "^1.2.1" @@ -24,6 +25,6 @@ "artillery": "^2.0.34", "artillery-plugin-ensure": "^1.27.0", "tsx": "^4.23.15", - "typescript": "^6.0.3" + "typescript": "6.0.3" } } diff --git a/bench/src/bench-cli.ts b/bench/src/bench-cli.ts index 64e8b2086..fb0c26f7a 100644 --- a/bench/src/bench-cli.ts +++ b/bench/src/bench-cli.ts @@ -10,14 +10,15 @@ import ora from 'ora'; import pg from 'pg'; import { pc } from 'shared/cli-utils/colors'; import { printHeader } from 'shared/cli-utils/display'; +import { BENCH_UUID_PREFIX } from 'shared/utils/bench-identity'; import { createBenchProcessEnv, DB_URL } from './config'; import { isPostgresReady, isServiceHealthy, SERVICES } from './preflight'; const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)); const BENCH_ROOT = resolve(__dirname, '..'); -/** Cooldown between scenarios in `--all` mode so load settles between runs. */ -const PAUSE_SECONDS = 5; +/** Cooldown between scenarios in `--all` mode: after a saturating scenario, 5s left sse-fanout's p95 ten times higher. */ +const PAUSE_SECONDS = 15; // ── CLI args ─────────────────────────────────────────────────────────────── @@ -92,9 +93,7 @@ async function assertInfrastructureReady(): Promise { if (!(await isPostgresReady())) { spinner.fail('postgres is not reachable'); const { hostname, port } = new URL(DB_URL); - console.error( - pc.dim(` Expected Postgres at ${hostname}:${port}. Start it with \`pnpm docker\` and seed with \`pnpm seed\`.`), - ); + console.error(pc.dim(` Expected Postgres at ${hostname}:${port}. Start it with \`pnpm docker\` and seed with \`pnpm seed\`.`)); process.exit(1); } @@ -114,10 +113,11 @@ async function assertInfrastructureReady(): Promise { spinner.succeed('infrastructure ready'); } +/** Password attempts and every budget keyed by a bench user: per-user limits (stream connects, sync reads) span runs. */ async function clearRateLimits(): Promise { const pool = new pg.Pool({ connectionString: DB_URL }); try { - await pool.query("DELETE FROM rate_limits WHERE key LIKE 'password_%'"); + await pool.query("DELETE FROM rate_limits WHERE key LIKE 'password_%' OR strpos(key, $1) > 0", [BENCH_UUID_PREFIX]); } catch { // Table may not exist on first run } finally { @@ -130,16 +130,11 @@ async function clearRateLimits(): Promise { function seedDatabase(): Promise<{ output: string }> { return new Promise((resolve, reject) => { const chunks: string[] = []; - const child = spawn('tsx', ['src/data-setup.ts'], { - cwd: BENCH_ROOT, - stdio: ['ignore', 'pipe', 'pipe'], - }); + const child = spawn('tsx', ['src/data-setup.ts'], { cwd: BENCH_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }); child.stdout?.on('data', (data: Buffer) => chunks.push(data.toString())); child.stderr?.on('data', (data: Buffer) => chunks.push(data.toString())); child.on('close', (code) => - code === 0 - ? resolve({ output: chunks.join('') }) - : reject(new Error(`db:seed exited with code ${code}\n${chunks.join('')}`)), + code === 0 ? resolve({ output: chunks.join('') }) : reject(new Error(`db:seed exited with code ${code}\n${chunks.join('')}`)), ); child.on('error', reject); }); @@ -178,12 +173,8 @@ function runArtillery( if (!quiet) { console.error(`\n${pc.red('✗')} artillery exited with code ${code}`); } else { - const output = [ - String((err as { stdout?: string }).stdout ?? ''), - String((err as { stderr?: string }).stderr ?? ''), - ] - .join('\n') - .trim(); + const { stdout, stderr } = err as { stdout?: string; stderr?: string }; + const output = [String(stdout ?? ''), String(stderr ?? '')].join('\n').trim(); if (output) { const lines = output.split('\n').slice(-40).join('\n'); console.error(`\n${pc.red('✗')} ${name} artillery output:\n${lines}\n`); @@ -195,10 +186,7 @@ function runArtillery( /** Samples CDC throughput and latency for every scenario, summarizing only when CDC processed events. A separate process, since `runArtillery` blocks the event loop on `execFileSync`. */ function startCdcPoller(): { proc: ChildProcess; summary: Promise } { - const proc = spawn('tsx', ['src/cdc-poller.ts', '--quiet'], { - cwd: BENCH_ROOT, - stdio: ['ignore', 'pipe', 'ignore'], - }); + const proc = spawn('tsx', ['src/cdc-poller.ts', '--quiet'], { cwd: BENCH_ROOT, stdio: ['ignore', 'pipe', 'ignore'] }); const chunks: string[] = []; proc.stdout?.on('data', (data: Buffer) => chunks.push(data.toString())); const summary = new Promise((res) => proc.on('close', () => res(chunks.join('')))); @@ -311,24 +299,9 @@ function printComparison(current: BaselineMetrics, baseline: BaselineMetrics | n baseline ? String(baseline.requestRate) : '-', baseline ? formatDelta(current.requestRate, baseline.requestRate, false) : '', ], - [ - 'Mean (ms)', - String(current.mean), - baseline ? String(baseline.mean) : '-', - baseline ? formatDelta(current.mean, baseline.mean, true) : '', - ], - [ - 'p95 (ms)', - String(current.p95), - baseline ? String(baseline.p95) : '-', - baseline ? formatDelta(current.p95, baseline.p95, true) : '', - ], - [ - 'p99 (ms)', - String(current.p99), - baseline ? String(baseline.p99) : '-', - baseline ? formatDelta(current.p99, baseline.p99, true) : '', - ], + ['Mean (ms)', String(current.mean), baseline ? String(baseline.mean) : '-', baseline ? formatDelta(current.mean, baseline.mean, true) : ''], + ['p95 (ms)', String(current.p95), baseline ? String(baseline.p95) : '-', baseline ? formatDelta(current.p95, baseline.p95, true) : ''], + ['p99 (ms)', String(current.p99), baseline ? String(baseline.p99) : '-', baseline ? formatDelta(current.p99, baseline.p99, true) : ''], ['Errors', String(current.errors), baseline ? String(baseline.errors) : '-', ''], ['VUs failed', String(current.vusersFailed), baseline ? String(baseline.vusersFailed) : '-', ''], ]; @@ -406,10 +379,7 @@ interface ScenarioResult { } /** Runs the CDC poller, Artillery, and the baseline compare or save. Short runs produce no comparable metrics and never touch baselines; `quiet` leaves output to the caller's combined summary. */ -async function runScenario( - name: string, - { short, quiet }: { short: boolean; quiet: boolean }, -): Promise { +async function runScenario(name: string, { short, quiet }: { short: boolean; quiet: boolean }): Promise { const cdcPoller = startCdcPoller(); const stopPoller = () => cdcPoller.proc.kill('SIGINT'); registerCleanup(stopPoller); @@ -505,10 +475,7 @@ async function main() { selected = cliScenario; } else { const choices = [ - ...scenarios.map((name) => ({ - value: name, - name: `${name.padEnd(22)}${pc.dim(scenarioDescription(name))}`, - })), + ...scenarios.map((name) => ({ value: name, name: `${name.padEnd(22)}${pc.dim(scenarioDescription(name))}` })), { type: 'separator' as const, separator: '─'.repeat(40) }, { value: 'exit', name: pc.red(`exit${' '.repeat(18)}${pc.dim('quit without running')}`) }, ]; @@ -540,11 +507,7 @@ async function main() { if (seedPromise) await seedPromise; - // ── 3. Clear rate limits ── - - await clearRateLimits(); - - // ── 4. Run scenario(s) ── + // ── 3. Run scenario(s), each from cleared rate limits ── const toRun = all ? scenarios : [selected]; // --all prints one combined summary; a single scenario stays verbose with live output and a comparison table. @@ -557,6 +520,7 @@ async function main() { try { for (let i = 0; i < toRun.length; i++) { const name = toRun[i]; + await clearRateLimits(); const result = await runScenario(name, { short, quiet }); results.push({ name, result }); if (result.exitCode !== 0) failureCode = result.exitCode; diff --git a/bench/src/cdc-poller.ts b/bench/src/cdc-poller.ts index ca7d6c13a..6eb025f37 100644 --- a/bench/src/cdc-poller.ts +++ b/bench/src/cdc-poller.ts @@ -30,12 +30,7 @@ async function poll(state: PollState, quiet: boolean) { if (!res.ok) return; const body = (await res.json()) as { - metrics?: { - eventsProcessed: number; - processingLatency?: { p95?: number }; - walLagBytes?: number; - batchSize?: { avg?: number }; - }; + metrics?: { eventsProcessed: number; processingLatency?: { p95?: number }; walLagBytes?: number; batchSize?: { avg?: number } }; }; const m = body.metrics; if (!m) return; @@ -98,10 +93,8 @@ async function main() { const state: PollState = { prevEvents: 0, prevTime: 0, samples: [] }; if (!quiet) { - console.info( - `${pc.cyan('⧈ CDC poller')} polling ${CDC_HEALTH_URL} every ${interval}s` + - (duration > 0 ? ` for ${duration}s` : ''), - ); + const limit = duration > 0 ? ` for ${duration}s` : ''; + console.info(`${pc.cyan('⧈ CDC poller')} polling ${CDC_HEALTH_URL} every ${interval}s${limit}`); } const timer = setInterval(() => poll(state, quiet), interval * 1000); diff --git a/bench/src/config.ts b/bench/src/config.ts index 5ab48c0c0..5d9ea712c 100644 --- a/bench/src/config.ts +++ b/bench/src/config.ts @@ -6,14 +6,16 @@ try { process.loadEnvFile(new URL('../../backend/.env', import.meta.url)); } catch {} -/** Derived from `appConfig` and `backend/.env` so bench follows the app's port offset. Dev-only, local stack. */ +/** Derived from `appConfig.devPorts` and `backend/.env` so bench follows the app's port offset. Dev-only, local stack. */ // Measures the backend port directly: the Vite proxy serializes requests and resets connections. The configured mount path is preserved so API routes resolve. const backendMountPath = new URL(appConfig.backendUrl).pathname.replace(/\/$/, ''); // biome-ignore lint/style/noProcessEnv: bench reads the app's backend PORT from backend/.env here. -export const BACKEND_PORT = Number(process.env.PORT ?? '4000'); +export const BACKEND_PORT = Number(process.env.PORT ?? appConfig.devPorts.api); export const BASE_URL = `http://localhost:${BACKEND_PORT}${backendMountPath}`; -export const CDC_HEALTH_URL = `http://localhost:${BACKEND_PORT + 1}/health?depth=full`; +// biome-ignore lint/style/noProcessEnv: bench reads the cdc worker's CDC_HEALTH_PORT override like the worker does. +export const CDC_HEALTH_PORT = Number(process.env.CDC_HEALTH_PORT ?? appConfig.devPorts.cdcHealth); +export const CDC_HEALTH_URL = `http://localhost:${CDC_HEALTH_PORT}/health?depth=full`; export const SESSION_COOKIE_NAME = `${appConfig.slug}-session-${appConfig.cookieVersion}`; diff --git a/bench/src/preflight.ts b/bench/src/preflight.ts index aa61a443b..07808470c 100644 --- a/bench/src/preflight.ts +++ b/bench/src/preflight.ts @@ -1,13 +1,12 @@ import pg from 'pg'; import { appConfig } from 'shared'; -import { BACKEND_PORT, BASE_URL, DB_URL } from './config'; +import { BASE_URL, CDC_HEALTH_PORT, DB_URL } from './config'; -// Only services the app runs are health-checked: cdc, yjs, and mcp are skipped when disabled in appConfig.services. +// Only the services the scenarios use are health-checked: the API, and the cdc worker when the app runs it. Yjs and +// mcp stay out, so a stack without them (or the test config, which turns them on) does not skip or block a run. export const SERVICES = { backend: `${BASE_URL}/health`, - ...(appConfig.services.cdc.enabled !== false ? { cdc: `http://localhost:${BACKEND_PORT + 1}/health` } : {}), - ...(appConfig.services.yjs.enabled !== false ? { yjs: `http://localhost:${BACKEND_PORT + 2}/health` } : {}), - ...(appConfig.services.mcp.enabled !== false ? { mcp: `http://localhost:${BACKEND_PORT + 3}/health` } : {}), + ...(appConfig.services.cdc.enabled !== false && { cdc: `http://localhost:${CDC_HEALTH_PORT}/health` }), } as const; export async function isPostgresReady(): Promise { diff --git a/bench/src/processors/attachment-edit.ts b/bench/src/processors/attachment-edit.ts index 69817d51e..4f84105e8 100644 --- a/bench/src/processors/attachment-edit.ts +++ b/bench/src/processors/attachment-edit.ts @@ -24,20 +24,12 @@ function hlcTimestamp(sourceId: string, counter = 0): string { } /** Builds attachment name-edit payloads and sets Artillery context variables. */ -export function buildAttachmentEditPayload( - context: { vars: Record }, - _events: unknown, - done: () => void, -) { +export function buildAttachmentEditPayload(context: { vars: Record }, _events: unknown, done: () => void) { const userIndex = (context.vars.userIndex as number) ?? 0; const aId = attachmentId(userIndex % TOTAL_ATTACHMENTS); const sourceId = uuidv7(); - const stx: StxPayload = { - mutationId: uuidv7(), - sourceId, - fieldTimestamps: { name: hlcTimestamp(sourceId) }, - }; + const stx: StxPayload = { mutationId: uuidv7(), sourceId, fieldTimestamps: { name: hlcTimestamp(sourceId) } }; context.vars.tenantId = TENANT_ID; context.vars.orgId = ORG_ID; diff --git a/bench/src/processors/sse-fanout.ts b/bench/src/processors/sse-fanout.ts index c110e4af3..6ed0b91ef 100644 --- a/bench/src/processors/sse-fanout.ts +++ b/bench/src/processors/sse-fanout.ts @@ -34,10 +34,7 @@ function hashSpread(key: string): number { } /** Merges app-stream ranges per scope and fetches after deterministic sync-window jitter; immediate mode fetches each notification as the comparison baseline. */ -export async function subscribeAndReact( - context: { vars: Record }, - events: ArtilleryEvents, -): Promise { +export async function subscribeAndReact(context: { vars: Record }, events: ArtilleryEvents): Promise { const cookie = context.vars.cookie as string; const clientId = `vu-${context.vars.userIndex}`; @@ -48,9 +45,7 @@ export async function subscribeAndReact( const deltaFetch = async (from: number, until: number) => { const started = Date.now(); try { - const res = await fetch(`${BASE_URL}/${TENANT_ID}/${ORG_ID}/attachments?seqCursor=${from},${until}&limit=1000`, { - headers: { cookie }, - }); + const res = await fetch(`${BASE_URL}/${TENANT_ID}/${ORG_ID}/attachments?seqCursor=${from},${until}&limit=1000`, { headers: { cookie } }); await res.json(); events.emit('histogram', 'sync.fetch_ms', Date.now() - started); events.emit('counter', 'sync.delta_fetches', 1); @@ -101,10 +96,7 @@ export async function subscribeAndReact( try { const started = Date.now(); - const res = await fetch(`${BASE_URL}/entities/app/stream`, { - headers: { cookie, accept: 'text/event-stream' }, - signal: controller.signal, - }); + const res = await fetch(`${BASE_URL}/entities/app/stream`, { headers: { cookie, accept: 'text/event-stream' }, signal: controller.signal }); if (!res.ok || !res.body) { events.emit('counter', 'sse.errors', 1); return; diff --git a/bench/src/processors/task-edits.ts b/bench/src/processors/task-edits.ts index 5e5c97571..e7720d508 100644 --- a/bench/src/processors/task-edits.ts +++ b/bench/src/processors/task-edits.ts @@ -53,10 +53,7 @@ function buildAssignedToEdit(): EditPayload { } function buildDisplayOrderEdit(): EditPayload { - return { - ops: { displayOrder: Math.random() * 1000 }, - stx: buildStx(['displayOrder']), - }; + return { ops: { displayOrder: Math.random() * 1000 }, stx: buildStx(['displayOrder']) }; } function buildStatusEdit(): EditPayload { @@ -74,10 +71,7 @@ function buildDescriptionEdit(): EditPayload { '[{"type":"paragraph","content":[{"type":"text","text":"Bug report: users experiencing slow load times on the dashboard."}]}]', '[{"type":"paragraph","content":[{"type":"text","text":"Feature spec v2: add support for bulk operations on task lists."}]}]', ]; - return { - ops: { description: randomChoice(descriptions) }, - stx: buildStx(['description']), - }; + return { ops: { description: randomChoice(descriptions) }, stx: buildStx(['description']) }; } /** @@ -85,9 +79,4 @@ function buildDescriptionEdit(): EditPayload { * Each produces an `ops` + `stx` payload matching the sync mutation contract * for a single field edit. */ -export const allEditBuilders: EditBuilder[] = [ - buildAssignedToEdit, - buildDisplayOrderEdit, - buildStatusEdit, - buildDescriptionEdit, -]; +export const allEditBuilders: EditBuilder[] = [buildAssignedToEdit, buildDisplayOrderEdit, buildStatusEdit, buildDescriptionEdit]; diff --git a/bench/src/seeds/attachment.bench.ts b/bench/src/seeds/attachment.bench.ts index 526341e97..cea61f706 100644 --- a/bench/src/seeds/attachment.bench.ts +++ b/bench/src/seeds/attachment.bench.ts @@ -1,3 +1,4 @@ +import { appConfig, hierarchy } from 'shared'; import type { InsertAttachmentModel } from '#/modules/attachment/attachment-db'; import { mockAttachment } from '#/modules/attachment/attachment-mocks'; import { registerBenchSeed } from '../registry'; @@ -22,6 +23,8 @@ export const loadtestAttachment = (index: number): InsertAttachmentModel => ({ bucketName: 'attachments', keys: { original: `uploads/xbench/${attachmentId(index)}/xbench-file-${index}.pdf` }, organizationId: ORG_ID, + // Org-homed: the mock invents ids for every ancestor, and nullable ones would reference no seeded channel. + ...Object.fromEntries(hierarchy.getNullableAncestors('attachment').map((type) => [appConfig.entityIdColumnKeys[type], null])), // fork: attachments are project-homed (FK on project_id), so each row lands in a seeded bench project projectId: projectId(index % TOTAL_PROJECTS), createdBy: userId(index % 100), @@ -32,8 +35,6 @@ registerBenchSeed({ table: 'attachments', // fork: after projects (order 110), the attachment home order: 115, - pgArrayColumns: ['mentions'], idVariant: CORE_ID_VARIANTS.attachment, - rows: ({ now }) => - Array.from({ length: TOTAL_ATTACHMENTS }, (_, i) => ({ ...loadtestAttachment(i), createdAt: now, seq: 0 })), + rows: ({ now }) => Array.from({ length: TOTAL_ATTACHMENTS }, (_, i) => ({ ...loadtestAttachment(i), createdAt: now, seq: 0 })), }); diff --git a/bench/src/seeds/email.bench.ts b/bench/src/seeds/email.bench.ts index ef15b439c..ee810ab65 100644 --- a/bench/src/seeds/email.bench.ts +++ b/bench/src/seeds/email.bench.ts @@ -7,6 +7,5 @@ registerBenchSeed({ table: 'emails', order: 30, idVariant: CORE_ID_VARIANTS.email, - rows: ({ now }) => - Array.from({ length: TOTAL_USERS }, (_, i) => ({ ...loadtestEmail(i), verifiedAt: now, createdAt: now })), + rows: ({ now }) => Array.from({ length: TOTAL_USERS }, (_, i) => ({ ...loadtestEmail(i), verifiedAt: now, createdAt: now })), }); diff --git a/bench/src/seeds/membership.ts b/bench/src/seeds/membership.ts index f2e975a6b..f369a3f06 100644 --- a/bench/src/seeds/membership.ts +++ b/bench/src/seeds/membership.ts @@ -3,11 +3,7 @@ import { mockChannelMembership } from '#/modules/memberships/memberships-mocks'; import { ORG_ID, TENANT_ID, userId } from './ids'; export const loadtestOrgMembership = (userIndex: number): InsertMembershipModel => { - const membership = mockChannelMembership( - 'organization', - { id: ORG_ID, tenantId: TENANT_ID }, - { id: userId(userIndex) }, - ); + const membership = mockChannelMembership('organization', { id: ORG_ID, tenantId: TENANT_ID }, { id: userId(userIndex) }); return { ...membership, role: 'admin', diff --git a/bench/src/seeds/project.bench.ts b/bench/src/seeds/project.bench.ts index a851c457d..0d316a279 100644 --- a/bench/src/seeds/project.bench.ts +++ b/bench/src/seeds/project.bench.ts @@ -12,9 +12,7 @@ export const TOTAL_PROJECTS = 10; * so it is stripped (the registry derives INSERT columns from the row's keys). */ export const loadtestProject = (index: number): InsertProjectModel => { - const { description: _description, ...record } = mockProject(`lt-${index}`) as InsertProjectModel & { - description?: unknown; - }; + const { description: _description, ...record } = mockProject(`lt-${index}`) as InsertProjectModel & { description?: unknown }; return { ...record, id: projectId(index), diff --git a/bench/src/seeds/session-auth.ts b/bench/src/seeds/session-auth.ts index 7f2845da3..4ef63ca57 100644 --- a/bench/src/seeds/session-auth.ts +++ b/bench/src/seeds/session-auth.ts @@ -15,12 +15,7 @@ export function hashToken(token: string): string { * `..`, the MAC covering the versioned cookie name, the expiry and the content. The app signs * cookies in every mode, so an unsigned one never authenticates. Signed with the first `COOKIE_SECRET` entry. */ -export function sealSessionCookie( - versionedName: string, - content: string, - cookieSecret: string, - maxAgeSeconds: number, -): string { +export function sealSessionCookie(versionedName: string, content: string, cookieSecret: string, maxAgeSeconds: number): string { const secret = cookieSecret.split(',')[0].trim(); const expiresAt = Math.floor(Date.now() / 1000) + maxAgeSeconds; const mac = createHmac('sha256', secret).update(`${versionedName}\n${expiresAt}\n${content}`).digest('base64url'); diff --git a/bench/src/seeds/task.bench.ts b/bench/src/seeds/task.bench.ts index 317841b86..659b00133 100644 --- a/bench/src/seeds/task.bench.ts +++ b/bench/src/seeds/task.bench.ts @@ -19,11 +19,7 @@ export const loadtestTask = (index: number): InsertTaskModel => { deletedAt: _deletedAt, deletedBy: _deletedBy, ...record - } = mockTask(`task:loadtest:${index}`) as InsertTaskModel & { - statusChangedAt?: unknown; - deletedAt?: unknown; - deletedBy?: unknown; - }; + } = mockTask(`task:loadtest:${index}`) as InsertTaskModel & { statusChangedAt?: unknown; deletedAt?: unknown; deletedBy?: unknown }; return { ...record, id: taskId(index), @@ -52,11 +48,11 @@ export const loadtestTask = (index: number): InsertTaskModel => { }; // Seeds after projects (order 110): tasks FK-reference a project. `labels`, -// `assigned_to`, `attachments` and `mentions` are native Postgres arrays (see `pgArrayColumns`). +// `assigned_to` and `attachments` are native Postgres arrays (see `pgArrayColumns`). registerBenchSeed({ table: 'tasks', order: 120, - pgArrayColumns: ['labels', 'assigned_to', 'attachments', 'mentions'], + pgArrayColumns: ['labels', 'assigned_to', 'attachments'], idVariant: CORE_ID_VARIANTS.task, rows: ({ now }) => Array.from({ length: TOTAL_TASKS }, (_, i) => ({ ...loadtestTask(i), createdAt: now, seq: 0 })), }); diff --git a/bench/src/seeds/tenant.bench.ts b/bench/src/seeds/tenant.bench.ts index 1cf31ca90..a518a86ff 100644 --- a/bench/src/seeds/tenant.bench.ts +++ b/bench/src/seeds/tenant.bench.ts @@ -1,3 +1,4 @@ +import { defaultRestrictions } from '#/modules/tenants/tenant-restrictions'; import { registerBenchSeed } from '../registry'; import { TENANT_ID } from './ids'; @@ -9,8 +10,14 @@ registerBenchSeed({ await client.query('DELETE FROM tenants WHERE id = $1', [TENANT_ID]); }, seed: async ({ now, pool }) => { - // attachment 0 means unlimited: the seeded 500 attachments exceed the default org quota of 100 and would 429 every create. - const restrictions = JSON.stringify({ quotas: { attachment: 0 }, rateLimits: { apiPointsPerHour: 10_000_000 } }); + // The full current shape, so the tenants list validates. attachment 0 means unlimited: the seeded 500 attachments + // exceed the default org quota of 100 and would 429 every create. + const defaults = defaultRestrictions(); + const restrictions = JSON.stringify({ + ...defaults, + quotas: { ...defaults.quotas, attachment: 0 }, + rateLimits: { apiPointsPerHour: 10_000_000 }, + }); await pool.query( 'INSERT INTO tenants (id, name, restrictions, created_at) VALUES ($1, $2, $3::jsonb, $4) ON CONFLICT (id) DO UPDATE SET restrictions = $3::jsonb', [TENANT_ID, 'Load Test Tenant', restrictions, now], diff --git a/bench/src/seeds/user.bench.ts b/bench/src/seeds/user.bench.ts index 3e7dc5a8f..787bd2984 100644 --- a/bench/src/seeds/user.bench.ts +++ b/bench/src/seeds/user.bench.ts @@ -1,4 +1,4 @@ -import { insertUsers } from '#/modules/user/helpers/insert-users'; +import { insertUsers } from '#/modules/user/user-queries'; import { registerBenchSeed } from '../registry'; import { userId } from './ids'; import { loadtestUser } from './user'; @@ -15,6 +15,6 @@ registerBenchSeed({ }, seed: async ({ now, db }) => { const users = Array.from({ length: TOTAL_USERS }, (_, i) => ({ ...loadtestUser(i), createdAt: now })); - await insertUsers(db, users); + await insertUsers({ var: { db } }, { users }); }, }); diff --git a/bench/src/seeds/user.ts b/bench/src/seeds/user.ts index 0b5a2b10c..a41249ae9 100644 --- a/bench/src/seeds/user.ts +++ b/bench/src/seeds/user.ts @@ -20,10 +20,7 @@ export function loadtestUser(index: number): InsertUserModel { } export function loadtestEmail(index: number) { - return { - ...mockEmail({ id: userId(index), email: userEmail(index) } as UserModel), - id: emailId(index), - }; + return { ...mockEmail({ id: userId(index), email: userEmail(index) } as UserModel), id: emailId(index) }; } /** The token is deterministic per index, so the Artillery processor reconstructs the cookie without a DB query. */ diff --git a/bench/src/tests/all-scenarios.test.ts b/bench/src/tests/all-scenarios.test.ts index d80fded06..73558ddf4 100644 --- a/bench/src/tests/all-scenarios.test.ts +++ b/bench/src/tests/all-scenarios.test.ts @@ -23,10 +23,6 @@ describe('bench scenarios (short)', () => { if (!ready) return; // The exact CLI path users run; a non-zero exit throws and fails the test. - execFileSync('tsx', ['src/bench-cli.ts', '--all', '--short'], { - cwd: BENCH_ROOT, - stdio: 'inherit', - env: createBenchProcessEnv(), - }); + execFileSync('tsx', ['src/bench-cli.ts', '--all', '--short'], { cwd: BENCH_ROOT, stdio: 'inherit', env: createBenchProcessEnv() }); }, 120_000); }); diff --git a/bench/src/tests/preflight.test.ts b/bench/src/tests/preflight.test.ts new file mode 100644 index 000000000..8a87e7710 --- /dev/null +++ b/bench/src/tests/preflight.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { SERVICES } from '../preflight'; + +describe('bench preflight', () => { + it('checks only the services the scenarios use', () => { + expect(SERVICES).toHaveProperty('backend'); + expect(Object.keys(SERVICES).filter((name) => !['backend', 'cdc'].includes(name))).toEqual([]); + }); +}); diff --git a/biome.jsonc b/biome.jsonc index 4830f15ce..c2434b74c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -118,7 +118,7 @@ "formatter": { "enabled": true, "indentStyle": "space", - "lineWidth": 120, + "lineWidth": 150, "bracketSpacing": true }, "javascript": { @@ -151,7 +151,7 @@ "**/vite.config.ts", "**/vitest.config.ts", "**/tsup.config.ts", - "**/tailwind.config.ts", + "frontend/src/styling/tailwind-plugin.ts", "**/drizzle.config.ts", "**/svgo.config.mjs", "cella/cella.config.ts", @@ -195,7 +195,7 @@ "group": ["lucide-react"], "importNamePattern": "([A-Za-z0-9]+Icon|LucideProps|LucideProvider)", "invertImportNamePattern": true, - "message": "Use the *Icon-suffixed lucide name (BellIcon, not Bell). Size icons with classes (icon-xs…icon-xl / size-*), never the `size` prop — the global svg.lucide rule overrides its px attributes." + "message": "Use the *Icon-suffixed lucide name (BellIcon, not Bell). Size icons with size-* classes, never the `size` prop — the global svg.lucide rule overrides its px attributes." } ] } @@ -236,7 +236,7 @@ "group": ["lucide-react"], "importNamePattern": "([A-Za-z0-9]+Icon|LucideProps|LucideProvider)", "invertImportNamePattern": true, - "message": "Use the *Icon-suffixed lucide name (BellIcon, not Bell). Size icons with classes (icon-xs…icon-xl / size-*), never the `size` prop — the global svg.lucide rule overrides its px attributes." + "message": "Use the *Icon-suffixed lucide name (BellIcon, not Bell). Size icons with size-* classes, never the `size` prop — the global svg.lucide rule overrides its px attributes." } ] } @@ -451,7 +451,6 @@ "frontend/src/modules/ui/carousel.tsx", "frontend/src/modules/ui/field.tsx", "frontend/src/modules/ui/input-group.tsx", - "frontend/src/modules/ui/tag-input.tsx", "frontend/src/modules/common/resizable-panels/resizable-panels.tsx" ], "linter": { diff --git a/cdc/package.json b/cdc/package.json index d99d92e1d..8325117e6 100644 --- a/cdc/package.json +++ b/cdc/package.json @@ -10,14 +10,14 @@ "node": "26.x" }, "scripts": { + "build": "cross-env NODE_ENV=production tsup", + "dev": "cross-env NODE_ENV=development tsx --env-file=../backend/.env --watch src/cdc-worker.ts", "start": "node dist/cdc-worker.js", "start:dev": "tsx src/cdc-worker.ts", - "dev": "cross-env NODE_ENV=development node --import ../shared/scripts/register.mjs --env-file=../backend/.env --watch src/cdc-worker.ts", - "build": "cross-env NODE_ENV=production tsup", - "ts": "tsgo --pretty", - "tsperf": "rm -rf tsconfig.tsbuildinfo && tsgo --noEmit --extendedDiagnostics", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "ts": "tsgo --pretty", + "tsperf": "rm -rf tsconfig.tsbuildinfo && tsgo --noEmit --extendedDiagnostics" }, "dependencies": { "@opentelemetry/api": "^1.9.1", @@ -33,26 +33,26 @@ "@opentelemetry/sdk-node": "^0.222.0", "@opentelemetry/sdk-trace-base": "^2.11.0", "@opentelemetry/semantic-conventions": "^1.43.0", - "pg": "^8.23.0", + "pg": "^8.23.1", "pg-logical-replication": "^2.5.0", "pino": "^10.3.1", "pino-opentelemetry-transport": "^4.0.2", "pino-pretty": "^13.1.3" }, "devDependencies": { - "@hono/node-server": "^2.1.1", + "@hono/node-server": "^2.1.3", "@types/node": "^26.6.3", - "@types/ws": "^8.18.1", + "@types/ws": "^8.18.2", "@typescript/native-preview": "7.0.0-dev.20260707.2", "cross-env": "^10.1.0", "drizzle-orm": "1.0.0-rc.3", - "hono": "^4.13.10", + "hono": "^4.13.12", "sdk": "workspace:*", "shared": "workspace:*", "tsup": "^8.5.1", "tsx": "^4.23.15", - "typescript": "^6.0.3", - "vitest": "^5.0.2", + "typescript": "6.0.3", + "vitest": "^5.0.3", "ws": "^8.22.0", "zod": "^4.6.5" } diff --git a/cdc/src/constants.ts b/cdc/src/constants.ts index 2c266205b..b86a2e929 100644 --- a/cdc/src/constants.ts +++ b/cdc/src/constants.ts @@ -12,12 +12,7 @@ export const RESOURCE_LIMITS = { }, // Retry configuration for transient errors - retry: { - maxAttempts: 3, - initialDelayMs: 100, - maxDelayMs: 5000, - backoffMultiplier: 2, - }, + retry: { maxAttempts: 3, initialDelayMs: 100, maxDelayMs: 5000, backoffMultiplier: 2 }, // Reconnection configuration reconnection: { diff --git a/cdc/src/handlers/update.ts b/cdc/src/handlers/update.ts index 6b401b94e..8f7524533 100644 --- a/cdc/src/handlers/update.ts +++ b/cdc/src/handlers/update.ts @@ -53,11 +53,7 @@ export function handleUpdate(tableMeta: TableMeta, message: Pgoutput.MessageUpda if (!isSoftDeleteTransition(rowData, oldRowData) && isAlreadySoftDeleted(rowData, oldRowData)) return null; // User edits always include 'updatedAt', so an embedding-column-only change is CDC's own cleanup. - if ( - userChangedFields && - !userChangedFields.includes('updatedAt') && - userChangedFields.every((k) => embeddingColumns.has(k)) - ) { + if (userChangedFields && !userChangedFields.includes('updatedAt') && userChangedFields.every((k) => embeddingColumns.has(k))) { return null; } @@ -68,9 +64,7 @@ export function handleUpdate(tableMeta: TableMeta, message: Pgoutput.MessageUpda const oldLocation = oldRowData ? rowLocationPath(tableMeta.type, oldRowData) : null; const newLocation = rowLocationPath(tableMeta.type, rowData); const movedFrom = - oldRowData && oldLocation !== null && newLocation !== null && oldLocation !== newLocation - ? pickPermissionRowData(oldRowData) - : null; + oldRowData && oldLocation !== null && newLocation !== null && oldLocation !== newLocation ? pickPermissionRowData(oldRowData) : null; // changedFields is computed, so the large columns can go: nothing downstream reads them. return { diff --git a/cdc/src/lib/tracing.ts b/cdc/src/lib/tracing.ts index b14b1e812..92fe16ec1 100644 --- a/cdc/src/lib/tracing.ts +++ b/cdc/src/lib/tracing.ts @@ -12,12 +12,7 @@ export { activityAttrs, cdcAttrs, cdcSpanNames }; const debugProcessor = createSpanStoreProcessor({ onSpanEnd: (span) => { - log.trace(`Span: ${span.name}`, { - traceId: span.traceId, - duration: `${span.duration}ms`, - status: span.status, - ...span.attributes, - }); + log.trace(`Span: ${span.name}`, { traceId: span.traceId, duration: `${span.duration}ms`, status: span.status, ...span.attributes }); }, }); @@ -34,9 +29,7 @@ export const otel: OtelSDK = createOtelSDK({ const meter = otel.meterProvider.getMeter('cdc-health'); meter - .createObservableGauge('cdc.ws.connected', { - description: 'Whether CDC is connected to backend WebSocket (0/1)', - }) + .createObservableGauge('cdc.ws.connected', { description: 'Whether CDC is connected to backend WebSocket (0/1)' }) .addCallback(async (result) => { const { wsClient } = await import('../network/websocket-client'); result.observe(wsClient.isConnected() ? 1 : 0); @@ -52,9 +45,7 @@ meter }); meter - .createObservableGauge('cdc.circuit_breaker.open_count', { - description: 'Number of open/half-open circuit breakers', - }) + .createObservableGauge('cdc.circuit_breaker.open_count', { description: 'Number of open/half-open circuit breakers' }) .addCallback(async (result) => { const { circuitBreaker } = await import('../services/circuit-breaker'); const status = circuitBreaker.getStatus(); @@ -63,9 +54,7 @@ meter }); meter - .createObservableGauge('cdc.replication.status', { - description: 'Replication status (0=stopped, 1=paused, 2=active)', - }) + .createObservableGauge('cdc.replication.status', { description: 'Replication status (0=stopped, 1=paused, 2=active)' }) .addCallback(async (result) => { const { replicationState } = await import('../services/replication-state'); const statusMap = { stopped: 0, paused: 1, active: 2 } as const; diff --git a/cdc/src/network/health.ts b/cdc/src/network/health.ts index 2589600f2..7c021eaa7 100644 --- a/cdc/src/network/health.ts +++ b/cdc/src/network/health.ts @@ -26,26 +26,13 @@ interface HealthResponse { /** Null until the startup probe ran or when it failed. */ role: RoleCapabilities | null; }; - catchup: { - active: boolean; - eventsProcessed: number; - startedAt: string | null; - lagMs: number; - } | null; - websocket: { - connected: boolean; - state: string; - messagesSent: number; - lastMessageAt: string | null; - }; + catchup: { active: boolean; eventsProcessed: number; startedAt: string | null; lagMs: number } | null; + websocket: { connected: boolean; state: string; messagesSent: number; lastMessageAt: string | null }; circuitBreakers: Record; metrics: MetricsSnapshot; } -export function getHealthResponse(): { - response: HealthResponse; - httpStatus: number; -} { +export function getHealthResponse(): { response: HealthResponse; httpStatus: number } { const replStatus = replicationState.status; const wsConnected = wsClient.isConnected(); @@ -90,9 +77,7 @@ export function getHealthResponse(): { ? { active: true, eventsProcessed: replicationState.catchupEventsProcessed, - startedAt: replicationState.catchupStartedAt - ? new Date(replicationState.catchupStartedAt).toISOString() - : null, + startedAt: replicationState.catchupStartedAt ? new Date(replicationState.catchupStartedAt).toISOString() : null, lagMs: replicationState.lastLagMs ?? 0, } : null, diff --git a/cdc/src/network/websocket-client.ts b/cdc/src/network/websocket-client.ts index 6d023b0a6..f3406a5d6 100644 --- a/cdc/src/network/websocket-client.ts +++ b/cdc/src/network/websocket-client.ts @@ -53,9 +53,7 @@ class WebSocketClient { this._state = 'connecting'; - const headers: Record = { - 'x-cdc-secret': env.CDC_SECRET, - }; + const headers: Record = { 'x-cdc-secret': env.CDC_SECRET }; if (!this.inGracePeriod()) { log.info('CDC WebSocket connecting...', { url: this.url, attempt: this.reconnectAttempt + 1 }); @@ -182,10 +180,7 @@ class WebSocketClient { this.reconnectAttempt++; if (!this.inGracePeriod()) { - log.info('CDC WebSocket scheduling reconnect', { - attempt: this.reconnectAttempt, - delayMs: delay, - }); + log.info('CDC WebSocket scheduling reconnect', { attempt: this.reconnectAttempt, delayMs: delay }); } this.reconnectTimeout = setTimeout(() => { diff --git a/cdc/src/pipeline/parse-message.ts b/cdc/src/pipeline/parse-message.ts index 12e61065c..8dd33b5b7 100644 --- a/cdc/src/pipeline/parse-message.ts +++ b/cdc/src/pipeline/parse-message.ts @@ -32,13 +32,10 @@ function isFilteredDraftEvent(result: ParseMessageResult): boolean { const now = Date.now(); if (now - lastDraftGuardWarnAt > DRAFT_GUARD_WARN_INTERVAL_MS) { lastDraftGuardWarnAt = now; - log.warn( - 'Draft product row reached CDC: publication row filter missing? Regenerate migrations (pnpm generate + pnpm migrate).', - { - entityType: result.tableMeta.type, - action: result.activity.action, - }, - ); + log.warn('Draft product row reached CDC: publication row filter missing? Regenerate migrations (pnpm generate + pnpm migrate).', { + entityType: result.tableMeta.type, + action: result.activity.action, + }); } return true; } diff --git a/cdc/src/pipeline/process-events.ts b/cdc/src/pipeline/process-events.ts index 88ffe5381..3b2c27225 100644 --- a/cdc/src/pipeline/process-events.ts +++ b/cdc/src/pipeline/process-events.ts @@ -4,12 +4,7 @@ import { cdcDb } from '../lib/db'; import { log } from '../lib/pino'; import type { TraceContext } from '../lib/tracing'; import { activityAttrs, cdcAttrs, cdcSpanNames, withSpan } from '../lib/tracing'; -import { - type BatchEvent, - generateActivityId, - sendBatchMessageToApi, - sendMessageToApi, -} from '../services/activity-service'; +import { type BatchEvent, generateActivityId, sendBatchMessageToApi, sendMessageToApi } from '../services/activity-service'; import { metrics } from '../services/cdc-metrics'; import { circuitBreaker } from '../services/circuit-breaker'; import { replicationState } from '../services/replication-state'; @@ -33,10 +28,7 @@ interface PreparedEvent { // Activity persistence -function prepareActivity( - parseResult: ParseMessageResult, - lsn: string, -): { activityWithId: BatchEvent['activity']; seq: number | undefined } { +function prepareActivity(parseResult: ParseMessageResult, lsn: string): Pick { const activityId = generateActivityId(lsn); const activityWithId = { ...parseResult.activity, id: activityId }; const seq = typeof parseResult.rowData.seq === 'number' ? parseResult.rowData.seq : undefined; @@ -47,10 +39,7 @@ function prepareActivity( * Multi-row insert with retry, falling back to individual inserts. * @returns false when persistence failed, in which case the caller must skip deltas. */ -async function persistActivities( - infos: Array<{ activityWithId: BatchEvent['activity']; lsn: string }>, - tableName: string, -): Promise { +async function persistActivities(infos: Array>, tableName: string): Promise { if (infos.length === 1) { const { activityWithId, lsn } = infos[0]; const insertResult = await withRetry(async () => { @@ -71,11 +60,7 @@ async function persistActivities( } if (insertResult.attempts > 1) { - log.info('Activity insert succeeded after retry', { - activityId: activityWithId.id, - attempts: insertResult.attempts, - lsn, - }); + log.info('Activity insert succeeded after retry', { activityId: activityWithId.id, attempts: insertResult.attempts, lsn }); } return true; } @@ -153,81 +138,63 @@ export async function processEvents(events: Array<{ lsn: string; result: ParseMe return; } - await withSpan( - cdcSpanNames.processWal, - cdcAttrs({ lsn: firstLsn, tag: action, table: tableName }), - async (traceCtx) => { - const startMs = performance.now(); + const processWalAttrs = cdcAttrs({ lsn: firstLsn, tag: action, table: tableName }); + await withSpan(cdcSpanNames.processWal, processWalAttrs, async (traceCtx) => { + const startMs = performance.now(); - // Pure: no side effects until applyBatchUnifiedDeltas below. - const batchPlan = computeBatchUnifiedDeltas(events); + // Pure: no side effects until applyBatchUnifiedDeltas below. + const batchPlan = computeBatchUnifiedDeltas(events); - const prepared = events.map(({ lsn, result }) => { - replicationState.lastLsn = lsn; - const { activityWithId, seq } = prepareActivity(result, lsn); - return { activityWithId, seq, lsn, rowData: result.rowData, movedFrom: result.movedFrom ?? null }; - }); + const prepared = events.map(({ lsn, result }) => { + replicationState.lastLsn = lsn; + const { activityWithId, seq } = prepareActivity(result, lsn); + return { activityWithId, seq, lsn, rowData: result.rowData, movedFrom: result.movedFrom ?? null }; + }); - // Persist first: a failure here leaves no deltas applied. - const persisted = await withSpan( - cdcSpanNames.createActivity, - activityAttrs(prepared[0].activityWithId), - async () => { - return persistActivities( - prepared.map(({ activityWithId, lsn }) => ({ activityWithId, lsn })), - tableName, - ); - }, - ); - - if (!persisted) { - return; - } + const persisted = await withSpan(cdcSpanNames.createActivity, activityAttrs(prepared[0].activityWithId), () => + persistActivities(prepared, tableName), + ); + + if (!persisted) { + return; + } - await applyBatchUnifiedDeltas(batchPlan); + await applyBatchUnifiedDeltas(batchPlan); - // Mirror channel paths onto counters rows: the view-ancestry verification source. - await syncChannelPaths(events); + // Mirror channel paths onto counters rows: the view-ancestry verification source. + await syncChannelPaths(events); - const stamped = prepared.map((item) => ({ - ...item, - seq: typeof item.rowData.seq === 'number' ? item.rowData.seq : item.seq, - })); + const stamped = prepared.map((item) => ({ ...item, seq: typeof item.rowData.seq === 'number' ? item.rowData.seq : item.seq })); - circuitBreaker.recordSuccess(tableName); + circuitBreaker.recordSuccess(tableName); - for (const { activityWithId, lsn } of stamped) { - log.trace('Activity created from CDC', { - type: activityWithId.type, - subjectId: activityWithId.subjectId, - activityId: activityWithId.id, - lsn, - ...(activityWithId.changedFields && { changedFields: activityWithId.changedFields }), - }); - } + for (const { activityWithId, lsn } of stamped) { + log.trace('Activity created from CDC', { + type: activityWithId.type, + subjectId: activityWithId.subjectId, + activityId: activityWithId.id, + lsn, + ...(activityWithId.changedFields && { changedFields: activityWithId.changedFields }), + }); + } - dispatchToApi(stamped, traceCtx); + dispatchToApi(stamped, traceCtx); - // Strip deleted embedded-entity ids from host-entity arrays. - const { tableMeta } = events[0].result; - if (tableMeta.kind === 'entity' && isProduct(tableMeta.type) && (action === 'update' || action === 'delete')) { - await cleanupEmbeddingReferences(tableMeta.type, action, events); - } + // Strip deleted embedded-entity ids from host-entity arrays. + const { tableMeta } = events[0].result; + if (tableMeta.kind === 'entity' && isProduct(tableMeta.type) && (action === 'update' || action === 'delete')) { + await cleanupEmbeddingReferences(tableMeta.type, action, events); + } - // Soft-delete embedded rows their host arrays stopped referencing; hard deletes ride FK cascades. - if (tableMeta.kind === 'entity' && isProduct(tableMeta.type) && action === 'update') { - await gcOwnedEmbeddedRows(tableMeta.type, events); - } + // Soft-delete embedded rows their host arrays stopped referencing; hard deletes ride FK cascades. + if (tableMeta.kind === 'entity' && isProduct(tableMeta.type) && action === 'update') { + await gcOwnedEmbeddedRows(tableMeta.type, events); + } - metrics.recordProcessing(events.length, performance.now() - startMs); + metrics.recordProcessing(events.length, performance.now() - startMs); - if (isBatch) { - log.trace('Batch processed', { - batchSize: events.length, - entityType: events[0].result.activity.entityType, - action, - }); - } - }, - ); + if (isBatch) { + log.trace('Batch processed', { batchSize: events.length, entityType: events[0].result.activity.entityType, action }); + } + }); } diff --git a/cdc/src/pipeline/replication.ts b/cdc/src/pipeline/replication.ts index dd25a02e5..529a8daed 100644 --- a/cdc/src/pipeline/replication.ts +++ b/cdc/src/pipeline/replication.ts @@ -35,10 +35,7 @@ export function createReplicationService(): LogicalReplicationService { // Verified TLS, matching the query connection; certificate identity is pinned to the dialed host in production. ssl: buildVerifiedSsl(env.DATABASE_CDC_URL), }, - { - acknowledge: { auto: false, timeoutSeconds: 0 }, - flowControl: { enabled: true }, - }, + { acknowledge: { auto: false, timeoutSeconds: 0 }, flowControl: { enabled: true } }, ); service.on('data', (lsn: string, message: unknown) => { @@ -93,13 +90,9 @@ async function recreateReplicationSlot(): Promise { return; } try { - const publicationCheck = await cdcDb.execute( - sql`SELECT 1 FROM pg_publication WHERE pubname = ${CDC_PUBLICATION_NAME}`, - ); + const publicationCheck = await cdcDb.execute(sql`SELECT 1 FROM pg_publication WHERE pubname = ${CDC_PUBLICATION_NAME}`); if (publicationCheck.rows.length === 0) { - log.warn( - `Publication '${CDC_PUBLICATION_NAME}' does not exist; not recreating slot '${CDC_SLOT_NAME}'. Backing off until it appears.`, - ); + log.warn(`Publication '${CDC_PUBLICATION_NAME}' does not exist; not recreating slot '${CDC_SLOT_NAME}'. Backing off until it appears.`); return; } @@ -162,10 +155,7 @@ export function setupBackpressure(): void { // Subscription loop -export async function subscribeWithReconnect( - service: LogicalReplicationService, - plugin: PgoutputPlugin, -): Promise { +export async function subscribeWithReconnect(service: LogicalReplicationService, plugin: PgoutputPlugin): Promise { // Fast retries during a rolling-deploy slot handoff, then the normal cadence under sustained contention. let attempt = 0; while (true) { @@ -182,8 +172,7 @@ export async function subscribeWithReconnect( const inHandoffWindow = attempt <= slotTakeover.maxAttempts; const retryDelayMs = inHandoffWindow ? slotTakeover.retryDelayMs : reconnection.retryDelayMs; const takeover = inHandoffWindow ? ` (slot-takeover ${attempt}/${slotTakeover.maxAttempts})` : ''; - const slotHolder = - (error as { code?: string } | null)?.code === PG_OBJECT_IN_USE ? await describeSlotHolder() : null; + const slotHolder = (error as { code?: string } | null)?.code === PG_OBJECT_IN_USE ? await describeSlotHolder() : null; log.warn(`Subscription error, retrying in ${retryDelayMs / 1000}s${takeover}...`, { err: error, ...(slotHolder && { slotHolder }), diff --git a/cdc/src/pipeline/worker.ts b/cdc/src/pipeline/worker.ts index 2e8480668..027dc0d06 100644 --- a/cdc/src/pipeline/worker.ts +++ b/cdc/src/pipeline/worker.ts @@ -11,20 +11,14 @@ import { createReplicationService, setupBackpressure, subscribeWithReconnect } f /** Start and stop for the CDC worker; pipeline stages are documented in @see cdc/README.md */ export async function startCdcWorker(): Promise { - log.info('CDC worker starting...', { - publicationName: CDC_PUBLICATION_NAME, - slotName: CDC_SLOT_NAME, - }); + log.info('CDC worker starting...', { publicationName: CDC_PUBLICATION_NAME, slotName: CDC_SLOT_NAME }); await probeRoleCapabilities(); const service = createReplicationService(); replicationState.service = service; - const plugin = new PgoutputPlugin({ - protoVersion: 1, - publicationNames: [CDC_PUBLICATION_NAME], - }); + const plugin = new PgoutputPlugin({ protoVersion: 1, publicationNames: [CDC_PUBLICATION_NAME] }); setupBackpressure(); wsClient.connect(); diff --git a/cdc/src/services/catchup-recovery.ts b/cdc/src/services/catchup-recovery.ts index da6c9ff2a..a52fc0b49 100644 --- a/cdc/src/services/catchup-recovery.ts +++ b/cdc/src/services/catchup-recovery.ts @@ -17,11 +17,8 @@ export async function runPostCatchupRecovery(): Promise { // Phase 1: recalculate counters from the source-of-truth tables. try { const { channelRows, productRows } = await recalculateCounters(cdcDb); - log.info('Post-catchup counter recalculation complete', { - channelRows, - productRows, - durationMs: Math.round(performance.now() - startMs), - }); + const durationMs = Math.round(performance.now() - startMs); + log.info('Post-catchup counter recalculation complete', { channelRows, productRows, durationMs }); } catch (error) { log.error('Post-catchup counter recalculation failed', { err: error }); } @@ -39,8 +36,6 @@ export async function runPostCatchupRecovery(): Promise { replicationState.resetCatchup(); - log.info('Post-catchup recovery complete', { - totalDurationMs: Math.round(performance.now() - startMs), - eventsProcessed, - }); + const totalDurationMs = Math.round(performance.now() - startMs); + log.info('Post-catchup recovery complete', { totalDurationMs, eventsProcessed }); } diff --git a/cdc/src/services/cdc-metrics.ts b/cdc/src/services/cdc-metrics.ts index 1c6597c9e..75f77b43a 100644 --- a/cdc/src/services/cdc-metrics.ts +++ b/cdc/src/services/cdc-metrics.ts @@ -20,14 +20,7 @@ interface Bucket { } function createBucket(startMs: number): Bucket { - return { - startMs, - eventCount: 0, - flushCount: 0, - processingDurations: [], - flushDurations: [], - batchSizes: [], - }; + return { startMs, eventCount: 0, flushCount: 0, processingDurations: [], flushDurations: [], batchSizes: [] }; } function percentile(sorted: number[], p: number): number { @@ -110,6 +103,8 @@ class Metrics { const sortedBatch = allBatchSizes.slice().sort((a, b) => a - b); const windowSec = Math.max(1, windowMs / 1000); + const avgBatchSize = sortedBatch.length ? Math.round((sortedBatch.reduce((a, b) => a + b, 0) / sortedBatch.length) * 10) / 10 : 0; + return { windowSeconds: Math.round(windowSec), eventsProcessed: totalEvents, @@ -121,9 +116,7 @@ class Metrics { p99: Math.round(percentile(sortedProc, 99) * 10) / 10, }, batchSize: { - avg: sortedBatch.length - ? Math.round((sortedBatch.reduce((a, b) => a + b, 0) / sortedBatch.length) * 10) / 10 - : 0, + avg: avgBatchSize, max: sortedBatch.length ? sortedBatch[sortedBatch.length - 1] : 0, }, flushes: totalFlushes, @@ -156,20 +149,13 @@ class Metrics { if (currentBytes >= warnBytes && !this.hasWarned) { this.hasWarned = true; - log.warn('WAL lag approaching backpressure limit', { - lagBytes: currentBytes, - warnThreshold: warnBytes, - unhealthyThreshold: unhealthyBytes, - }); + log.warn('WAL lag approaching backpressure limit', { lagBytes: currentBytes, warnThreshold: warnBytes, unhealthyThreshold: unhealthyBytes }); this.emitLagControl('wal_lag_warn'); } if (currentBytes >= unhealthyBytes && !this.hasGoneUnhealthy) { this.hasGoneUnhealthy = true; - log.error('WAL lag exceeded backpressure limit: CDC unhealthy', { - lagBytes: currentBytes, - unhealthyThreshold: unhealthyBytes, - }); + log.error('WAL lag exceeded backpressure limit: CDC unhealthy', { lagBytes: currentBytes, unhealthyThreshold: unhealthyBytes }); this.emitLagControl('wal_lag_unhealthy'); } } @@ -190,11 +176,7 @@ class Metrics { private async pollLag(): Promise { try { - const result = await cdcDb.execute<{ - lag_bytes: string; - active: boolean; - wal_status: string; - }>( + const result = await cdcDb.execute<{ lag_bytes: string; active: boolean; wal_status: string }>( sql`SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)::text AS lag_bytes FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`, diff --git a/cdc/src/services/circuit-breaker.ts b/cdc/src/services/circuit-breaker.ts index 7ef1e17ec..be7e947b7 100644 --- a/cdc/src/services/circuit-breaker.ts +++ b/cdc/src/services/circuit-breaker.ts @@ -79,9 +79,7 @@ class CircuitBreaker { entry.state = 'open'; entry.openedAt = Date.now(); entry.skippedCount = 0; - log.warn(`Circuit OPEN for table '${tableName}': ${FAILURE_THRESHOLD} consecutive failures`, { - failureCount: entry.failureCount, - }); + log.warn(`Circuit OPEN for table '${tableName}': ${FAILURE_THRESHOLD} consecutive failures`, { failureCount: entry.failureCount }); } } @@ -99,9 +97,7 @@ class CircuitBreaker { entry.openedAt = null; if (wasOpen) { - log.info(`Circuit CLOSED for table '${tableName}': recovered`, { - skippedCount: skipped, - }); + log.info(`Circuit CLOSED for table '${tableName}': recovered`, { skippedCount: skipped }); } } diff --git a/cdc/src/services/create-activity.ts b/cdc/src/services/create-activity.ts index c1ec45a58..1879d27cc 100644 --- a/cdc/src/services/create-activity.ts +++ b/cdc/src/services/create-activity.ts @@ -47,12 +47,7 @@ export function createActivity( return { tenantId, // The actor: whoever last touched the row. `revokedBy` is the api_keys update column. - userId: - getRowValue(row, 'updatedBy') ?? - getRowValue(row, 'revokedBy') ?? - getRowValue(row, 'createdBy') ?? - getRowValue(row, 'userId') ?? - null, + userId: getRowValue(row, 'updatedBy') ?? getRowValue(row, 'revokedBy') ?? getRowValue(row, 'createdBy') ?? getRowValue(row, 'userId') ?? null, entityType, resourceType, action, diff --git a/cdc/src/services/flush-buffer.ts b/cdc/src/services/flush-buffer.ts index a525f95a4..60b772844 100644 --- a/cdc/src/services/flush-buffer.ts +++ b/cdc/src/services/flush-buffer.ts @@ -52,9 +52,7 @@ export class FlushBuffer { // Safety cap. if (this.pending.length >= RESOURCE_LIMITS.buffers.maxBufferedEvents) { - log.trace('Flush buffer hit size cap, flushing immediately', { - count: this.pending.length, - }); + log.trace('Flush buffer hit size cap, flushing immediately', { count: this.pending.length }); await this.flush(); return; } @@ -95,9 +93,7 @@ export class FlushBuffer { else groups.set(key, [event]); } - const results = await Promise.allSettled( - [...groups.values()].map((groupEvents) => this.processEvents(groupEvents)), - ); + const results = await Promise.allSettled([...groups.values()].map((groupEvents) => this.processEvents(groupEvents))); for (const result of results) { if (result.status === 'rejected') { @@ -111,10 +107,7 @@ export class FlushBuffer { metrics.recordFlush(events.length, performance.now() - flushStart); if (events.length > 1) { - log.trace('Flush buffer batch processed', { - totalEvents: events.length, - groups: groups.size, - }); + log.trace('Flush buffer batch processed', { totalEvents: events.length, groups: groups.size }); } } finally { this.flushing = false; diff --git a/cdc/src/services/replication-state.ts b/cdc/src/services/replication-state.ts index 02cab1b4a..5203bda40 100644 --- a/cdc/src/services/replication-state.ts +++ b/cdc/src/services/replication-state.ts @@ -134,10 +134,7 @@ class ReplicationStateManager { this._catchupStartedAt = Date.now(); this._catchupEventsProcessed = 0; this._consecutiveLiveTxns = 0; - log.info('Entering catchup mode: WAL lag exceeds threshold', { - lagMs: Math.round(lagMs), - thresholdMs: enterLagMs, - }); + log.info('Entering catchup mode: WAL lag exceeds threshold', { lagMs: Math.round(lagMs), thresholdMs: enterLagMs }); } return this._catchingUp; } diff --git a/cdc/src/services/retry.ts b/cdc/src/services/retry.ts index 0c63ee6ec..5cb2a89d9 100644 --- a/cdc/src/services/retry.ts +++ b/cdc/src/services/retry.ts @@ -26,12 +26,7 @@ export function isTransientError(error: unknown): boolean { } function hasErrorCode(value: unknown): value is { code: string } { - return ( - typeof value === 'object' && - value !== null && - 'code' in value && - typeof (value as { code: unknown }).code === 'string' - ); + return typeof value === 'object' && value !== null && 'code' in value && typeof (value as { code: unknown }).code === 'string'; } /** Reads the PostgreSQL error code, unwrapping Drizzle-wrapped errors via `.cause`. */ @@ -43,9 +38,7 @@ export function getErrorCode(error: unknown): string | null { return null; } -type RetryResult = - | { success: true; value: T; attempts: number } - | { success: false; error: Error; attempts: number; isTransient: boolean }; +type RetryResult = { success: true; value: T; attempts: number } | { success: false; error: Error; attempts: number; isTransient: boolean }; /** * Retries transient errors with exponential backoff. @@ -67,10 +60,7 @@ export async function withRetry(fn: () => Promise, context: string): Promi break; } - const delay = Math.min( - RETRY_CONFIG.initialDelayMs * RETRY_CONFIG.backoffMultiplier ** (attempt - 1), - RETRY_CONFIG.maxDelayMs, - ); + const delay = Math.min(RETRY_CONFIG.initialDelayMs * RETRY_CONFIG.backoffMultiplier ** (attempt - 1), RETRY_CONFIG.maxDelayMs); log.warn(`Transient error during ${context}, retrying...`, { attempt, @@ -84,10 +74,5 @@ export async function withRetry(fn: () => Promise, context: string): Promi } } - return { - success: false, - error: lastError, - attempts: RETRY_CONFIG.maxAttempts, - isTransient: isLastErrorTransient, - }; + return { success: false, error: lastError, attempts: RETRY_CONFIG.maxAttempts, isTransient: isLastErrorTransient }; } diff --git a/cdc/src/services/role-capabilities.ts b/cdc/src/services/role-capabilities.ts index 19a973fb6..19ad16bcc 100644 --- a/cdc/src/services/role-capabilities.ts +++ b/cdc/src/services/role-capabilities.ts @@ -56,12 +56,7 @@ export async function probeRoleCapabilities(): Promise replication: row.superuser || row.replication, }; if (!current.rlsBypass || !current.replication) { - log.error( - 'CDC database role cannot bypass RLS on every table or open the slot; seq stamping or replication will fail', - { - ...current, - }, - ); + log.error('CDC database role cannot bypass RLS on every table or open the slot; seq stamping or replication will fail', { ...current }); } return current; } catch (err) { diff --git a/cdc/src/services/transaction-buffer.ts b/cdc/src/services/transaction-buffer.ts index b5d7a3d64..d25682f92 100644 --- a/cdc/src/services/transaction-buffer.ts +++ b/cdc/src/services/transaction-buffer.ts @@ -106,11 +106,7 @@ export class TransactionBuffer { } if (suppressedCount > 0) { - log.info('Suppressed cascaded delete events', { - suppressedCount, - processedCount: events.length, - deletedChannelIds, - }); + log.info('Suppressed cascaded delete events', { suppressedCount, processedCount: events.length, deletedChannelIds }); } if (events.length === 0) return; @@ -129,13 +125,10 @@ export class TransactionBuffer { if (surviving.length > 0) { if (surviving.length > 1) { - const nonDeleteTypes = new Set( - surviving.filter((e) => e.result.activity.action !== 'delete').map((e) => e.result.tableMeta.type), - ); + const nonDeleteEvents = surviving.filter((e) => e.result.activity.action !== 'delete'); + const nonDeleteTypes = new Set(nonDeleteEvents.map((e) => e.result.tableMeta.type)); if (nonDeleteTypes.size > 1) { - log.warn('Transaction contains non-delete mutations across types', { - types: [...nonDeleteTypes], - }); + log.warn('Transaction contains non-delete mutations across types', { types: [...nonDeleteTypes] }); } } @@ -200,11 +193,7 @@ export class TransactionBuffer { } if (softSuppressedCount > 0) { - log.info('Suppressed soft cascade update events', { - softSuppressedCount, - deleteTypes: [...deleteTypes], - survivingCount: kept.length, - }); + log.info('Suppressed soft cascade update events', { softSuppressedCount, deleteTypes: [...deleteTypes], survivingCount: kept.length }); } return kept; @@ -226,10 +215,7 @@ export class TransactionBuffer { this.clearTimeout(); this.timeoutHandle = setTimeout(() => { if (this.activeXid !== null) { - log.warn('Transaction buffer timeout, flushing without filtering', { - xid: this.activeXid, - count: this.pendingEvents.length, - }); + log.warn('Transaction buffer timeout, flushing without filtering', { xid: this.activeXid, count: this.pendingEvents.length }); this.flushAll(); } }, transactionTimeoutMs); diff --git a/cdc/src/table-registry.ts b/cdc/src/table-registry.ts index 8852425af..c3eef7ba6 100644 --- a/cdc/src/table-registry.ts +++ b/cdc/src/table-registry.ts @@ -22,23 +22,15 @@ function buildTableRegistry(): Map { for (const [type, table] of typedEntries(entityTables)) { const tableName = getTableName(table); - const meta: EntityTableMeta = { - kind: 'entity', - table, - type, - columnNameMap: buildColumnNameMap(Object.keys(getColumns(table))), - }; + const columnNameMap = buildColumnNameMap(Object.keys(getColumns(table))); + const meta: EntityTableMeta = { kind: 'entity', table, type, columnNameMap }; registry.set(tableName, meta); } for (const [type, table] of typedEntries(resourceTables)) { const tableName = getTableName(table); - const meta: ResourceTableMeta = { - kind: 'resource', - table, - type, - columnNameMap: buildColumnNameMap(Object.keys(getColumns(table))), - }; + const columnNameMap = buildColumnNameMap(Object.keys(getColumns(table))); + const meta: ResourceTableMeta = { kind: 'resource', table, type, columnNameMap }; registry.set(tableName, meta); } diff --git a/cdc/src/tests/activity-service.test.ts b/cdc/src/tests/activity-service.test.ts index 7416c4696..d9ced4b93 100644 --- a/cdc/src/tests/activity-service.test.ts +++ b/cdc/src/tests/activity-service.test.ts @@ -1,13 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockBatchEvent } from './factories'; -vi.mock('../network/websocket-client', () => ({ - wsClient: { send: vi.fn(() => true) }, -})); -vi.mock('shared/utils/nanoid', () => ({ - nanoid: () => 'mock-token', - nanoidTenant: () => 'mock-t', -})); +vi.mock('../network/websocket-client', () => ({ wsClient: { send: vi.fn(() => true) } })); +vi.mock('shared/utils/nanoid', () => ({ nanoid: () => 'mock-token', nanoidTenant: () => 'mock-t' })); import { wsClient } from '../network/websocket-client'; import { generateActivityId, sendBatchMessageToApi } from '../services/activity-service'; @@ -59,9 +54,7 @@ describe('sendBatchMessageToApi', () => { const events = [mockBatchEvent(10), mockBatchEvent(12)]; sendBatchMessageToApi(events, { traceId: 'test', spanId: 'test' } as never); - const payload = vi.mocked(wsClient.send).mock.calls[0][0] as never as { - activity: { seq?: number; batchUntilSeq?: number; count?: number }; - }; + const payload = vi.mocked(wsClient.send).mock.calls[0][0] as never as { activity: { seq?: number; batchUntilSeq?: number; count?: number } }; expect(payload.activity.seq).toBe(10); expect(payload.activity.batchUntilSeq).toBe(12); expect(payload.activity.count).toBe(2); @@ -78,14 +71,12 @@ describe('sendBatchMessageToApi', () => { sendBatchMessageToApi(events, { traceId: 'test', spanId: 'test' } as never); expect(wsClient.send).toHaveBeenCalledTimes(2); - const payloads = vi.mocked(wsClient.send).mock.calls.map( - (call) => - call[0] as never as { - activity: { seq?: number; batchUntilSeq?: number }; - rowData: Record; - batchRows: { seq?: number }[]; - }, - ); + const payloads = vi + .mocked(wsClient.send) + .mock.calls.map( + (call) => + call[0] as never as { activity: { seq?: number; batchUntilSeq?: number }; rowData: Record; batchRows: { seq?: number }[] }, + ); const orgA = payloads.find((p) => p.rowData.organizationId === 'org-a'); const orgB = payloads.find((p) => p.rowData.organizationId === 'org-b'); @@ -107,15 +98,9 @@ describe('sendBatchMessageToApi', () => { ]; sendBatchMessageToApi(events, { traceId: 'test', spanId: 'test' } as never); - const payload = vi.mocked(wsClient.send).mock.calls[0][0] as never as { - batchRows: { seq?: number; rowData: Record }[]; - }; + const payload = vi.mocked(wsClient.send).mock.calls[0][0] as never as { batchRows: { seq?: number; rowData: Record }[] }; // Context ids and audit fields stay; content fields never hit the wire. - expect(payload.batchRows[0].rowData).toEqual({ - id: event.rowData.id, - organizationId: 'org-a', - createdBy: 'u1', - }); + expect(payload.batchRows[0].rowData).toEqual({ id: event.rowData.id, organizationId: 'org-a', createdBy: 'u1' }); }); it('groups non-product entities (user) by org instead of demanding a channel ancestor', () => { diff --git a/cdc/src/tests/apply-unified-deltas.test.ts b/cdc/src/tests/apply-unified-deltas.test.ts index 686d00f27..5550e2f94 100644 --- a/cdc/src/tests/apply-unified-deltas.test.ts +++ b/cdc/src/tests/apply-unified-deltas.test.ts @@ -26,11 +26,7 @@ vi.mock('../lib/db', () => { return { rows: [{ counts: upsertReturnValue }], rowCount: 1 }; }); - return { - cdcDb: { - execute: mockExecute, - }, - }; + return { cdcDb: { execute: mockExecute } }; }); const { applyBatchUnifiedDeltas, sumInto } = await import('../utils/apply-unified-deltas'); @@ -52,11 +48,7 @@ beforeEach(() => { describe('applyBatchUnifiedDeltas', () => { const mockEvent = (id: string) => - changeEvent({ - tableMeta: tableMetaOf('entity', 'task'), - action: 'create', - rowData: { id, projectId: 'proj-1', organizationId: 'org-1' }, - }); + changeEvent({ tableMeta: tableMetaOf('entity', 'task'), action: 'create', rowData: { id, projectId: 'proj-1', organizationId: 'org-1' } }); it('assigns sequential org-sequence values to events from the reserved range', async () => { upsertReturnValue = { sequence: 5 }; // highSeq = 5, count = 3, baseSeq = 2 @@ -129,10 +121,7 @@ describe('applyBatchUnifiedDeltas', () => { }); it('handles empty plan', async () => { - const plan: BatchUnifiedDeltaPlan = { - orgSequenceGroups: [], - countDeltasByChannelKey: new Map(), - }; + const plan: BatchUnifiedDeltaPlan = { orgSequenceGroups: [], countDeltasByChannelKey: new Map() }; await applyBatchUnifiedDeltas(plan, syntheticH); expect(dbOps).toHaveLength(0); @@ -141,9 +130,7 @@ describe('applyBatchUnifiedDeltas', () => { describe('frontierNodeKeys', () => { it('org first, then every non-null ancestor, deduplicated', () => { - expect( - frontierNodeKeys('task', { id: 't1', projectId: 'proj-1', organizationId: 'org-1' }, 'org-1', syntheticH), - ).toEqual(['org-1', 'proj-1']); + expect(frontierNodeKeys('task', { id: 't1', projectId: 'proj-1', organizationId: 'org-1' }, 'org-1', syntheticH)).toEqual(['org-1', 'proj-1']); }); it('org-homed row rolls up to the org node only', () => { diff --git a/cdc/src/tests/compact-row-data.test.ts b/cdc/src/tests/compact-row-data.test.ts index bb7e1415a..5413406a6 100644 --- a/cdc/src/tests/compact-row-data.test.ts +++ b/cdc/src/tests/compact-row-data.test.ts @@ -2,23 +2,8 @@ import { describe, expect, it } from 'vitest'; import { parseMessage } from '../pipeline/parse-message'; import { dmlMessage } from './factories'; -const apiKey = { - id: 'k1', - tenant_id: 't1', - created_by: 'p-owner', - name: 'ci', - prefix: 'app_sk_live_ab', - last4: '1234', - hash: 'sha256-of-the-key', -}; -const apiKeyOnTheWire = { - id: 'k1', - tenantId: 't1', - createdBy: 'p-owner', - name: 'ci', - prefix: 'app_sk_live_ab', - last4: '1234', -}; +const apiKey = { id: 'k1', tenant_id: 't1', created_by: 'p-owner', name: 'ci', prefix: 'app_sk_live_ab', last4: '1234', hash: 'sha256-of-the-key' }; +const apiKeyOnTheWire = { id: 'k1', tenantId: 't1', createdBy: 'p-owner', name: 'ci', prefix: 'app_sk_live_ab', last4: '1234' }; // The handlers compact every row image they emit, so a column in `secretColumns` (backend/src/db/secret-columns.ts) // never reaches the backend or the worker's log. @@ -31,14 +16,7 @@ describe('parseMessage strips secret columns from the row images the handlers em }); it('an oauth_client insert and an api_key delete drop the secret column', () => { - const insert = parseMessage( - dmlMessage('insert', 'oauth_clients', { - id: 'c1', - name: 'Portfolio', - created_by: 'p-admin', - secret_hash: 'sha256', - }), - ); + const insert = parseMessage(dmlMessage('insert', 'oauth_clients', { id: 'c1', name: 'Portfolio', created_by: 'p-admin', secret_hash: 'sha256' })); expect(insert?.rowData).toEqual({ id: 'c1', name: 'Portfolio', createdBy: 'p-admin' }); const deletion = parseMessage(dmlMessage('delete', 'api_keys', apiKey)); @@ -48,11 +26,6 @@ describe('parseMessage strips secret columns from the row images the handlers em it('keeps a column named like a secret on a table that declares none', () => { const row = { id: 'm1', organization_id: 'org-1', role: 'admin', hash: 'not a secret column here' }; const result = parseMessage(dmlMessage('insert', 'memberships', row)); - expect(result?.rowData).toEqual({ - id: 'm1', - organizationId: 'org-1', - role: 'admin', - hash: 'not a secret column here', - }); + expect(result?.rowData).toEqual({ id: 'm1', organizationId: 'org-1', role: 'admin', hash: 'not a secret column here' }); }); }); diff --git a/cdc/src/tests/create-activity.test.ts b/cdc/src/tests/create-activity.test.ts index 03b20083a..99d534cf7 100644 --- a/cdc/src/tests/create-activity.test.ts +++ b/cdc/src/tests/create-activity.test.ts @@ -13,12 +13,7 @@ describe('createActivity actor', () => { it('attributes an api_key create to createdBy', () => { const row = { id: 'k1', tenantId: 't1', createdBy: 'p-owner', revokedBy: null }; const activity = createActivity(metaFor('api_keys'), row, 'create'); - expect(activity).toMatchObject({ - resourceType: 'api_key', - type: 'api_key.created', - tenantId: 't1', - userId: 'p-owner', - }); + expect(activity).toMatchObject({ resourceType: 'api_key', type: 'api_key.created', tenantId: 't1', userId: 'p-owner' }); }); it('attributes an api_key revoke to revokedBy, not the key creator', () => { diff --git a/cdc/src/tests/deep-hierarchy.test.ts b/cdc/src/tests/deep-hierarchy.test.ts index 4ac8e3604..f73b109bd 100644 --- a/cdc/src/tests/deep-hierarchy.test.ts +++ b/cdc/src/tests/deep-hierarchy.test.ts @@ -11,17 +11,9 @@ const h = makeDeepHierarchy(['project', 'courseSection']); const itemMeta = () => tableMetaOf('entity', 'item'); -const itemActivity = (action: InsertActivityModel['action'], organizationId: string | null = 'o1') => - mockCdcActivity({ action, organizationId }); - -const fullDepthRow = { - id: 'i1', - projectId: 'p1', - courseSectionId: 's1', - courseId: 'c1', - organizationId: 'o1', - deletedAt: null, -}; +const itemActivity = (action: InsertActivityModel['action'], organizationId: string | null = 'o1') => mockCdcActivity({ action, organizationId }); + +const fullDepthRow = { id: 'i1', projectId: 'p1', courseSectionId: 's1', courseId: 'c1', organizationId: 'o1', deletedAt: null }; /** Item attached to a course section (no project). */ const sectionRow = { ...fullDepthRow, projectId: null }; /** Course-stream item: lives directly on the course. */ @@ -69,10 +61,7 @@ describe('home channel: deepest non-null ancestor (resolveChannelKey)', () => { describe('sequence groups per organization (computeBatchUnifiedDeltas)', () => { it('variable-depth rows in one org share ONE sequence group (all depths, one order)', () => { - const plan = computeBatchUnifiedDeltas( - [mockEvent('create', fullDepthRow), mockEvent('create', { ...courseStreamRow, id: 'i2' })], - h, - ); + const plan = computeBatchUnifiedDeltas([mockEvent('create', fullDepthRow), mockEvent('create', { ...courseStreamRow, id: 'i2' })], h); expect(plan.orgSequenceGroups).toHaveLength(1); expect(plan.orgSequenceGroups[0]).toMatchObject({ orgKey: 'o1', count: 2 }); @@ -80,10 +69,7 @@ describe('sequence groups per organization (computeBatchUnifiedDeltas)', () => { }); it('same-org rows preserve WAL order within the group', () => { - const plan = computeBatchUnifiedDeltas( - [mockEvent('create', fullDepthRow), mockEvent('create', { ...fullDepthRow, id: 'i2' })], - h, - ); + const plan = computeBatchUnifiedDeltas([mockEvent('create', fullDepthRow), mockEvent('create', { ...fullDepthRow, id: 'i2' })], h); expect(plan.orgSequenceGroups).toHaveLength(1); expect(plan.orgSequenceGroups[0].events.map((e) => e.result.rowData.id)).toEqual(['i1', 'i2']); }); @@ -94,9 +80,7 @@ describe('sequence groups per organization (computeBatchUnifiedDeltas)', () => { }); it('an org-less row fails the batch loudly instead of inventing a scope', () => { - expect(() => computeBatchUnifiedDeltas([mockEvent('create', { id: 'i1' }, null, null)], h)).toThrow( - /organization ancestor/, - ); + expect(() => computeBatchUnifiedDeltas([mockEvent('create', { id: 'i1' }, null, null)], h)).toThrow(/organization ancestor/); }); }); @@ -154,35 +138,18 @@ describe('counter attribution: org + every non-null ancestor (getCountDeltas)', }); it('batch merge: two rows at different depths accumulate per context', () => { - const plan = computeBatchUnifiedDeltas( - [mockEvent('create', fullDepthRow), mockEvent('create', { ...courseStreamRow, id: 'i2' })], - h, - ); + const plan = computeBatchUnifiedDeltas([mockEvent('create', fullDepthRow), mockEvent('create', { ...courseStreamRow, id: 'i2' })], h); // Activity stamps land at each row's home context only. expect(plan.countDeltasByChannelKey.get('o1')).toEqual({ 'e:c:item': 2 }); - expect(plan.countDeltasByChannelKey.get('c1')).toEqual({ - 'e:c:item': 2, - 'e:c:h:item': 1, - 'e:li:h:item': expect.any(Number), - }); + expect(plan.countDeltasByChannelKey.get('c1')).toEqual({ 'e:c:item': 2, 'e:c:h:item': 1, 'e:li:h:item': expect.any(Number) }); expect(plan.countDeltasByChannelKey.get('s1')).toEqual({ 'e:c:item': 1 }); - expect(plan.countDeltasByChannelKey.get('p1')).toEqual({ - 'e:c:item': 1, - 'e:c:h:item': 1, - 'e:li:h:item': expect.any(Number), - }); + expect(plan.countDeltasByChannelKey.get('p1')).toEqual({ 'e:c:item': 1, 'e:c:h:item': 1, 'e:li:h:item': expect.any(Number) }); }); }); describe('reparent updates re-credit the ancestor diff', () => { it('project→project move (same course): only the projects change, lu stamps the new home', () => { - const deltas = getCountDeltas( - itemMeta(), - itemActivity('update'), - { ...fullDepthRow, projectId: 'p2' }, - fullDepthRow, - h, - ); + const deltas = getCountDeltas(itemMeta(), itemActivity('update'), { ...fullDepthRow, projectId: 'p2' }, fullDepthRow, h); expect(deltas).toEqual( expect.arrayContaining([ { channelKey: 'p2', deltas: { 'e:c:item': 1 } }, @@ -238,13 +205,7 @@ describe('reparent updates re-credit the ancestor diff', () => { describe('soft-delete / restore transitions on variable-depth rows', () => { it('soft-delete of a course-stream item decrements exactly its non-null ancestors', () => { - const deltas = getCountDeltas( - itemMeta(), - itemActivity('update'), - { ...courseStreamRow, deletedAt: '2026-07-07T12:00:00Z' }, - courseStreamRow, - h, - ); + const deltas = getCountDeltas(itemMeta(), itemActivity('update'), { ...courseStreamRow, deletedAt: '2026-07-07T12:00:00Z' }, courseStreamRow, h); expect(deltas).toEqual( expect.arrayContaining([ { channelKey: 'o1', deltas: { 'e:c:item': -1 } }, @@ -256,13 +217,7 @@ describe('soft-delete / restore transitions on variable-depth rows', () => { }); it('restore counts the row again on the same set', () => { - const deltas = getCountDeltas( - itemMeta(), - itemActivity('update'), - courseStreamRow, - { ...courseStreamRow, deletedAt: '2026-07-07T12:00:00Z' }, - h, - ); + const deltas = getCountDeltas(itemMeta(), itemActivity('update'), courseStreamRow, { ...courseStreamRow, deletedAt: '2026-07-07T12:00:00Z' }, h); expect(deltas).toEqual( expect.arrayContaining([ { channelKey: 'o1', deltas: { 'e:c:item': 1 } }, diff --git a/cdc/src/tests/factories.ts b/cdc/src/tests/factories.ts index f34fbf799..499d8a71d 100644 --- a/cdc/src/tests/factories.ts +++ b/cdc/src/tests/factories.ts @@ -29,9 +29,12 @@ export const tableMetaOf = (kind: TableMeta['kind'], type: string): TableMeta => const DEFAULT_ENTITY: NonNullable = 'attachment'; const DEFAULT_TABLE = 'attachments'; +/** The seeded mock is the same on every call, and reseeding faker per call made a 50,000-event test take 11s, so it is built once. */ +let cdcActivityDefaults: InsertActivityModel | undefined; + /** Activity with explicit test-friendly defaults, based on the backend mockActivity shape. */ export function mockCdcActivity(overrides: Partial = {}): InsertActivityModel { - return mockActivity('cdc:default', { + cdcActivityDefaults ??= mockActivity('cdc:default', { action: 'create', entityType: DEFAULT_ENTITY, resourceType: null, @@ -42,8 +45,8 @@ export function mockCdcActivity(overrides: Partial = {}): I organizationId: 'org-1', changedFields: null, stx: null, - ...overrides, }) as InsertActivityModel; + return { ...cdcActivityDefaults, ...overrides }; } /** ParseMessageResult fixture. */ @@ -70,12 +73,7 @@ export function mockParseResult( type: `${type}.${overrides.action === 'delete' ? 'deleted' : 'created'}` as InsertActivityModel['type'], }); - return { - activity, - rowData: { id: activity.subjectId ?? 'unknown' }, - oldRowData: null, - tableMeta: tableMetaOf(kind, type), - }; + return { activity, rowData: { id: activity.subjectId ?? 'unknown' }, oldRowData: null, tableMeta: tableMetaOf(kind, type) }; } type Row = Record & { id?: string }; @@ -123,18 +121,11 @@ export function mockPendingEvent(overrides: { organizationId?: string | null; tableMeta?: 'entity' | 'resource'; }): PendingEvent { - return { - lsn: overrides.lsn, - result: mockParseResult(overrides), - }; + return { lsn: overrides.lsn, result: mockParseResult(overrides) }; } /** BatchEvent fixture. */ export function mockBatchEvent(seq: number, subjectId = `entity-${seq}`): BatchEvent { const activity = mockCdcActivity({ subjectId }); - return { - activity: { ...activity, id: `act-${seq}` } as InsertActivityModel & { id: string }, - rowData: { id: subjectId, seq }, - seq, - }; + return { activity: { ...activity, id: `act-${seq}` } as InsertActivityModel & { id: string }, rowData: { id: subjectId, seq }, seq }; } diff --git a/cdc/src/tests/flush-buffer.test.ts b/cdc/src/tests/flush-buffer.test.ts index f330abb55..c14d7947d 100644 --- a/cdc/src/tests/flush-buffer.test.ts +++ b/cdc/src/tests/flush-buffer.test.ts @@ -2,9 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mockPendingEvent } from './factories'; // Mock cdc-metrics to avoid db/env import chain -vi.mock('../services/cdc-metrics', () => ({ - metrics: { recordFlush: vi.fn() }, -})); +vi.mock('../services/cdc-metrics', () => ({ metrics: { recordFlush: vi.fn() } })); import { FlushBuffer } from '../services/flush-buffer'; import type { PendingEvent } from '../types'; diff --git a/cdc/src/tests/handle-message.test.ts b/cdc/src/tests/handle-message.test.ts index 9beb4abc3..ce600c031 100644 --- a/cdc/src/tests/handle-message.test.ts +++ b/cdc/src/tests/handle-message.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mocks must precede the import of the module under test. -vi.mock('../pipeline/process-events', () => ({ - processEvents: vi.fn(), -})); +vi.mock('../pipeline/process-events', () => ({ processEvents: vi.fn() })); -vi.mock('../services/catchup-recovery', () => ({ - runPostCatchupRecovery: vi.fn(), -})); +vi.mock('../services/catchup-recovery', () => ({ runPostCatchupRecovery: vi.fn() })); vi.mock('../pipeline/parse-message', () => ({ parseMessage: vi.fn(() => ({ @@ -32,12 +28,7 @@ vi.mock('../pipeline/parse-message', () => ({ })); vi.mock('../network/websocket-client', () => ({ - wsClient: { - isConnected: vi.fn(() => true), - connect: vi.fn(), - send: vi.fn(() => true), - setCallbacks: vi.fn(), - }, + wsClient: { isConnected: vi.fn(() => true), connect: vi.fn(), send: vi.fn(() => true), setCallbacks: vi.fn() }, })); import { handleDataMessage } from '../pipeline/handle-message'; diff --git a/cdc/src/tests/heartbeat-ack.test.ts b/cdc/src/tests/heartbeat-ack.test.ts index 0720b6bf0..f868172b4 100644 --- a/cdc/src/tests/heartbeat-ack.test.ts +++ b/cdc/src/tests/heartbeat-ack.test.ts @@ -13,20 +13,10 @@ vi.mock('pg-logical-replication', async () => { return { LogicalReplicationService, PgoutputPlugin: class {} }; }); -vi.mock('../lib/db', () => ({ - cdcDb: { execute: vi.fn() }, - buildVerifiedSsl: () => undefined, - stripSslParams: (url: string) => url, -})); +vi.mock('../lib/db', () => ({ cdcDb: { execute: vi.fn() }, buildVerifiedSsl: () => undefined, stripSslParams: (url: string) => url })); vi.mock('../network/websocket-client', () => ({ - wsClient: { - isConnected: () => ws.connected, - inGracePeriod: () => false, - setCallbacks: vi.fn(), - connect: vi.fn(), - close: vi.fn(), - }, + wsClient: { isConnected: () => ws.connected, inGracePeriod: () => false, setCallbacks: vi.fn(), connect: vi.fn(), close: vi.fn() }, })); const { createReplicationService } = await import('../pipeline/replication'); @@ -83,12 +73,7 @@ describe('replication heartbeat acknowledgement', () => { }; it.each([ - { - case: 'confirms the keepalive position, one byte back because the client adds one', - acked: '0/AB', - keepalive: '0/1F0', - reply: '0/1EF', - }, + { case: 'confirms the keepalive position, one byte back because the client adds one', acked: '0/AB', keepalive: '0/1F0', reply: '0/1EF' }, { case: 'borrows from the high word at a segment boundary', acked: null, keepalive: '2/0', reply: '1/FFFFFFFF' }, { case: 'never moves backwards', acked: '0/2F0', keepalive: '0/1F0', reply: '0/2F0' }, ])('$case', async ({ acked, keepalive, reply }) => { diff --git a/cdc/src/tests/integration/cdc-backpressure.test.ts b/cdc/src/tests/integration/cdc-backpressure.test.ts index 8783a8418..a7ed4ab2b 100644 --- a/cdc/src/tests/integration/cdc-backpressure.test.ts +++ b/cdc/src/tests/integration/cdc-backpressure.test.ts @@ -15,9 +15,7 @@ async function probeReady(): Promise { try { const wal = await cdcDb.execute<{ wal_level: string }>(sql`SHOW wal_level`); if (wal.rows[0]?.wal_level !== 'logical') return false; - const pub = await cdcDb.execute<{ ok: number }>( - sql`SELECT 1 AS ok FROM pg_publication WHERE pubname = ${CDC_PUBLICATION_NAME}`, - ); + const pub = await cdcDb.execute<{ ok: number }>(sql`SELECT 1 AS ok FROM pg_publication WHERE pubname = ${CDC_PUBLICATION_NAME}`); return pub.rows.length > 0; } catch { return false; @@ -164,11 +162,7 @@ describe.skipIf(!READY)('CDC backpressure (integration)', () => { await waitFor(() => replicationState.status === 'active', 20_000, 'replication resumed'); // Acks resume → retained WAL drains well below the WS-down peak. - await waitFor( - async () => (await slotLagBytes()) < Math.max(65_536, lagPeak / 2), - 30_000, - 'slot drained after reconnect', - ); + await waitFor(async () => (await slotLagBytes()) < Math.max(65_536, lagPeak / 2), 30_000, 'slot drained after reconnect'); // Drain to the floor before probing a fresh change: the previous threshold still allows // half the burst to be in flight, whose replay competes with the probe on a slow runner. diff --git a/cdc/src/tests/integration/pipeline-harness.ts b/cdc/src/tests/integration/pipeline-harness.ts index 4fb629a37..c0c0dbf9d 100644 --- a/cdc/src/tests/integration/pipeline-harness.ts +++ b/cdc/src/tests/integration/pipeline-harness.ts @@ -9,11 +9,7 @@ import { createReplicationService, ensureReplicationSlot, setupBackpressure } fr import { replicationState } from '../../services/replication-state'; /** Poll a predicate until it holds or the deadline passes. */ -export async function waitFor( - predicate: () => boolean | Promise, - timeoutMs: number, - label: string, -): Promise { +export async function waitFor(predicate: () => boolean | Promise, timeoutMs: number, label: string): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await predicate()) return; @@ -24,9 +20,7 @@ export async function waitFor( /** Whether the CDC replication slot is currently held by a connection. */ export async function slotActive(): Promise { - const res = await cdcDb.execute<{ active: boolean }>( - sql`SELECT active FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`, - ); + const res = await cdcDb.execute<{ active: boolean }>(sql`SELECT active FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`); return res.rows[0]?.active ?? false; } @@ -41,9 +35,7 @@ export interface CdcPipelineHarness { */ export async function startCdcPipeline(): Promise { // Drop a leftover slot from a previous run; bail if one is actively held. - const existing = await cdcDb.execute<{ active: boolean }>( - sql`SELECT active FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`, - ); + const existing = await cdcDb.execute<{ active: boolean }>(sql`SELECT active FROM pg_replication_slots WHERE slot_name = ${CDC_SLOT_NAME}`); if (existing.rows[0]?.active) { throw new Error(`Replication slot '${CDC_SLOT_NAME}' is already active: another worker is using the test DB`); } @@ -72,9 +64,7 @@ export async function startCdcPipeline(): Promise { await service.stop().catch(() => {}); await drainBuffers().catch(() => {}); // service.stop() releases the active flag; wait for it before dropping. - await waitFor(async () => !(await slotActive()), 10_000, `replication slot '${CDC_SLOT_NAME}' released`).catch( - () => {}, - ); + await waitFor(async () => !(await slotActive()), 10_000, `replication slot '${CDC_SLOT_NAME}' released`).catch(() => {}); await cdcDb .execute( sql`SELECT pg_drop_replication_slot(${CDC_SLOT_NAME}) diff --git a/cdc/src/tests/owned-embedding-gc.test.ts b/cdc/src/tests/owned-embedding-gc.test.ts index 688dfdb66..f7ae765e4 100644 --- a/cdc/src/tests/owned-embedding-gc.test.ts +++ b/cdc/src/tests/owned-embedding-gc.test.ts @@ -124,10 +124,7 @@ describe('gcOwnedEmbeddedRows', () => { it('groups candidates per organization', async () => { await gc('task', [ hostEvent({ ...base, items: [] }, { ...base, items: ['i1'] }), - hostEvent( - { ...base, id: 't2', organizationId: 'o2', items: [] }, - { ...base, id: 't2', organizationId: 'o2', items: ['i2'] }, - ), + hostEvent({ ...base, id: 't2', organizationId: 'o2', items: [] }, { ...base, id: 't2', organizationId: 'o2', items: ['i2'] }), ]); expect(updates).toHaveLength(2); diff --git a/cdc/src/tests/parse-message.test.ts b/cdc/src/tests/parse-message.test.ts index 49890c27f..2d7bb1e85 100644 --- a/cdc/src/tests/parse-message.test.ts +++ b/cdc/src/tests/parse-message.test.ts @@ -46,16 +46,11 @@ describe('parseMessage: draft entrance guard', () => { const result = parseMessage(dmlMessage('insert', 'attachments', attachmentRow({ published_at: null }))); expect(result).toBeNull(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('publication row filter missing'), { - entityType: 'attachment', - action: 'create', - }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('publication row filter missing'), { entityType: 'attachment', action: 'create' }); }); it('drops a draft product UPDATE and a draft hard-DELETE (old-row snapshot checked)', () => { - const update = parseMessage( - dmlMessage('update', 'attachments', attachmentRow({ published_at: null }), attachmentRow({ published_at: null })), - ); + const update = parseMessage(dmlMessage('update', 'attachments', attachmentRow({ published_at: null }), attachmentRow({ published_at: null }))); const del = parseMessage(dmlMessage('delete', 'attachments', attachmentRow({ published_at: null }))); expect(update).toBeNull(); @@ -70,9 +65,7 @@ describe('parseMessage: draft entrance guard', () => { }); it('passes a published product INSERT (the publish edge as delivered)', () => { - const result = parseMessage( - dmlMessage('insert', 'attachments', attachmentRow({ published_at: '2026-07-04T09:00:00.000Z' })), - ); + const result = parseMessage(dmlMessage('insert', 'attachments', attachmentRow({ published_at: '2026-07-04T09:00:00.000Z' }))); expect(result).not.toBeNull(); expect(result?.activity.action).toBe('create'); @@ -80,9 +73,7 @@ describe('parseMessage: draft entrance guard', () => { }); it('passes an unpublish-as-DELETE (old row is published)', () => { - const result = parseMessage( - dmlMessage('delete', 'attachments', attachmentRow({ published_at: '2026-07-04T09:00:00.000Z' })), - ); + const result = parseMessage(dmlMessage('delete', 'attachments', attachmentRow({ published_at: '2026-07-04T09:00:00.000Z' }))); expect(result).not.toBeNull(); expect(result?.activity.action).toBe('delete'); @@ -90,12 +81,7 @@ describe('parseMessage: draft entrance guard', () => { it('never drops channel rows: channel publishedAt gates invitees, not replication', () => { const result = parseMessage( - dmlMessage('insert', 'organizations', { - id: 'org-1', - name: 'Org', - created_at: '2026-07-01T10:00:00.000Z', - published_at: null, - }), + dmlMessage('insert', 'organizations', { id: 'org-1', name: 'Org', created_at: '2026-07-01T10:00:00.000Z', published_at: null }), ); expect(result).not.toBeNull(); diff --git a/cdc/src/tests/replication-slot.test.ts b/cdc/src/tests/replication-slot.test.ts index c42807851..5d92520e4 100644 --- a/cdc/src/tests/replication-slot.test.ts +++ b/cdc/src/tests/replication-slot.test.ts @@ -10,13 +10,7 @@ vi.mock('../lib/db', () => ({ })); vi.mock('../network/websocket-client', () => ({ - wsClient: { - isConnected: () => true, - inGracePeriod: () => false, - setCallbacks: vi.fn(), - connect: vi.fn(), - close: vi.fn(), - }, + wsClient: { isConnected: () => true, inGracePeriod: () => false, setCallbacks: vi.fn(), connect: vi.fn(), close: vi.fn() }, })); import { RESOURCE_LIMITS } from '../constants'; @@ -30,10 +24,7 @@ function makeService(failures: number): LogicalReplicationService { return { subscribe: vi.fn(() => { calls += 1; - if (calls <= failures) - return Promise.reject( - Object.assign(new Error('replication slot "cdc_slot" does not exist'), { code: '42704' }), - ); + if (calls <= failures) return Promise.reject(Object.assign(new Error('replication slot "cdc_slot" does not exist'), { code: '42704' })); return new Promise(() => {}); // never resolves: subscribed and streaming }), } as unknown as LogicalReplicationService; @@ -111,8 +102,7 @@ function makeStaleService(failures: number): LogicalReplicationService { return { subscribe: vi.fn(() => { calls += 1; - if (calls <= failures) - return Promise.reject(Object.assign(new Error('publication "cdc_pub" does not exist'), { code: '42704' })); + if (calls <= failures) return Promise.reject(Object.assign(new Error('publication "cdc_pub" does not exist'), { code: '42704' })); return new Promise(() => {}); // never resolves: subscribed and streaming }), } as unknown as LogicalReplicationService; diff --git a/cdc/src/tests/retry.test.ts b/cdc/src/tests/retry.test.ts index c3c59509a..dcdbe176c 100644 --- a/cdc/src/tests/retry.test.ts +++ b/cdc/src/tests/retry.test.ts @@ -79,21 +79,13 @@ describe('retry utility', () => { const result = await withRetry(fn, 'test operation'); - expect(result).toEqual({ - success: true, - value: 'success', - attempts: 1, - }); + expect(result).toEqual({ success: true, value: 'success', attempts: 1 }); expect(fn).toHaveBeenCalledTimes(1); }); it('should retry on transient error and succeed', async () => { const transientError = Object.assign(new Error('deadlock detected'), { code: '40P01' }); - const fn = vi - .fn() - .mockRejectedValueOnce(transientError) - .mockRejectedValueOnce(transientError) - .mockResolvedValue('success after retries'); + const fn = vi.fn().mockRejectedValueOnce(transientError).mockRejectedValueOnce(transientError).mockResolvedValue('success after retries'); const resultPromise = withRetry(fn, 'test operation'); @@ -102,11 +94,7 @@ describe('retry utility', () => { const result = await resultPromise; - expect(result).toEqual({ - success: true, - value: 'success after retries', - attempts: 3, - }); + expect(result).toEqual({ success: true, value: 'success after retries', attempts: 3 }); expect(fn).toHaveBeenCalledTimes(3); }); @@ -131,9 +119,7 @@ describe('retry utility', () => { }); it('should not retry on non-transient error', async () => { - const nonTransientError = Object.assign(new Error('unique constraint violation'), { - code: '23505', - }); + const nonTransientError = Object.assign(new Error('unique constraint violation'), { code: '23505' }); const fn = vi.fn().mockRejectedValue(nonTransientError); const result = await withRetry(fn, 'test operation'); @@ -150,11 +136,7 @@ describe('retry utility', () => { it('should use exponential backoff for delays', async () => { const transientError = new Error('connection timeout'); - const fn = vi - .fn() - .mockRejectedValueOnce(transientError) - .mockRejectedValueOnce(transientError) - .mockResolvedValue('success'); + const fn = vi.fn().mockRejectedValueOnce(transientError).mockRejectedValueOnce(transientError).mockResolvedValue('success'); const resultPromise = withRetry(fn, 'test operation'); diff --git a/cdc/src/tests/role-capabilities.test.ts b/cdc/src/tests/role-capabilities.test.ts index a2c466e27..fc0e2bbdc 100644 --- a/cdc/src/tests/role-capabilities.test.ts +++ b/cdc/src/tests/role-capabilities.test.ts @@ -3,9 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const execute = vi.fn(); vi.mock('../lib/db', () => ({ cdcDb: { execute: (...args: unknown[]) => execute(...args) } })); -const { getRoleCapabilities, probeRoleCapabilities, resetRoleCapabilities } = await import( - '../services/role-capabilities' -); +const { getRoleCapabilities, probeRoleCapabilities, resetRoleCapabilities } = await import('../services/role-capabilities'); const { log } = await import('../lib/pino'); const row = (overrides: Record = {}) => ({ @@ -27,12 +25,7 @@ describe('probeRoleCapabilities', () => { it('accepts an owner without BYPASSRLS when no RLS table is forced or foreign-owned (the managed-provider shape)', async () => { execute.mockResolvedValue({ rows: [row()] }); - await expect(probeRoleCapabilities()).resolves.toEqual({ - role: 'admin_role', - rlsBypass: true, - rlsBlockedTables: [], - replication: true, - }); + await expect(probeRoleCapabilities()).resolves.toEqual({ role: 'admin_role', rlsBypass: true, rlsBlockedTables: [], replication: true }); expect(log.error).not.toHaveBeenCalled(); }); @@ -70,12 +63,7 @@ describe('probeRoleCapabilities', () => { rows: [row({ role: 'postgres', superuser: true, replication: false, rls_blocked_tables: ['attachments'] })], }); - await expect(probeRoleCapabilities()).resolves.toEqual({ - role: 'postgres', - rlsBypass: true, - rlsBlockedTables: [], - replication: true, - }); + await expect(probeRoleCapabilities()).resolves.toEqual({ role: 'postgres', rlsBypass: true, rlsBlockedTables: [], replication: true }); }); it('leaves the capabilities unknown when the probe fails', async () => { diff --git a/cdc/src/tests/secret-columns.test.ts b/cdc/src/tests/secret-columns.test.ts index 9813098a1..22a36a9be 100644 --- a/cdc/src/tests/secret-columns.test.ts +++ b/cdc/src/tests/secret-columns.test.ts @@ -7,9 +7,7 @@ import { secretColumnPattern, secretColumns, secretLookingColumns } from '#/db/s /** Every table in the schema, from the same files drizzle-kit reads (`backend/src/modules/** /*-db.ts`). */ const modulesDir = path.resolve(import.meta.dirname, '../../../backend/src/modules'); -const dbFiles = readdirSync(modulesDir, { recursive: true, encoding: 'utf8' }).filter((file) => - file.endsWith('-db.ts'), -); +const dbFiles = readdirSync(modulesDir, { recursive: true, encoding: 'utf8' }).filter((file) => file.endsWith('-db.ts')); const allTables = new Map(); @@ -22,8 +20,7 @@ beforeAll(async () => { } }); -const listed = (registry: Record, table: string, column: string): boolean => - registry[table]?.includes(column) ?? false; +const listed = (registry: Record, table: string, column: string): boolean => registry[table]?.includes(column) ?? false; describe('secretColumns registry', () => { it('sees the whole schema', () => { diff --git a/cdc/src/tests/transaction-buffer.test.ts b/cdc/src/tests/transaction-buffer.test.ts index c5d1c8e0c..8ebe433a2 100644 --- a/cdc/src/tests/transaction-buffer.test.ts +++ b/cdc/src/tests/transaction-buffer.test.ts @@ -46,39 +46,13 @@ describe('TransactionBuffer', () => { it('suppresses cascaded child deletes when the parent channel entity is deleted', async () => { buffer.onBegin({ tag: 'begin', xid: 42, commitLsn: null, commitTime: BigInt(0) }); - const t1 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-1', - organizationId: 'org-1', - }); - const t2 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-2', - organizationId: 'org-1', - }); - const t3 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-3', - organizationId: 'org-1', - }); + const t1 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-1', organizationId: 'org-1' }); + const t2 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-2', organizationId: 'org-1' }); + const t3 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-3', organizationId: 'org-1' }); - const m1 = mockParseResult({ - action: 'delete', - resourceType: 'membership', - entityType: null, - subjectId: 'mem-1', - organizationId: 'org-1', - }); + const m1 = mockParseResult({ action: 'delete', resourceType: 'membership', entityType: null, subjectId: 'mem-1', organizationId: 'org-1' }); - const proj = mockParseResult({ - action: 'delete', - entityType: 'organization', - subjectId: 'org-1', - organizationId: 'org-1', - }); + const proj = mockParseResult({ action: 'delete', entityType: 'organization', subjectId: 'org-1', organizationId: 'org-1' }); await buffer.onEvent('0/1', t1); await buffer.onEvent('0/2', t2); @@ -89,38 +63,20 @@ describe('TransactionBuffer', () => { await buffer.onCommit(); expect(processedEvents).toHaveLength(1); - const survivors = processedEvents.map((e) => ({ - entityType: e.result.activity.entityType, - subjectId: e.result.activity.subjectId, - })); + const survivors = processedEvents.map((e) => ({ entityType: e.result.activity.entityType, subjectId: e.result.activity.subjectId })); expect(survivors).toContainEqual({ entityType: 'organization', subjectId: 'org-1' }); }); it('does not suppress deletes from different channel entities', async () => { buffer.onBegin({ tag: 'begin', xid: 43, commitLsn: null, commitTime: BigInt(0) }); - const proj = mockParseResult({ - action: 'delete', - entityType: 'organization', - subjectId: 'org-1', - organizationId: 'org-1', - }); + const proj = mockParseResult({ action: 'delete', entityType: 'organization', subjectId: 'org-1', organizationId: 'org-1' }); // Different org: not suppressed. - const t1 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-99', - organizationId: 'org-other', - }); + const t1 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-99', organizationId: 'org-other' }); // Deleted org: suppressed. - const t2 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-1', - organizationId: 'org-1', - }); + const t2 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-1', organizationId: 'org-1' }); await buffer.onEvent('0/1', proj); await buffer.onEvent('0/2', t1); @@ -137,12 +93,7 @@ describe('TransactionBuffer', () => { buffer.onBegin({ tag: 'begin', xid: 44, commitLsn: null, commitTime: BigInt(0) }); const update = mockParseResult({ action: 'update', entityType: 'attachment', subjectId: 'attachment-1' }); - const proj = mockParseResult({ - action: 'delete', - entityType: 'organization', - subjectId: 'org-1', - organizationId: 'org-1', - }); + const proj = mockParseResult({ action: 'delete', entityType: 'organization', subjectId: 'org-1', organizationId: 'org-1' }); await buffer.onEvent('0/1', update); await buffer.onEvent('0/2', proj); @@ -156,19 +107,8 @@ describe('TransactionBuffer', () => { buffer.onBegin({ tag: 'begin', xid: 45, commitLsn: null, commitTime: BigInt(0) }); const org = mockParseResult({ action: 'delete', entityType: 'organization', subjectId: 'org-1' }); - const t1 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-1', - organizationId: 'org-1', - }); - const m1 = mockParseResult({ - action: 'delete', - resourceType: 'membership', - entityType: null, - subjectId: 'mem-1', - organizationId: 'org-1', - }); + const t1 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-1', organizationId: 'org-1' }); + const m1 = mockParseResult({ action: 'delete', resourceType: 'membership', entityType: null, subjectId: 'mem-1', organizationId: 'org-1' }); await buffer.onEvent('0/1', org); await buffer.onEvent('0/2', t1); @@ -188,12 +128,7 @@ describe('TransactionBuffer', () => { // 50,000 cascaded child deletes prove suppression stays memory-bounded. for (let i = 0; i < 50_000; i++) { - const task = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: `task-${i}`, - organizationId: 'org-1', - }); + const task = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: `task-${i}`, organizationId: 'org-1' }); await buffer.onEvent(`0/${i + 1}`, task); } @@ -210,24 +145,9 @@ describe('TransactionBuffer', () => { buffer.onBegin({ tag: 'begin', xid: 101, commitLsn: null, commitTime: BigInt(0) }); // Non-standard WAL order: children before parent. - const t1 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-1', - organizationId: 'org-1', - }); - const t2 = mockParseResult({ - action: 'delete', - entityType: 'attachment', - subjectId: 'attachment-2', - organizationId: 'org-1', - }); - const proj = mockParseResult({ - action: 'delete', - entityType: 'organization', - subjectId: 'org-1', - organizationId: 'org-1', - }); + const t1 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-1', organizationId: 'org-1' }); + const t2 = mockParseResult({ action: 'delete', entityType: 'attachment', subjectId: 'attachment-2', organizationId: 'org-1' }); + const proj = mockParseResult({ action: 'delete', entityType: 'organization', subjectId: 'org-1', organizationId: 'org-1' }); await buffer.onEvent('0/1', t1); await buffer.onEvent('0/2', t2); diff --git a/cdc/src/tests/unified-deltas.test.ts b/cdc/src/tests/unified-deltas.test.ts index cac7b98a5..0271dd132 100644 --- a/cdc/src/tests/unified-deltas.test.ts +++ b/cdc/src/tests/unified-deltas.test.ts @@ -78,11 +78,7 @@ describe('membership count deltas (via computeBatchUnifiedDeltas)', () => { describe('computeBatchUnifiedDeltas', () => { it('batch of 5 attachment creates in same org: accumulates deltas', () => { const events = Array.from({ length: 5 }, (_, i) => - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: `att-${i}`, organizationId: 'org-1' }, - }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: `att-${i}`, organizationId: 'org-1' } }), ); const plan = computeBatchUnifiedDeltas(events); @@ -149,11 +145,7 @@ describe('computeBatchUnifiedDeltas', () => { it("one sequence group per organization, holding that organization's events alone", () => { const events = Array.from({ length: 3 }, (_, i) => - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: `att-${i}`, organizationId: `org-${i}` }, - }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: `att-${i}`, organizationId: `org-${i}` } }), ); const plan = computeBatchUnifiedDeltas(events); @@ -172,33 +164,17 @@ describe('activity stamps (e:li:h:{type} / e:lu:h:{type})', () => { it('attachment create stamps e:li:h:attachment with the row createdAt at the home key', () => { const plan = computeBatchUnifiedDeltas([ - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: 'att-1', organizationId: 'org-1', createdAt }, - }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: 'att-1', organizationId: 'org-1', createdAt } }), ]); - expect(plan.countDeltasByChannelKey.get('org-1')).toEqual({ - 'e:c:attachment': 1, - 'e:c:h:attachment': 1, - 'e:li:h:attachment': createdAtMs, - }); + expect(plan.countDeltasByChannelKey.get('org-1')).toEqual({ 'e:c:attachment': 1, 'e:c:h:attachment': 1, 'e:li:h:attachment': createdAtMs }); }); it('two creates in one batch max-merge the stamp (timestamps must not sum)', () => { const laterCreatedAt = '2026-07-02T10:00:00.000Z'; const plan = computeBatchUnifiedDeltas([ - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: 'att-1', organizationId: 'org-1', createdAt: laterCreatedAt }, - }), - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: 'att-2', organizationId: 'org-1', createdAt }, - }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: 'att-1', organizationId: 'org-1', createdAt: laterCreatedAt } }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: 'att-2', organizationId: 'org-1', createdAt } }), ]); expect(plan.countDeltasByChannelKey.get('org-1')).toEqual({ @@ -211,11 +187,7 @@ describe('activity stamps (e:li:h:{type} / e:lu:h:{type})', () => { it('missing createdAt falls back to Date.now()', () => { const before = Date.now(); const plan = computeBatchUnifiedDeltas([ - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: 'att-1', organizationId: 'org-1' }, - }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: 'att-1', organizationId: 'org-1' } }), ]); const after = Date.now(); @@ -227,11 +199,7 @@ describe('activity stamps (e:li:h:{type} / e:lu:h:{type})', () => { it('a row created directly published stamps li: from publishedAt', () => { const publishedAt = '2026-07-01T10:00:00.500Z'; const plan = computeBatchUnifiedDeltas([ - changeEvent({ - tableMeta: attachmentEntry(), - action: 'create', - rowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt }, - }), + changeEvent({ tableMeta: attachmentEntry(), action: 'create', rowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt } }), ]); expect(plan.countDeltasByChannelKey.get('org-1')).toEqual({ @@ -252,9 +220,7 @@ describe('activity stamps (e:li:h:{type} / e:lu:h:{type})', () => { }), ]); - expect(plan.countDeltasByChannelKey.get('org-1')).toEqual({ - 'e:lu:h:attachment': Date.parse(updatedAt), - }); + expect(plan.countDeltasByChannelKey.get('org-1')).toEqual({ 'e:lu:h:attachment': Date.parse(updatedAt) }); }); it('update with missing updatedAt falls back to Date.now()', () => { @@ -312,14 +278,7 @@ describe('draft lifecycle count deltas (publication row filter delivery)', () => changeEvent({ tableMeta: attachmentEntry(), action: 'create', - rowData: { - id: 'att-1', - organizationId: 'org-1', - createdAt, - updatedAt: publishedAt, - publishedAt, - deletedAt: null, - }, + rowData: { id: 'att-1', organizationId: 'org-1', createdAt, updatedAt: publishedAt, publishedAt, deletedAt: null }, }), ]); @@ -349,13 +308,7 @@ describe('draft lifecycle count deltas (publication row filter delivery)', () => changeEvent({ tableMeta: attachmentEntry(), action: 'create', - rowData: { - id: 'att-1', - organizationId: 'org-1', - createdAt, - publishedAt, - deletedAt: '2026-07-03T10:00:00.000Z', - }, + rowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt, deletedAt: '2026-07-03T10:00:00.000Z' }, }), ]); @@ -368,13 +321,7 @@ describe('draft lifecycle count deltas (publication row filter delivery)', () => changeEvent({ tableMeta: attachmentEntry(), action: 'update', - rowData: { - id: 'att-1', - organizationId: 'org-1', - createdAt, - publishedAt, - deletedAt: '2026-07-05T10:00:00.000Z', - }, + rowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt, deletedAt: '2026-07-05T10:00:00.000Z' }, oldRowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt, deletedAt: null }, }), ]); @@ -388,13 +335,7 @@ describe('draft lifecycle count deltas (publication row filter delivery)', () => tableMeta: attachmentEntry(), action: 'update', rowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt, deletedAt: null }, - oldRowData: { - id: 'att-1', - organizationId: 'org-1', - createdAt, - publishedAt, - deletedAt: '2026-07-05T10:00:00.000Z', - }, + oldRowData: { id: 'att-1', organizationId: 'org-1', createdAt, publishedAt, deletedAt: '2026-07-05T10:00:00.000Z' }, }), ]); diff --git a/cdc/src/tests/utils.test.ts b/cdc/src/tests/utils.test.ts index edd70750e..17a4d4ea9 100644 --- a/cdc/src/tests/utils.test.ts +++ b/cdc/src/tests/utils.test.ts @@ -104,11 +104,7 @@ describe('extractStxData', () => { const row = { stx: { mutationId: 'mut-1', sourceId: 'src-1', fieldTimestamps: { name: '100:0001:aaa' } }, }; - expect(extractStxData(row)).toEqual({ - mutationId: 'mut-1', - sourceId: 'src-1', - fieldTimestamps: { name: '100:0001:aaa' }, - }); + expect(extractStxData(row)).toEqual({ mutationId: 'mut-1', sourceId: 'src-1', fieldTimestamps: { name: '100:0001:aaa' } }); }); it('should return null when stx is not present', () => { @@ -124,9 +120,7 @@ describe('extractStxData', () => { }); it('should default fieldTimestamps to empty object', () => { - const row = { - stx: { mutationId: 'mut-1', sourceId: 'src-1' }, - }; + const row = { stx: { mutationId: 'mut-1', sourceId: 'src-1' } }; const result = extractStxData(row); expect(result?.fieldTimestamps).toEqual({}); }); diff --git a/cdc/src/tests/wire-contract.type-check.ts b/cdc/src/tests/wire-contract.type-check.ts index 48ea85321..7f489339d 100644 --- a/cdc/src/tests/wire-contract.type-check.ts +++ b/cdc/src/tests/wire-contract.type-check.ts @@ -3,9 +3,7 @@ import type { CdcOutboundMessage } from '../services/activity-service'; type ActivityFieldsTolerant = { [K in keyof T]?: T[K] }; -type WireConformanceTarget = Omit & { - activity: ActivityFieldsTolerant; -}; +type WireConformanceTarget = Omit & { activity: ActivityFieldsTolerant }; type Assert = T; diff --git a/cdc/src/utils/apply-unified-deltas.ts b/cdc/src/utils/apply-unified-deltas.ts index f278590f2..2ee6cafb6 100644 --- a/cdc/src/utils/apply-unified-deltas.ts +++ b/cdc/src/utils/apply-unified-deltas.ts @@ -13,11 +13,7 @@ import { isMaxMergeKey } from './update-counts'; * GREATEST(0, existing + delta) per key and max-merges `e:li:`/`e:lu:`/`e:f:` keys. The SQL shape is * fixed so PostgreSQL can cache the plan. */ -async function mergedUpsert( - channelKey: string, - deltas: Record, - returning: true, -): Promise>; +async function mergedUpsert(channelKey: string, deltas: Record, returning: true): Promise>; async function mergedUpsert(channelKey: string, deltas: Record, returning?: false): Promise; async function mergedUpsert( channelKey: string, @@ -56,10 +52,7 @@ async function mergedUpsert( * Adds `source` into `target` in place, summing on key collision. Max-merge keys keep the max, since * apply_count_deltas only ever moves stamps and frontiers forward. */ -export function sumInto( - target: Record, - source: Record | undefined, -): Record { +export function sumInto(target: Record, source: Record | undefined): Record { if (source) { for (const [k, v] of Object.entries(source)) { target[k] = isMaxMergeKey(k) ? Math.max(target[k] ?? 0, v) : (target[k] ?? 0) + v; @@ -73,10 +66,7 @@ export function sumInto( * reserves WAL-ordered sequence ranges; phase 2 writes ancestor frontiers, remaining counts, and the * row seq values. */ -export async function applyBatchUnifiedDeltas( - plan: BatchUnifiedDeltaPlan, - h: EntityHierarchy = hierarchy, -): Promise { +export async function applyBatchUnifiedDeltas(plan: BatchUnifiedDeltaPlan, h: EntityHierarchy = hierarchy): Promise { const { orgSequenceGroups, countDeltasByChannelKey } = plan; const handledChannelKeys = new Set(); @@ -111,12 +101,7 @@ export async function applyBatchUnifiedDeltas( mergeDelta(phase2Deltas, home, { [`e:f:h:${tableMeta.type}`]: seq }); } - log.trace('Batch sequence stamped', { - orgKey: group.orgKey, - count: group.count, - baseSeq: baseSeq + 1, - highSeq, - }); + log.trace('Batch sequence stamped', { orgKey: group.orgKey, count: group.count, baseSeq: baseSeq + 1, highSeq }); } // Phase 2: frontier marks + remaining count UPSERTs + bulk entity stamp, all in parallel. diff --git a/cdc/src/utils/channel-columns.ts b/cdc/src/utils/channel-columns.ts index 57e361d56..608822289 100644 --- a/cdc/src/utils/channel-columns.ts +++ b/cdc/src/utils/channel-columns.ts @@ -5,6 +5,6 @@ import { appConfig } from 'shared'; * Id column keys for every channel entity type, e.g. `['organizationId']`. Shared by the activity * builder, the transaction buffer, and the delta planner so all three track `channelEntityTypes`. */ -export const channelIdColumnKeys = appConfig.channelEntityTypes.map( - (type) => appConfig.entityIdColumnKeys[type], -) as ReadonlyArray; +export const channelIdColumnKeys = appConfig.channelEntityTypes.map((type) => appConfig.entityIdColumnKeys[type]) as ReadonlyArray< + keyof ChannelIdColumns +>; diff --git a/cdc/src/utils/compute-unified-deltas.ts b/cdc/src/utils/compute-unified-deltas.ts index b57ddc562..8845fa2a0 100644 --- a/cdc/src/utils/compute-unified-deltas.ts +++ b/cdc/src/utils/compute-unified-deltas.ts @@ -30,30 +30,18 @@ export interface OrgSequenceGroup { * organization; a missing organization violates the hierarchy and throws. The key groups audiences, * activity stamps, and unseen counts, never sequence allocation. */ -export function resolveChannelKey( - entityType: string, - rowData: CdcRowData, - activity: ActivityWithoutId, - h: EntityHierarchy = hierarchy, -): string { +export function resolveChannelKey(entityType: string, rowData: CdcRowData, activity: ActivityWithoutId, h: EntityHierarchy = hierarchy): string { const deepest = h.resolveDeepestAncestorId(entityType, rowData); if (deepest) return deepest; if (activity.organizationId) return activity.organizationId; - throw new Error( - `No context for ${entityType} row ${rowData.id}: the hierarchy model requires an organization ancestor`, - ); + throw new Error(`No context for ${entityType} row ${rowData.id}: the hierarchy model requires an organization ancestor`); } /** * Channel-counter nodes a stamped row's frontier propagates to: the organization plus every non-null * ancestor, so `e:f:{type}` at any node answers "did anything of this type change at or below here". */ -export function frontierNodeKeys( - entityType: string, - rowData: CdcRowData, - organizationId: string, - h: EntityHierarchy = hierarchy, -): string[] { +export function frontierNodeKeys(entityType: string, rowData: CdcRowData, organizationId: string, h: EntityHierarchy = hierarchy): string[] { const nodes = [organizationId]; for (const ancestor of h.resolveNonNullAncestors(entityType, rowData)) { if (ancestor.id !== organizationId) nodes.push(ancestor.id); @@ -62,11 +50,7 @@ export function frontierNodeKeys( } /** Sums matching keys; max-merge keys keep the max, since stamps and frontiers must never sum. */ -export function mergeDelta( - map: Map>, - channelKey: string, - deltas: Record, -): void { +export function mergeDelta(map: Map>, channelKey: string, deltas: Record): void { const existing = map.get(channelKey); if (existing) { for (const [k, v] of Object.entries(deltas)) { @@ -85,10 +69,7 @@ function isStampable(tableMeta: TableMeta, action: ActivityAction, h: EntityHier * Reserves one sequence range per organization, shared by all product entity types and preserving WAL * order, and accumulates count deltas. Frontier (`e:f:`) deltas wait until seq values are assigned. */ -export function computeBatchUnifiedDeltas( - events: PendingEvent[], - h: EntityHierarchy = hierarchy, -): BatchUnifiedDeltaPlan { +export function computeBatchUnifiedDeltas(events: PendingEvent[], h: EntityHierarchy = hierarchy): BatchUnifiedDeltaPlan { const countDeltasByChannelKey = new Map>(); const orgSequenceGroupMap = new Map(); @@ -99,9 +80,7 @@ export function computeBatchUnifiedDeltas( if (isStampable(tableMeta, action, h)) { const orgKey = activity.organizationId; if (!orgKey) { - throw new Error( - `No organization for ${tableMeta.type} row ${rowData.id}: the hierarchy model requires an organization ancestor`, - ); + throw new Error(`No organization for ${tableMeta.type} row ${rowData.id}: the hierarchy model requires an organization ancestor`); } const existing = orgSequenceGroupMap.get(orgKey); if (existing) { diff --git a/cdc/src/utils/embedding-cleanup.ts b/cdc/src/utils/embedding-cleanup.ts index 07b80a8f8..741f4c41d 100644 --- a/cdc/src/utils/embedding-cleanup.ts +++ b/cdc/src/utils/embedding-cleanup.ts @@ -40,18 +40,12 @@ function resolveEmbeddings(): ReadonlyMap(hierarchy.getNullableAncestors(embeddedProduct)); - const parentType = hierarchy - .getOrderedAncestors(embeddedProduct) - .find((ancestor) => !nullableAncestors.has(ancestor)); - if (!parentType) - throw new Error( - `productEmbeddings: "${embeddedProduct}" has no parent context: cleanup requires a scoping column`, - ); + const parentType = hierarchy.getOrderedAncestors(embeddedProduct).find((ancestor) => !nullableAncestors.has(ancestor)); + if (!parentType) throw new Error(`productEmbeddings: "${embeddedProduct}" has no parent context: cleanup requires a scoping column`); const parentColumnName = appConfig.entityIdColumnKeys[parentType]; const parentColumn = columns[parentColumnName]; - if (!parentColumn) - throw new Error(`productEmbeddings: column "${parentColumnName}" not found on "${hostProduct}" table`); + if (!parentColumn) throw new Error(`productEmbeddings: column "${parentColumnName}" not found on "${hostProduct}" table`); const resolved: ResolvedEmbedding = { hostTable, hostColumn, hostColumnName, parentColumnName, parentColumn }; const list = map.get(embeddedProduct); @@ -78,10 +72,7 @@ export async function cleanupEmbeddingReferences( if (!embeddings) return; // Hard delete: every event is a removal. Soft delete: only events that flip deletedAt. - const relevantEvents = - action === 'delete' - ? events - : events.filter(({ result }) => isSoftDeleteTransition(result.rowData, result.oldRowData)); + const relevantEvents = action === 'delete' ? events : events.filter(({ result }) => isSoftDeleteTransition(result.rowData, result.oldRowData)); if (relevantEvents.length === 0) return; diff --git a/cdc/src/utils/extract-stx-data.ts b/cdc/src/utils/extract-stx-data.ts index 7f8fa516a..89e39ff2c 100644 --- a/cdc/src/utils/extract-stx-data.ts +++ b/cdc/src/utils/extract-stx-data.ts @@ -18,8 +18,6 @@ export function extractStxData(row: RowData): StxBase | null { mutationId: stxObj.mutationId, sourceId: stxObj.sourceId, fieldTimestamps: - typeof stxObj.fieldTimestamps === 'object' && stxObj.fieldTimestamps !== null - ? (stxObj.fieldTimestamps as Record) - : {}, + typeof stxObj.fieldTimestamps === 'object' && stxObj.fieldTimestamps !== null ? (stxObj.fieldTimestamps as Record) : {}, }; } diff --git a/cdc/src/utils/owned-embedding-gc.ts b/cdc/src/utils/owned-embedding-gc.ts index 80e0b1d7a..660de4c7b 100644 --- a/cdc/src/utils/owned-embedding-gc.ts +++ b/cdc/src/utils/owned-embedding-gc.ts @@ -42,18 +42,15 @@ function resolveOwnedEmbeddings(): ReadonlyMap[0]); const embeddedColumns = getColumns(embeddedTable) as Record; for (const required of requiredEmbeddedColumns) { - if (!embeddedColumns[required]) - throw new Error(`owned embedding: column "${required}" not found on "${embeddedProduct}" table`); + if (!embeddedColumns[required]) throw new Error(`owned embedding: column "${required}" not found on "${embeddedProduct}" table`); } // Refcounting is scoped to the organization, so a host in a sibling subtree still spares the row. const scopeColumnName = appConfig.entityIdColumnKeys.organization; const hostScopeColumn = hostColumns[scopeColumnName]; const embeddedScopeColumn = embeddedColumns[scopeColumnName]; - if (!hostScopeColumn) - throw new Error(`owned embedding: column "${scopeColumnName}" not found on "${hostProduct}" table`); - if (!embeddedScopeColumn) - throw new Error(`owned embedding: column "${scopeColumnName}" not found on "${embeddedProduct}" table`); + if (!hostScopeColumn) throw new Error(`owned embedding: column "${scopeColumnName}" not found on "${hostProduct}" table`); + if (!embeddedScopeColumn) throw new Error(`owned embedding: column "${scopeColumnName}" not found on "${embeddedProduct}" table`); const resolved: ResolvedOwnedEmbedding = { embeddedProduct, @@ -77,8 +74,7 @@ function resolveOwnedEmbeddings(): ReadonlyMap - Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +const toIdArray = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []); /** Per-scope removal candidates with the actor to attribute the soft-delete to. */ interface ScopeCandidates { @@ -164,17 +160,10 @@ export async function gcOwnedEmbeddedRows( updatedBy: actorId, stx: stripChangedFieldsStx(), }) - .where( - and( - inArray(embeddedColumns.id, orphanIds), - eq(embeddedScopeColumn, scopeId), - isNull(embeddedColumns.deletedAt), - ), - ) + .where(and(inArray(embeddedColumns.id, orphanIds), eq(embeddedScopeColumn, scopeId), isNull(embeddedColumns.deletedAt))) .returning({ id: embeddedColumns.id }); - if (deleted.length > 0) - log.info('Owned embedded rows garbage-collected', { embeddedProduct, scopeId, count: deleted.length }); + if (deleted.length > 0) log.info('Owned embedded rows garbage-collected', { embeddedProduct, scopeId, count: deleted.length }); } catch (err) { // The flush pipeline acks the WAL position regardless: a failed GC batch leaks, never wrongly deletes. log.error('gcOwnedEmbeddedRows failed; candidates leaked', { embeddedProduct, scopeId, candidates, err }); diff --git a/cdc/src/utils/update-counts.ts b/cdc/src/utils/update-counts.ts index 9d2a7547d..6f80e40c2 100644 --- a/cdc/src/utils/update-counts.ts +++ b/cdc/src/utils/update-counts.ts @@ -41,10 +41,7 @@ export function getCountDeltas( // Memberships (active + inactive): counter deltas plus an org-level membership change signal. if (tableMeta.kind === 'resource' && (tableMeta.type === 'membership' || tableMeta.type === 'inactive_membership')) { - const delta = - tableMeta.type === 'membership' - ? getMembershipDelta(action, newRow, oldRow) - : getInactiveMembershipDelta(action, newRow, oldRow); + const delta = tableMeta.type === 'membership' ? getMembershipDelta(action, newRow, oldRow) : getInactiveMembershipDelta(action, newRow, oldRow); const deltas = delta ? [delta] : []; // Org-level signal on every membership activity (invitations included) so catchup screens // membership changes in O(1) without scanning activities. @@ -60,11 +57,7 @@ export function getCountDeltas( // Creates/publishes and content updates stamp the home context only; deletes and restores do not. if (h.isProduct(tableMeta.type) && countAction !== null && countAction !== 'delete') { const stampKey = - action === 'create' && countAction === 'create' - ? `e:li:h:${tableMeta.type}` - : countAction === 'update' - ? `e:lu:h:${tableMeta.type}` - : null; + action === 'create' && countAction === 'create' ? `e:li:h:${tableMeta.type}` : countAction === 'update' ? `e:lu:h:${tableMeta.type}` : null; if (stampKey) { // e:li:h: prefers publishedAt so CDC and recalculation stamp the same instant; e:lu:h: is updatedAt. const stampSource = stampKey.startsWith('e:li:') @@ -117,11 +110,7 @@ export function getCountDeltas( * (restore, publish), leave = delete (soft-delete, unpublish), stay inside = update, stay outside * = null (invisible to counters and stamps). */ -function deriveCountAction( - action: ActivityAction, - newRow: CdcRowData, - oldRow: CdcRowData | null, -): ActivityAction | null { +function deriveCountAction(action: ActivityAction, newRow: CdcRowData, oldRow: CdcRowData | null): ActivityAction | null { if (action === 'create') return isCountableRow(newRow) ? 'create' : null; if (action === 'delete') return isCountableRow(oldRow ?? newRow) ? 'delete' : null; // REPLICA IDENTITY FULL always carries the old row on updates; fallback only. @@ -169,11 +158,7 @@ function getMembershipDelta(action: ActivityAction, newRow: CdcRowData, oldRow: return null; } /** Inactive membership m:c:pending delta; only rows with rejectedAt null count as pending. */ -function getInactiveMembershipDelta( - action: ActivityAction, - newRow: CdcRowData, - oldRow: CdcRowData | null, -): CountDelta | null { +function getInactiveMembershipDelta(action: ActivityAction, newRow: CdcRowData, oldRow: CdcRowData | null): CountDelta | null { const channelId = getStringValue(newRow, 'channelId'); if (!channelId) return null; diff --git a/cdc/tsup.config.ts b/cdc/tsup.config.ts index a5e543537..22fdf06df 100644 --- a/cdc/tsup.config.ts +++ b/cdc/tsup.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from 'tsup'; import { appKeepOnDisk } from '../backend/src/bundle-config.ts'; import { keepOnDisk } from '../shared/src/keep-on-disk.ts'; +import pkg from './package.json' with { type: 'json' }; -const { noExternal, external } = keepOnDisk(['pg-logical-replication', ...appKeepOnDisk]); +const { noExternal, external } = keepOnDisk(['pg-logical-replication', ...appKeepOnDisk], pkg.dependencies); export default defineConfig({ entry: ['src/cdc-worker.ts'], diff --git a/cella/ADD_ENTITY.md b/cella/ADD_ENTITY.md index 47ca4424b..a3583513b 100644 --- a/cella/ADD_ENTITY.md +++ b/cella/ADD_ENTITY.md @@ -24,7 +24,7 @@ Pick the kind ([Architecture](./ARCHITECTURE.md#entity-hierarchy-model)): a **ch - `backend/src/modules//-db.ts`: copy [attachment-db.ts](../backend/src/modules/attachment/attachment-db.ts): spread `productColumns('')` and `channelRelationColumns('')`. Keep the `(organizationId, seq)` index (test-enforced), `organizationForeignKey(table)`, and `tenantSelectPolicy` + `writeThroughPolicies`. Non-entity tables: [Optional capabilities](#optional-capabilities). - [channel-tables.ts](../backend/src/db/channel-tables.ts) or [product-tables.ts](../backend/src/db/product-tables.ts): add a lazy getter. This feeds `entityTables` and with it RLS grants, the CDC publication, immutability triggers, and activity tracking. - `-schema.ts`: Zod schemas plus `evolutionContract.product('', { createItem, updateOps, blockFields })`, copy [attachment-schema.ts](../backend/src/modules/attachment/attachment-schema.ts); `blockFields` names the block-document columns. CI `lens:check` fails without it ([Schema evolution](./SCHEMA_EVOLUTION.md)). -- Other files, copy the [attachment module](../backend/src/modules/attachment/): `-routes.ts` (`createXRoute` with `xGuard: [userGuard, tenantGuard, orgGuard]`), `-handlers.ts`, `-queries.ts`, `operations/*.ts`, `-mocks.ts`. Reads in `tenantRead()`, writes in `tenantContext()` ([tenant-context.ts](../backend/src/db/tenant-context.ts)). Permissions via `canCreateEntity` / `getValidProduct` / `resolveCollectionReadFilter`. Creates run `checkIdempotency(ctx, sTable, mutationId)` before inserting; creates and updates call `Contract.assertBlockFields(input, organizationId)`. +- Other files, copy the [attachment module](../backend/src/modules/attachment/): `-routes.ts` (`createXRoutes` with `xGuard: [userGuard, tenantGuard, orgGuard]`), `-handlers.ts`, `-queries.ts`, `operations/*.ts`, `-mocks.ts`. Reads in `tenantRead()`, writes in `tenantContext()` ([tenant-context.ts](../backend/src/db/tenant-context.ts)). Permissions via `canCreateEntity` / `getValidProduct` / `resolveCollectionReadFilter`. Creates run `checkIdempotency(ctx, sTable, mutationId)` before inserting; creates and updates call `Contract.assertBlockFields(input, organizationId)`. - `-module.ts`: declare the mount in `defineBackendModule`, e.g. `routes: [{ path: '/:tenantId/:organizationId/s', app: handlers, phase: 'tenant' }]`. [routes.ts](../backend/src/routes.ts) mounts per phase. Import the module in the pinned [modules.ts](../backend/src/modules.ts). - `operations/get-s.ts`: copy [get-attachments.ts](../backend/src/modules/attachment/operations/get-attachments.ts): `seqCursor` in the query schema, `seqCursorFilters`, order `asc(seq)` then `asc(id)`, include tombstones, read via `tenantReadIncludingDeleted`. @@ -35,7 +35,7 @@ Pick the kind ([Architecture](./ARCHITECTURE.md#entity-hierarchy-model)): a **ch ### Frontend -- `frontend/src/modules//query.ts`, copy [attachment/query.ts](../frontend/src/modules/attachment/query.ts): `createEntityKeys('')` and `registerEntityQueryKeys('', keys, deltaFetch)` (missing registration throws on SSE dispatch). Query options (canonical, infinite, detail) and mutations via `createOptimisticEntity`. Add `addMutationRegistrar(...)` so paused offline mutations resume after reload. +- `frontend/src/modules//query.ts`, copy [attachment/query.ts](../frontend/src/modules/attachment/query.ts): `createEntityKeys('')` and `registerEntityQueryKeys('', keys, deltaFetch)` (missing registration throws on SSE dispatch). Lists filtered on a row column can add `registerEqualityFilterKeys('', [''])`, so a new row refetches only the lists it can belong to. Query options (canonical, infinite, detail) and mutations via `createOptimisticEntity`. Add `addMutationRegistrar(...)` so paused offline mutations resume after reload. - Add `types.ts`, `search-params-schemas.ts`, and the UI components. Then `pnpm check` regenerates SDK types, client functions, and Zod schemas. - [list-queries-config.tsx](../frontend/src/list-queries-config.tsx): import the canonical options (the eager import triggers self-registration) and push them in `buildEntitySyncQueries` under the parent channel. Add a route file under `frontend/src/routes/`. diff --git a/cella/AGENTS.md b/cella/AGENTS.md index f6bf8aded..febe48450 100644 --- a/cella/AGENTS.md +++ b/cella/AGENTS.md @@ -17,7 +17,7 @@ Tech stack, file structure, data modeling, security and sync/offline design: [Ar - **Backend (Hono + OpenAPI)**: - `backend/src/server.ts`: base app, global middleware, error handler (`appErrorHandler`). - - Routes: `backend/src/modules//-routes.ts` using `createXRoute`. + - Routes: `backend/src/modules//-routes.ts` using `createXRoutes`. - Handlers: `backend/src/modules//-handlers.ts` using `.openapi()` on `OpenAPIHono`. - **Frontend (TanStack Router, file-based)**: - Route files in `frontend/src/routes/`. The router vite plugin registers them into `routeTree.gen.ts` (committed, never hand-edited). @@ -31,16 +31,15 @@ Global chain in `backend/src/middlewares/app.ts`: log context → referrer overr Route-level guards in `backend/src/middlewares/guard/`: -- `userGuard`: validates the session and sets `ctx.var.user`, `ctx.var.memberships`, `ctx.var.actor`, `ctx.var.db` (baseDb). +- `userGuard`: validates the session and sets `ctx.var.user`, `ctx.var.memberships`, `ctx.var.actor`, `ctx.var.db` (baseDb). A route without `tenantGuard` reads across tenants: its handlers use `tenantRead()` for product entity queries. - `serviceGuard`: a secret API key (`Authorization: Bearer _sk_…` or `x-api-key`) or an access token from the app's authorization server; sets `ctx.var.actor` (a service account, or the consenting user masked by the token scopes). Never system admin. - `actorGuard`: a session, an API key or an access token, for routes whose operation takes `ActorContext`. `tokenGuard`: access tokens only (the MCP face), answering 401 with the RFC 9728 challenge. - Contexts, narrowest first: `DbContext` (a connection), `ActorContext` (actor + tenant, no user row), `OrgContext` (plus the organization), `UserContext` (a signed-in user, session fields only behind `userGuard`). Type an operation on the narrowest it needs. Every guard declares the OpenAPI `security` it accepts; `createXRoute` emits it per operation. - `tenantGuard`: verifies tenant membership, loads the tenant row, and sets `ctx.var.db = baseDb` and `ctx.var.tenantId`. - `orgGuard`: resolves the organization and verifies membership. - `publicGuard`: unauthenticated routes. Sets `ctx.var.db` to baseDb. -- `crossTenantGuard`: authenticated cross-tenant routes. Sets `ctx.var.db = baseDb`. Handlers use `tenantRead()` for product entity queries. - `stepUpGuard`: after `userGuard` on account-security routes: the session must have proven its user's presence again recently, never an impersonation; else 403 `step_up_required` naming the methods. The routes, the proofs and the window: [Authentication](./AUTHENTICATION.md#step-up). -- Also: `sysAdminGuard`, `relatableGuard`, `noImpersonationGuard` (after `userGuard`: the browser's own session, never an impersonation; 403 `impersonation_forbidden`). +- Also: `sysAdminGuard` (never an impersonation: 403 `impersonation_forbidden` before the role check), `relatableGuard`. ### Database access patterns @@ -52,7 +51,7 @@ Secret columns (a hash, a session or token secret, a private key) are declared o ## Error handling -`AppError` is the structured error class: `status`, `type` (i18n key from `locales/en/error`), `severity`, `entityType`, `meta`, `willRedirect`. PostgreSQL error codes map automatically (FK violation → 400, unique constraint → 409, RLS denial → 403, deadlock → 409). A route that answers 302 is a browser navigation: outside tests, whatever refuses it (a gate, a limiter, the handler) answers with a redirect to `/auth/error`, the `ctx.var.errorPagePath` a handler may point elsewhere; `willRedirect` forces that redirect in every mode. +`AppError` is the structured error class: `status`, `type` (i18n key from `locales/en/error`), `severity`, `entityType`, `meta`, `willRedirect`. PostgreSQL error codes map automatically (FK violation → 400, unique constraint → 409, RLS denial → 403, deadlock → 409). A route that answers 302 is a browser navigation: outside tests, whatever refuses it (the config switch, a guard, a limiter, the handler) answers with a redirect to `/auth/error`, the `ctx.var.errorPagePath` a handler may point elsewhere; `willRedirect` forces that redirect in every mode. ## Auth @@ -92,7 +91,7 @@ Every check takes an `Access` from `accessFrom(ctx)`. Never assemble one by hand **Extension system** in `backend/src/core/`: - `x-middleware.ts`: wrap guards/limiters/caches with `xMiddleware(options, fn)` so they appear in the spec and docs UI. Use `setMiddlewareExtension` for composed middleware. -- `x-routes.ts`: always `createXRoute`, never `createRoute`. Props: `xGuard` (required), `xRateLimiter`, `xCache`, `x-service` (404 while that service is disabled), `x-tool` (opts the route in as an MCP tool: `{ enabled, description, approvalRequired, category, entity, execute }`; input derives from `request`, `execute` calls the operation). Per-operation `security` follows the guard's declaration (`cookieAuth`, `apiKey`, `oauth2`). +- `x-routes.ts`: a module's routes are `createXRoutes(tags, { key: xRoute({ ... }) })`, never `createRoute`. Each route's `operationId` is its key (set it only when the SDK name differs), its tags are the module's, and the error responses (`errorResponseRefs`) are appended to every route. `json(description, schema, example?)` builds a JSON response, `jsonBody(schema)` a required JSON body. `createXRoute` finishes a single route the same way. Props, in this order, which is the order a request meets them: `method`, `path`, `xEnabledBy` (the config switch the route belongs to: `{ service }` 404s while that service is off, `{ strategy }` refuses an auth route while its sign-in method is off, `{ strategy: 'oauth', provider }` while that provider is; it runs before the guards, so it is for deployment switches that need no caller), `xGuard` (required: who may call), `xRateLimiter`, `xCache`, `xTool` (opts the route in as an MCP tool: `{ description, approvalRequired, entity }`; input derives from `request`, a call runs the route through the app), then `middleware` (after the guards: checks that need the caller, such as a plan or tenant flag), `summary`, `description`, `request`, `responses`. The spec shows the `x*` props as `x-*` extensions. The docs list a route whose switch is off and mark it off by the docs page's own config; to drop routes from the docs (they stay in the spec and SDK), set `hidden: true` on the module or add `'internal'` to a route's `tags`. Per-operation `security` follows the guard's declaration (`cookieAuth`, `apiKey`, `oauth2`). - `openapi-extensions.ts`: new `x-*` extension types go here. - `openapi-registration.ts`: builds the spec and writes `openapi.cache.json`. - Frontend: the openapi-parser plugin (`sdk/src/plugins/openapi-parser/`) writes generated docs, served by Vite at `/static/docs.gen/`. The docs UI is the frontend docs module. @@ -132,17 +131,18 @@ A child-side host FK (nullable `Id` column on one product pointing at anot - **Frontend modules & placements**: every `frontend/src/modules//` folder registers itself in `-module.ts` (`.tsx` when tools render JSX) via `defineFrontendModule` (`~/lib/module`). `frontend/src/modules.ts` glob-imports these before first render. A **tool** is a component placed into a **slot**. The **consumer** is the page hosting the slot. Modules declare `tools`. Consumers read `getTools(slot)` (typed by `SlotContexts`) and resolve with `resolvePlacementList`. Slot families: `` `${channelType}.settings` ``, `` `${channelType}.tabs` ``, `account.settings`, `home.sections`, `user.profile` (profile page body) and the non-entity `system.tabs`. A tool's `render` returns the slot's full content unit (lazy-load heavy UI). A channel tool's entity context is the `ChannelEntityByType` interface (apps widen it via module augmentation). Gating: `requires` names a grant. `visibleTo` lists context-role pairs like `'organization.admin'` (matched over the ancestor chain via `heldContextRoles(entity, memberships)`, a UI boundary only, never data authorization). Arrangement layers, in order: manifest defaults, app overrides in `frontend/src/placement-config.ts` (pinned), then the channel row's `toolsConfig` jsonb (per-slot `order`/`hidden`/`settings`, reconciled fail-closed: unknown ids drop, new tools append at default order, and `locked` tools ignore channel hiding). Page tabs: `resolveNavTabs` merges child routes declaring `staticData.navTab` (a `PlacementDescriptor`) with the `.tabs` tools of the slot named in the layout route's `staticData.tabsSlot` into one gated, ordered bar. Entity links target the layout route tab-less. Its `beforeLoad` calls `guardNavTabs` (redirects to `defaultTabId`, else the first visible tab, and forwards navigations aimed at a disabled tab). A settings slot costs its forms, a per-module `settings-tools.tsx` built from the `*ToolBase` helpers in `modules/entities/channel-settings-tools.tsx` (`dangerToolBase` plus `DeleteToolCard` is the danger zone), and a `` route. A tabs slot costs a one-line `$tool` route (`SlotTabHost`) plus its `.tabs` tools. Shells: `ToolCard` (`modules/common`), `TabsArrangementCard` (`modules/entities`). - **Entity id columns**: the hierarchy is the ONE source of truth for id-column names (`organization` → `organizationId`). Never hand-write `` `${type}Id` `` or hardcode a sub-organization key like `'projectId'`. Prefer, in order: `EntityIdColumns` (shared) for an entity-type → id-column map _type_, then `EntityIdColumnKey` for one key type, then `appConfig.entityIdColumnKeys[type]` or `entityIdColumnKey(type)` / `entityIdColumnName(type)` at runtime. The organization is the fixed spine, not a configurable root: write `'organization'` and `organizationId` directly (there is no `rootChannelType`), and declare it with `organization()` in the hierarchy builder. Row-location logic and entity-kind guards (`isChannel`, `isProduct`, `getRoles`, `hierarchy.resolveDeepestAncestorId`, `hierarchy.computeProductPath`, `hierarchy.pathColumnSql`, ...) are bound arrow methods on `EntityHierarchy` (destructuring keeps `this`), no free-function twin. `shared` re-exports the singleton's `isChannel`/`isProduct` as aliases, so a `vi.mock('shared')` factory replacing `hierarchy` must also override `isChannel: h.isChannel, isProduct: h.isProduct`. Injectable-hierarchy parameters are typed `EntityHierarchy`, defaulting to the app singleton (`options.hierarchy` on permission checks). - **Debug mode**: `VITE_DEBUG_MODE=true` in `frontend/.env`. -- **Icons**: import from `lucide-react` with `*Icon`-suffixed names (`LoaderCircleIcon`, not `Loader2`/`Loader2Icon`, Biome-enforced). Size with classes only: `icon-xs/sm/md/lg/xl` (12-24px) or `size-*`. NEVER lucide's `size` prop (a global `:where(svg.lucide)` rule overrides its px attributes). Never combine two `icon-*`/`size-*` classes on one element (tailwind-merge does not dedupe them). strokeWidth defaults via `LucideProvider` in main.tsx (`appConfig.theme.strokeWidth`). Per-icon `strokeWidth` overrides. Custom SVG icons in `frontend/src/modules/common/icons/` carry the `lucide` class. Icon-as-prop declarations use `IconComponent` from `~/modules/common/icons/types` (omits `size`). -- **Migrations**: every sync-breaking change ships a `cella/migrations/` folder plus manifest entry in the same PR: `cella/migrations/README.md`. +- **Icons**: import from `lucide-react` with `*Icon`-suffixed names (`LoaderCircleIcon`, not `Loader2`/`Loader2Icon`, Biome-enforced). Size with `size-*` classes only (`size-3` to `size-6`, 12-24px; unsized icons default to 1rem). NEVER lucide's `size` prop (a global `:where(svg.lucide)` rule overrides its px attributes). strokeWidth defaults via `LucideProvider` in main.tsx (`appConfig.theme.strokeWidth`). Per-icon `strokeWidth` overrides. Custom SVG icons in `frontend/src/modules/common/icons/` carry the `lucide` class. Icon-as-prop declarations use `IconComponent` from `~/modules/common/icons/types` (omits `size`). +- **Migrations**: every sync-breaking change ships a `cella/migrations//` folder in the same PR, its README opening with frontmatter: `cella/migrations/README.md`. The folder stays upstream; apps read and record the notes with `pnpm cella migrate`. - **Syncing (apps)**: the `cella-sync` skill (`cella/skills/cella-sync/SKILL.md`) drives `pnpm cella sync` / `pnpm cella analyze`: conflict triage, silent-damage sweep, migration bookkeeping, drift triage. - **Skills**: `cella/skills/` is the single home for agent skills (synced to apps). Claude Code only discovers `.claude/skills` (gitignored): `ln -s ../cella/skills .claude/skills`. - **OpenAPI nullable**: `z.union([schema, z.null()])`, never `schema.nullable()`, for named schemas. - **OpenAPI schema naming**: register named components (`.openapi('Name')`) only for whole entity responses or crucial shared base types. Inline enums and request body schemas. Share one schema when the shape is identical across contexts. +- **OpenAPI named schema shape**: define it in the module's `*-schema.ts` and pass `{ description, example: mockXResponse(), 'x-tags': schemaTags(kind, module, 'cella') }`, kind first (`data`, `base` or `errors`). A variant of a named schema stays unnamed and overrides the parent's `description` and `example` with `.openapi({ ... })` when they no longer fit. ## Style & naming - Biome (`biome.jsonc`). Run `pnpm lint:fix`. -- Indentation 2 spaces, line width 120, single quotes, Biome defaults for the rest. +- Indentation 2 spaces, line width 150, single quotes, Biome defaults for the rest. - Zod v4 only: `import { z } from 'zod'`. Backend: `import { z } from '@hono/zod-openapi'`. - camelCase variables/functions (constants included), PascalCase components, kebab-case files, snake_case translation keys. - JSDoc: backend exports get full JSDoc with params/response. Frontend exports get one line, and none when identifier and types already carry the meaning (`useAttachmentDeleteMutation` earns one: it also cancels paused offline creates). No file-level comments above imports. A comment longer than three prose lines must document a declaration or local executable block. Cross-file architecture, workflows and failure-mode narratives go to the nearest canonical README. @@ -150,9 +150,9 @@ A child-side host FK (nullable `Id` column on one product pointing at anot - **Members**: one line when name and type underdetermine the contract (default, constraint, unit or encoding, null/empty condition, population source), and always for `unknown`, `any` or a bare `string`/`number`/`boolean`. Drop it when a named type carries the meaning (`items: FloatingNavItem[]`) or default and behavior are visible in the same file. - **Locals and JSX**: one line of rationale for a local (two lines means rename or extract). JSX keeps the constraint only: `{/* min-h-14 matches the bar row so the grid holds position */}`. Measurement and motivation go in the commit. One comment above a repetitive block covers its shared constraint. - **No repeats**: the same comment text never appears in two files. Put it once at the shared abstraction or delete every copy. -- **Never use em dashes (`—`, U+2014) anywhere in text** (code, YAML, config, docs). Split the sentence, use a colon, or drop the clause. `shared/scripts/check-comment-style.ts` (in `pnpm check`) fails the build on em dashes in code and YAML comments, `shared/scripts/check-doc-style.ts` (`pnpm docs:style`) on em dashes in Markdown and MDX prose. Contrast and history phrases (`instead`, `rather than`, `previously`, `used to`, `maybe`, `we should`) are review signals: rewrite around the current behavior, delete the rest. -- **Agent-associated vocabulary**: name the concrete behavior. Replace `load-bearing` with the dependency, requirement or failure consequence it abbreviates. `seam`, `land`, `surface` as a verb, `wiring`, `scaffold`, `floor`, `decisive`, `genuinely`, `cleanly`, `honest take` and `silently` are review signals. Prefer the exact term (boundary, merge, report, registration, minimum, the missing error). Keep exact domain terms (`canonical`, `idempotent`, `parity`, `guard`, `stale`, `round-trip`, `fallback`, `authoritative`, `verdict`). Never rename identifiers, files, APIs or domain concepts for prose style. `pnpm prose:audit` reports review terms. Required replacements fail `pnpm docs:style` (generated output, migrations, changelog, `infra/` excluded). -- **Template/app vocabulary**: `template` for Cella. `app`, `app-owned` or `app-specific` for projects built from it. `sync-breaking` for an upstream change that requires app work after a sync. The Cella CLI keeps its source-control term in `cella/cella.config.ts`, and the `// fork: ` markers the cella-sync skill requires on app edits are skipped by `pnpm vocabulary:check`. Compatibility migrations may name legacy identifiers they replace. `cella` in code names the template only where it contrasts with the app ("none in cella; apps with other vocabularies add theirs"), never the running system ("the app's authorization server", not "cella's"). The product name is never an identifier, claim, header, DNS record or URL literal in `backend/src`, `shared/src` or `frontend/src`: derive it from `appConfig.slug` / `appConfig.name` or pick a neutral name. `pnpm vocabulary:check` rejects `cella_*`, `Cella*`, `_cella-*` and `cellajs.com` there (tests, config, docs and marketing excluded). +- **Never use em dashes (`—`, U+2014) anywhere in text** (code, YAML, config, docs). Split the sentence, use a colon, or drop the clause. `pnpm style` (in `pnpm check`, `pnpm lint` and CI) fails on em dashes in code, YAML and config comments and in Markdown and MDX prose. In comments it also fails contrast and history phrases (`instead`, `rather than`, `previously`, `used to`, `maybe`, `we should`): rewrite around the current behavior, delete the rest. +- **Agent-associated vocabulary**: name the concrete behavior. Replace `load-bearing` with the dependency, requirement or failure consequence it abbreviates. `seam`, `land`, `surface` as a verb, `wiring`, `scaffold`, `floor`, `decisive`, `genuinely`, `cleanly`, `honest take` and `silently` are review signals. Prefer the exact term (boundary, merge, report, registration, minimum, the missing error). Keep exact domain terms (`canonical`, `idempotent`, `parity`, `guard`, `stale`, `round-trip`, `fallback`, `authoritative`, `verdict`). Never rename identifiers, files, APIs or domain concepts for prose style. `pnpm style:audit` lists review terms. Required replacements fail `pnpm style` (generated output, migrations, changelog, `infra/` excluded). +- **Template/app vocabulary**: `template` for Cella. `app`, `app-owned` or `app-specific` for projects built from it. `sync-breaking` for an upstream change that requires app work after a sync. The Cella CLI keeps its source-control term in `cella/cella.config.ts`, and the `// fork: ` markers the cella-sync skill requires on app edits are skipped by `pnpm style`. Compatibility migrations may name legacy identifiers they replace. `cella` in code names the template only where it contrasts with the app ("none in cella; apps with other vocabularies add theirs"), never the running system ("the app's authorization server", not "cella's"). The product name is never an identifier, claim, header, DNS record or URL literal in `backend/src`, `shared/src` or `frontend/src`: derive it from `appConfig.slug` / `appConfig.name` or pick a neutral name. `pnpm style` rejects `cella_*`, `Cella*`, `_cella-*` and `cellajs.com` there (tests, config, docs and marketing excluded). - `materialize`/`materialization` only for the Yjs operation that converts collaborative state into durable entity data. Elsewhere use `persist`, `provision`, `create` or `resolve`. - **Prefer plain composable functions over configuration factories.** `createX(config)` returning behavior is justified only to bind long-lived shared state for many call sites (e.g. mutation options bound to a QueryClient). Otherwise write a small function with explicit arguments. - **Reserved domain vocabulary.** These words name a subsystem. Never reuse them: @@ -165,7 +165,7 @@ A child-side host FK (nullable `Id` column on one product pointing at anot Name modules for their domain role, not the primitive underneath (`tab-coordinator`, not `leader-lease`). When splitting a module, name the remainder deliberately, never payload plus generic verb. - **Docs headings**: `##` headings in `frontend/src/content/docs/**` and in any `.md` those pages import (`cella/*.md`, `bench/README.md`, `cdc/README.md`, `yjs/README.md`) max out at 25 rendered characters (the sidebar truncates longer ones). Measure rendered text, not markup. Only `##` is affected. `cella/CHANGELOG.md` is exempt. - Storybook: stories in `stories/` inside the module, named `.stories.tsx`. -- UI primitives: Base UI (`@base-ui/react`), **not** Radix. Shadcn-style components in `frontend/src/modules/ui/` wrap Base UI. +- UI primitives: Base UI (`@base-ui/react`), **not** Radix. Shadcn-style components in `frontend/src/modules/ui/` wrap Base UI. When porting from the shadcn registry, start from the base-vega style (closest to cella's sizing). Its `data-horizontal:`/`data-vertical:` variants, `no-scrollbar` and `var(--radius-md)` work as-is; drop the `cn-*` hook classes (shadcn style CSS, not shipped here) and check every state selector against the attributes Base UI emits. - Keep existing comment content intact unless cleanup is explicitly requested. Trimming to the comment budget is always in scope (an over-budget comment is a defect). - Console: `console.log` for temp debugging (remove before commit), `console.info` for logging, `console.debug` for dev (stripped in prod). - Links as buttons: `` with `buttonVariants()` for linkable actions. Allow new-tab opening for URL-targetable sheet content. diff --git a/cella/AUTHENTICATION.md b/cella/AUTHENTICATION.md index 6a1d6c43c..b4c8a674b 100644 --- a/cella/AUTHENTICATION.md +++ b/cella/AUTHENTICATION.md @@ -18,7 +18,7 @@ actions that change how an account is protected first ask the session to prove i | Provider sign-in | An account at GitHub, Google or Microsoft, stored in `identities` by issuer and subject | `auth/oauth/` | | TOTP | A code from an authenticator app; a second factor only, never a first | `auth/totps/` | -`appConfig.enabledAuthStrategies` says which methods are on. Every auth route declares its method with `x-strategy`, and a route of a method that is off answers 400 `forbidden_strategy` before any guard runs. The sign-in page starts by posting the address to `check-email`, which answers `recognized: true` only to a browser that has signed in to that account before (the signed `device-id` cookie plus a `devices` row); every other browser gets the neutral sign-in step, whether or not the address has an account. +`appConfig.enabledAuthStrategies` says which methods are on. Every auth route names its method as its config switch, `xEnabledBy: { strategy: }` (an OAuth provider's routes add `provider`), so a route of a method that is off answers 400 `forbidden_strategy` (`unsupported_oauth` for a provider) before its guards run; deleting a passkey or TOTP names no switch and stays reachable. The token link route serves every link type, so it checks the magic switch in the magic link's handler. The sign-in page starts by posting the address to `check-email`, which answers `recognized: true` only to a browser that has signed in to that account before (the signed `device-id` cookie plus a `devices` row); every other browser gets the neutral sign-in step, whether or not the address has an account. A magic-link or provider sign-in ends in `finishSignIn`: a session, or first an MFA challenge when the account requires one. A passkey sign-in sets the session at once. A system administrator signs in, and counts as one, only from an address in `SYSTEM_ADMIN_IP_ALLOWLIST`, which defaults to `none`. @@ -30,9 +30,9 @@ An account is identified by the proofs it holds, never by an address; the identi ## Sessions -The session cookie carries a random 40-character token; `sessions.secret` stores its SHA-256 hash, so reading the table yields no session. `resolveSession(ctx)` reads the session a request presents from its cookies alone, so any process on the app origin can call it. `readSession(token)` turns a token into its row, from a one-minute cache or the database. `findSession(ctx)` serves requests that may carry no session: a refusal reads as null, while a failed read stays the request's failure, so the database being away never reads as signed out. A session lives a week. A browser holds one live session per account, and an account at most `maxSessionsPerUser` (10) besides impersonations. +The session cookie carries a random 40-character token; `sessions.secret` stores its SHA-256 hash, so reading the table yields no session. `resolveSession(ctx)` reads the session a request presents from its cookies alone, so any process on the app origin can call it. `readSession(token)` turns a token into its row, from a 10-second cache or the database. The process that revokes a session drops it at once, the API process drops a user's entries when CDC reports a change to the user, a membership or the system role, and any other process stops serving a revoked session within the 10 seconds. `findSession(ctx)` serves requests that may carry no session: a refusal reads as null, while a failed read stays the request's failure, so the database being away never reads as signed out. A session lives a week. A browser holds one live session per account, and an account at most `maxSessionsPerUser` (10) besides impersonations. -Every ending before expiry goes through `endSessions`. It stamps the rows with `revokedAt`, `revokedBy` and a `revocationReason`, tells every process to drop its cached sessions (`auth_invalidate`), and closes the streams bound to them; the row stays for the sessions list. +Every revocation before expiry goes through `revokeSessions`. It stamps the rows with `revokedAt`, `revokedBy` and a `revocationReason`, drops the user's cached sessions and closes the streams bound to them; the row stays for the sessions list. | Reason | When | | --- | --- | @@ -48,7 +48,7 @@ A client learns of a lost session from four 401 types, `unauthorized`, `no_sessi **Devices.** A sign-in sets a 400-day `device-id` cookie and records its per-user hash in `devices`. `PII_HASH_SECRET` peppers the hash so a database leak cannot correlate browsers across accounts. A sign-in from a browser the account has not used before mails the owner, and `check-email` recognizes a browser by it. -**Impersonation.** A system admin's impersonation is a session of its own (`type: 'impersonation'`, one hour) in its own cookie, layered on the admin's session cookie: it authenticates only while that admin session lives, the admin holds the system role and the request comes from an allowed address. The admin acts as the user, never on the account: stepping up, revoking the user's sessions and impersonating again are refused with 403 `impersonation_forbidden` (`noImpersonationGuard`). +**Impersonation.** A system admin's impersonation is a session of its own (`type: 'impersonation'`, one hour) in its own cookie, layered on the admin's session cookie: it authenticates only while that admin session lives, the admin holds the system role and the request comes from an allowed address. The admin acts as the user, never on the account: stepping up and revoking the user's sessions are refused with 403 `impersonation_forbidden` by their handlers, and every system route, impersonating again included, by `sysAdminGuard`. ## Cookies diff --git a/cella/CHANGELOG.md b/cella/CHANGELOG.md index c50970e8e..5cefbee2a 100644 --- a/cella/CHANGELOG.md +++ b/cella/CHANGELOG.md @@ -1,5 +1,86 @@ # Changelog +## [0.13.0](https://github.com/cellajs/cella/compare/0.12.2...0.13.0) (2026-10-02) + + +### ⚠ BREAKING CHANGES + +* **auth:** second factors move to queries and operations ([#1253](https://github.com/cellajs/cella/issues/1253)) +* **backend:** database helpers move into query files ([#1251](https://github.com/cellajs/cella/issues/1251)) + +### 🎉 New features + +* **ui:** toasts render through base ui toast and sonner is removed ([#1215](https://github.com/cellajs/cella/issues/1215)) ([440d2ad](https://github.com/cellajs/cella/commit/440d2addc48657ccd605bd05fcaf3a00c40b8828)) + + +### 🐞 Bug fixes + +* a taken slug no longer reports the organization limit, plus dense-line cleanups ([#1228](https://github.com/cellajs/cella/issues/1228)) ([cd1c98c](https://github.com/cellajs/cella/commit/cd1c98c6c18ad70795b6e58bce8e27a37f556958)) +* **auth:** rate limits count an IP or address under a pseudonym ([#1209](https://github.com/cellajs/cella/issues/1209)) ([b3eacc3](https://github.com/cellajs/cella/commit/b3eacc3ee079ecff7c7e2adc5c8e89ba6c30baaa)) +* **auth:** refusals on browser navigations redirect to the error page ([#1207](https://github.com/cellajs/cella/issues/1207)) ([0b962ee](https://github.com/cellajs/cella/commit/0b962eeeb5a91d31c31c6bbe28adda59cdf949ca)) +* **bench:** clean up seeds through the primary key and keep the cdc slot ([#1212](https://github.com/cellajs/cella/issues/1212)) ([3ef0c71](https://github.com/cellajs/cella/commit/3ef0c71fc94d454ec35ea67f812508434ae6c422)) +* **blocknote:** checklist shortcuts create the app's checklist block ([#1227](https://github.com/cellajs/cella/issues/1227)) ([8cd9986](https://github.com/cellajs/cella/commit/8cd9986b4a7b359821e01cc09ffbfc3889f796bc)) +* **frontend:** api keys card no longer hijacks its settings section scroll ([#1257](https://github.com/cellajs/cella/issues/1257)) ([3022b75](https://github.com/cellajs/cella/commit/3022b75c23fd802e40c6cc7859b631be64472481)) +* **frontend:** css fixes ([#1248](https://github.com/cellajs/cella/issues/1248)) ([6e224e0](https://github.com/cellajs/cella/commit/6e224e0b801a7d07a3dd1c88bf734960ce81d5c6)) +* one write per mention edit, comment emails, filter-aware list refetch and test db lock ([#1225](https://github.com/cellajs/cella/issues/1225)) ([59d163e](https://github.com/cellajs/cella/commit/59d163e33531075514e5f82cae14c5a7f3c44210)) +* scaffold config template, tunnel script and sheet close order ([#1211](https://github.com/cellajs/cella/issues/1211)) ([f3f9b58](https://github.com/cellajs/cella/commit/f3f9b5829e46bc93d02063a9a2a253052a228150)) +* **sync:** act on projectcampus's 2026-10-01 sync feedback ([#1252](https://github.com/cellajs/cella/issues/1252)) ([ced4c7d](https://github.com/cellajs/cella/commit/ced4c7d798289bd79c4d60f8b51dac702842d6dc)) +* **sync:** act on raak's 2026-09-30 sync feedback ([#1210](https://github.com/cellajs/cella/issues/1210)) ([48c9440](https://github.com/cellajs/cella/commit/48c94407ed190b405cb3b6019ecafafd9a329b59)) +* **test:** restore coverage in the root vitest run ([#1216](https://github.com/cellajs/cella/issues/1216)) ([205bab6](https://github.com/cellajs/cella/commit/205bab62819bdcddd7f45595e3ace231d2faa821)) +* tier 2 follow-ups and app sync feedback (tenants table, table export, notifications, dev:single mcp) ([#1224](https://github.com/cellajs/cella/issues/1224)) ([622e0ad](https://github.com/cellajs/cella/commit/622e0ade5762a888b3d13d835f2bacb6cf8567fc)) +* **ui:** alignment follow-ups for kit boundary, atoms, overlay exits, row confirm and hover card ([#1239](https://github.com/cellajs/cella/issues/1239)) ([dd10bb3](https://github.com/cellajs/cella/commit/dd10bb3fdaa31196b1c6f29d3f8337a7d201cecd)) +* **ui:** base ui wiring bugs found in the shadcn alignment audit ([#1230](https://github.com/cellajs/cella/issues/1230)) ([dac8ba9](https://github.com/cellajs/cella/commit/dac8ba917c9426f505c4fcf43808950d502c3376)) + + +### 🔧 Small improvements + +* **auth:** guard caches without broadcast, memberships versioned ([#1242](https://github.com/cellajs/cella/issues/1242)) ([4402741](https://github.com/cellajs/cella/commit/4402741a7b509d95276de7e6e333209d848f6da0)) +* **auth:** parse the device from the User-Agent without ua-parser-js ([#1255](https://github.com/cellajs/cella/issues/1255)) ([3eaa9b5](https://github.com/cellajs/cella/commit/3eaa9b54b8e68c13aa71e76dcb94c4f70146df75)) +* **auth:** second factors move to queries and operations ([#1253](https://github.com/cellajs/cella/issues/1253)) ([7b90881](https://github.com/cellajs/cella/commit/7b90881aa635bdd5177b94b8df56e806b5f1650a)) +* **backend:** database helpers move into query files ([#1251](https://github.com/cellajs/cella/issues/1251)) ([adc4c11](https://github.com/cellajs/cella/commit/adc4c110cf4035762ec89004e2978506a4ca52f3)) +* **blocknote:** title documents as a template, not enforced ([#1222](https://github.com/cellajs/cella/issues/1222)) ([991458e](https://github.com/cellajs/cella/commit/991458e47060406a8c8fa7074549017263917182)) +* **docs:** smoother mobile docs sidebar, scrolling and navigation ([#1241](https://github.com/cellajs/cella/issues/1241)) ([4bd8a42](https://github.com/cellajs/cella/commit/4bd8a42acfe5fec6396997d0a3b73d851cb312ce)) +* **emails:** one layout and one message body for every template ([#1223](https://github.com/cellajs/cella/issues/1223)) ([eba8255](https://github.com/cellajs/cella/commit/eba8255825244bae8b9ac752ca0f945ad59dd9f5)) +* **errors:** one request id in error bodies, headers and logs ([#1243](https://github.com/cellajs/cella/issues/1243)) ([9e71aa6](https://github.com/cellajs/cella/commit/9e71aa6ebc2092d4967a29596104c6da48d274b0)) +* **frontend:** enable the React Compiler ([#1247](https://github.com/cellajs/cella/issues/1247)) ([f2c2a75](https://github.com/cellajs/cella/commit/f2c2a75613a5bc52941a25b59215ed46caaa2568)) +* **frontend:** narrower subscriptions, data-grid hot paths and compositor-only animations ([#1246](https://github.com/cellajs/cella/issues/1246)) ([2fb4512](https://github.com/cellajs/cella/commit/2fb4512b5b6645a6cb217997a92825e70e6a92a4)) +* **guards:** drop crossTenantGuard and noImpersonationGuard ([#1250](https://github.com/cellajs/cella/issues/1250)) ([64b00aa](https://github.com/cellajs/cella/commit/64b00aa189e04e514ba1702e44170dcba12bad55)) +* **routes:** config switches leave xGuard as xEnabledBy ([#1256](https://github.com/cellajs/cella/issues/1256)) ([69d1350](https://github.com/cellajs/cella/commit/69d13501e539e214071933e50c5a122df5dd2f15)) +* **routes:** gates in xGuard, xTool runs the route handler, revokeSessions ([#1226](https://github.com/cellajs/cella/issues/1226)) ([5c0cb0a](https://github.com/cellajs/cella/commit/5c0cb0aa7996e98f8943ba5e11e3806e6583e957)) +* **sdk:** one schema resolver, docs types owned by the sdk, config factory ([#1217](https://github.com/cellajs/cella/issues/1217)) ([db6dfdb](https://github.com/cellajs/cella/commit/db6dfdb9150a657110e1f209c3eb241e2be13bea)) +* sheet exits, status panel, oauth sign-up mail and dependency bumps ([#1240](https://github.com/cellajs/cella/issues/1240)) ([34f8654](https://github.com/cellajs/cella/commit/34f8654b9123294788c9e76a8e30eb731470da37)) +* **style:** one style command over one finding shape ([#1220](https://github.com/cellajs/cella/issues/1220)) ([a81e335](https://github.com/cellajs/cella/commit/a81e3353ba1051ef25cc9fc8aad59882a26e239d)) +* **tenants:** fold the held organization into Tenant ([#1244](https://github.com/cellajs/cella/issues/1244)) ([588b5e1](https://github.com/cellajs/cella/commit/588b5e1656aa55348aaf91248839cfe4ee166011)) +* **test:** run backend tests in parallel on per-worker databases ([#1237](https://github.com/cellajs/cella/issues/1237)) ([d695224](https://github.com/cellajs/cella/commit/d6952245c5558009fb18894bcdb946ffd560f710)) +* tier 2 loc reduction with route helpers, shared table pieces and one prose engine ([#1218](https://github.com/cellajs/cella/issues/1218)) ([fc4c737](https://github.com/cellajs/cella/commit/fc4c7379081c4f835a539ea1b3b4fa0f52d6c009)) +* **ui:** build render props on base ui useRender and remove Slot ([#1233](https://github.com/cellajs/cella/issues/1233)) ([619f79b](https://github.com/cellajs/cella/commit/619f79b1261cb2f0b2e557067120c8a747112d81)) + + +### 📖 Documentation + +* **openapi:** one shape for every named schema ([#1249](https://github.com/cellajs/cella/issues/1249)) ([97a307c](https://github.com/cellajs/cella/commit/97a307c23956cd0037da5c9ae294b690845e73cc)) + + +### 🏗️ Build & deps + +* run storybook tests on the release pr only ([#1221](https://github.com/cellajs/cella/issues/1221)) ([f9bef0f](https://github.com/cellajs/cella/commit/f9bef0f343449f8b635f7b23d08e761b843fdfcf)) + + +### 🧹 Chores + +* **deps:** service dependencies are what the bundle loads from disk ([#1254](https://github.com/cellajs/cella/issues/1254)) ([fc2a4ac](https://github.com/cellajs/cella/commit/fc2a4acaf0b794781e7b71bc2fa82d15d6a0707a)) +* general improvements ([#1245](https://github.com/cellajs/cella/issues/1245)) ([d5f44e7](https://github.com/cellajs/cella/commit/d5f44e77880ae6634544ea3478d09d2d03c27200)) +* remove dead backend code (activities list API, cache metrics, sync meters) ([#1214](https://github.com/cellajs/cella/issues/1214)) ([5ee8c73](https://github.com/cellajs/cella/commit/5ee8c7362b5fa865b36130c0b96b78e8b72d4223)) +* remove dead frontend files, unused ui components and dead shared exports ([#1213](https://github.com/cellajs/cella/issues/1213)) ([1059629](https://github.com/cellajs/cella/commit/10596291ffaa9a21e1389f3a8f01552e99eb35c8)) +* remove unused report scripts and the custom node loader ([#1219](https://github.com/cellajs/cella/issues/1219)) ([bfead59](https://github.com/cellajs/cella/commit/bfead59035467c3cca9cfa2298f31c1ffe1fc4bc)) +* **ui:** let upstream shadcn base ui classes port unchanged ([#1232](https://github.com/cellajs/cella/issues/1232)) ([03a9d50](https://github.com/cellajs/cella/commit/03a9d505a100ed9adfbfed2a4b77f0f674130d75)) + + +### 🎨 Styles + +* line width 150 with shallow objects collapsed ([#1229](https://github.com/cellajs/cella/issues/1229)) ([25d1833](https://github.com/cellajs/cella/commit/25d183326355a1de182eb15c775b61833d1f681d)) +* readability fixes for lines the 150 reformat made long ([#1231](https://github.com/cellajs/cella/issues/1231)) ([fe807b2](https://github.com/cellajs/cella/commit/fe807b238b827fa8f7cf7aaf7b453e19afdfda89)) + ## [0.12.2](https://github.com/cellajs/cella/compare/0.12.1...0.12.2) (2026-09-29) diff --git a/cella/CLIENT.md b/cella/CLIENT.md index dfc99be45..c643aeef5 100644 --- a/cella/CLIENT.md +++ b/cella/CLIENT.md @@ -43,7 +43,7 @@ Five state owners. This document unpacks the query client. `localUserDb` is the - **Channel entity lists and details** (`[organization, 'list', ...]`): plain queries, refetched on membership or channel notifications. - **Canonical product lists** (`[attachment, 'list', org, home]`): one flat, complete list per home channel (the deepest channel a row belongs to), patched by live updates. Components narrow it with `select()`. -- **Filtered product lists**: server-side search and sort results under their own keys. They are invalidated, not patched. +- **Filtered product lists**: server-side search and sort results under their own keys. They are invalidated, not patched. A new row skips the lists it cannot belong to when its entity declares equality filter keys (`registerEqualityFilterKeys('comment', ['itemId'])`) and the row's own value differs. - **Session queries**: `me`, memberships, invites, unseen counts. Each entity module registers its query keys and delta fetch once in its `query.ts`, so generic cache and realtime code never import entity modules. Staleness follows the stream ([Freshness](./SYNC_ENGINE.md#freshness)). Sync deliveries are plain cache writes: upserts or invalidations. diff --git a/cella/INTEROPERABILITY.md b/cella/INTEROPERABILITY.md index db7edd1c9..c862f5d85 100644 --- a/cella/INTEROPERABILITY.md +++ b/cella/INTEROPERABILITY.md @@ -4,7 +4,7 @@ This document covers how systems outside the browser act on your app: the faces ### TL;DR -Your app has three machine-facing faces: the REST API, an OAuth authorization server, and an MCP endpoint per organization. All three run on one substrate. A caller is always an actor (a person or a service account), always holds role bindings the permission engine understands, and may carry a mask of access scopes that narrows what those bindings allow. There is no second permission vocabulary for machines. +Your app has three machine-facing faces: the REST API, an OAuth authorization server, and an MCP endpoint per organization. All three run on one substrate. A caller is always an actor (a person or a service account), always holds role bindings the permission engine understands, and may carry access scopes that narrow what those bindings allow. ## Who connects @@ -47,7 +47,7 @@ The scope vocabulary is derived from the policy matrix, never listed by hand: ev ### Tokens -Access tokens are RS256 JWTs the OAuth face signs: `sub` is the actor, `actor_kind` says which kind, `tenant_id` and the audience name one tenant's resource (the REST API of that tenant, or one organization's MCP endpoint), `scope` is the mask. A guard verifies the signature locally against a cached keystore (no token row, no call back to the authorization server) and then loads the actor: a cached read for a user, one row read for a service account. The audience check means a token can never cross tenants. Tokens live an hour; refresh tokens rotate. +Access tokens are RS256 JWTs the OAuth face signs: `sub` is the actor, `actor_kind` says which kind, `tenant_id` and the audience name one tenant's resource (the REST API of that tenant, or one organization's MCP endpoint), `scope` is the mask. A guard verifies the signature locally against a cached keystore (no token row, no call back to the authorization server) and then reads what the token rests on at every request: the grant and the user's bindings version for a user, the key and its account for a service account. The audience check means a token can never cross tenants. Tokens live an hour; refresh tokens rotate. ### Guards diff --git a/cella/OTEL.md b/cella/OTEL.md index 83623e639..edb3b9660 100644 --- a/cella/OTEL.md +++ b/cella/OTEL.md @@ -99,4 +99,4 @@ template to `secretPathTemplates`, a new token query key to `sensitiveQueryKeys` | CDC | `GET /health` | Status, uptime, replication state, WebSocket connection, circuit breakers | | YJS | `GET /health` | Status, uptime, connection/document/client counts | -All default to **shallow** 204 for load balancers and liveness probes. `?depth=full` returns JSON. Backend health is `unhealthy` when the database probe fails or the process's auth invalidation listener is not listening (never started or stopped; `degraded` between connections, when the process may miss a session ending), and `degraded` on lesser component trouble such as event-loop lag. The mcp and oauth processes report the same `authInvalidation` component; the jobs worker, which serves no request, does not. The api process and the jobs worker report a `jobs` component: `degraded`, never `unhealthy`, when no scheduler ran in five minutes, a queue passes its warning size or dead letters wait. CDC is `degraded` when replication is paused or the WebSocket is disconnected, `unhealthy` when replication is stopped or WAL lag passes its limit. +All default to **shallow** 204 for load balancers and liveness probes. `?depth=full` returns JSON. Backend health is `unhealthy` when the database probe fails, and `degraded` on lesser component trouble such as event-loop lag. The api process and the jobs worker report a `jobs` component: `degraded`, never `unhealthy`, when no scheduler ran in five minutes, a queue passes its warning size or dead letters wait. CDC is `degraded` when replication is paused or the WebSocket is disconnected, `unhealthy` when replication is stopped or WAL lag passes its limit. diff --git a/cella/PERMISSIONS.md b/cella/PERMISSIONS.md index 94772333a..f895b863b 100644 --- a/cella/PERMISSIONS.md +++ b/cella/PERMISSIONS.md @@ -95,6 +95,8 @@ export type Access = Backend handlers never assemble an access by hand: `accessFrom(ctx)` reads the guard-populated actor (`id`, `bindings`, `scopes`) and `isSystemAdmin` off the request context and yields `{ anonymous: true }` when nobody is signed in. `scopes` is required so a hand-built access states its mask: a session passes `null`; an API key or an access token passes what it was issued with, and the decision is `allowed AND the scope covers the action`. Where scopes come from: [Interoperability](./INTEROPERABILITY.md#access-scopes). +A user's bindings are their memberships, which each process caches under `actors.bindings_version`. A trigger on `memberships` gives that column a new random value on every insert, update and delete, cascades included. A token request reads the version at every use. A session request takes it from the cached session, which the writing operation drops (`invalidateCache.user`) and the API process drops when CDC reports the membership change, so a change counts within CDC lag there and within the session cache's 10 seconds elsewhere, whatever wrote it. + ## The policy consulted **`shared/config/hierarchy-config.ts`**, a fluent builder: @@ -220,7 +222,7 @@ Unexpected server errors include internal details in client responses only in de | An address or provider account another account holds (`oauth_email_exists`, `oauth_conflict`, `oauth_wrong_email`) | 409, severity `warn` | The caller's state, not a fault of the app | | Not signed in, or a session that ended | 401 `unauthorized`, `no_session`, `session_expired` or `session_revoked` | The frontend redirects to sign-in on these types alone; any other 401 refuses a proof while signed in | | An account-security route without a recent proof of presence | 403 `step_up_required` naming the methods | [Interoperability](./INTEROPERABILITY.md#guards) | -| An action on the account itself while impersonating: stepping up, revoking the user's sessions, impersonating again, and every `stepUpGuard` route | 403 `impersonation_forbidden` (`noImpersonationGuard`; `stepUpGuard` gives the same answer before its own) | The admin acts as the user, never on the account, its sessions or how it is protected | +| An action on the account itself while impersonating: stepping up, revoking the user's sessions, every `stepUpGuard` route and every system route, impersonating again included | 403 `impersonation_forbidden` (`refuseImpersonation`: in the step-up and session handlers, and in `stepUpGuard` and `sysAdminGuard` before their own answers) | The admin acts as the user, never on the account, its sessions or how it is protected | ## Behavior diff --git a/cella/SYNC_ENGINE.md b/cella/SYNC_ENGINE.md index b1e9fd0c4..fd07f1c12 100644 --- a/cella/SYNC_ENGINE.md +++ b/cella/SYNC_ENGINE.md @@ -179,6 +179,8 @@ Value shape selects merge behavior: `fieldTimestamps` must name exactly the scalar operation keys. For a replay, the server omits scalar values that lose HLC comparison and returns the authoritative row, never a conflict response. Merge resolution takes no `FOR UPDATE` lock, so overlapping updates can race. +Columns the server derives from a write stay outside the merge: the attachment `keywords`, re-derived from the description in `update-attachment.ts`, and an app's own audit stamps. They are written in the same transaction as the resolved values but never enter `stx.fieldTimestamps` or `stx.changedFields`, the columns the CDC worker reports as changed: they are not operations and carry no HLC. Only `updatedAt` joins `changedFields`, which is how the CDC worker tells a user edit from its own writes. + ### Paused writes Paused mutations persist to IndexedDB and survive a reload, so mutation variables must carry all routing data. Hook closures no longer exist at replay. The attachment module is the reference: mutation functions are registered as replay defaults, and `stx` is minted at intent time and stored in the variables so a replay reuses the mutation ID and field timestamps. diff --git a/cella/TESTING.md b/cella/TESTING.md index 3ba26623d..c1777baf0 100644 --- a/cella/TESTING.md +++ b/cella/TESTING.md @@ -76,7 +76,7 @@ A branch that adds migrations runs its suite against a throwaway database (`DB_T Coverage excludes `*.test.ts`, `tests/**` and mocks, so placement does not change coverage numbers. -**Backend specifics.** Test env vars (secrets, `DATABASE_URL`, `NODE_ENV=test`) are preset in [backend/vitest.config.ts](../backend/vitest.config.ts). Do not load `.env` in tests. Backend tests run serially (`fileParallelism: false`) against a shared test database prepared by [backend/tests/global-setup.ts](../backend/tests/global-setup.ts). Never assume an empty database. Use the `#/` import alias as in source. +**Backend specifics.** Test env vars (secrets, `DATABASE_URL`, `NODE_ENV=test`) are preset in [backend/vitest.config.ts](../backend/vitest.config.ts). Do not load `.env` in tests. Backend test files run in parallel, each vitest worker on its own database (`backend_worker_` in the test container), so files never see each other's rows. [backend/tests/global-setup.ts](../backend/tests/global-setup.ts) creates these once and migrates them with the shared database the yjs and cdc tests use. Tests that open their own connection take the URL from `shared/test-db`, which points at the worker's database. Never assume an empty database: a worker's database keeps the rows of earlier files and runs. Clean up with `clearDatabase()`: it deletes the rows, since `TRUNCATE` costs ~400ms a call. Server-wide views (`pg_stat_activity`, `pg_locks`) show every worker, so a query on them filters on `datname = current_database()`. Use the `#/` import alias as in source. **New packages.** Register a new workspace package with tests in the root [vitest.config.ts](../vitest.config.ts): add it to `projects` and the `coverage.include` globs. @@ -95,4 +95,4 @@ pnpm sdk pnpm test:storybook ``` -Every story is render-tested in headless Chromium. Stories with `play` functions also get their interactions exercised. A new component story is a test automatically. +Every story is render-tested in headless Chromium. Stories with `play` functions also get their interactions exercised. A new component story is a test automatically. CI runs this project on the release PR only, so run `pnpm test:storybook` before merging a frontend change. diff --git a/cella/cella.config.ts b/cella/cella.config.ts index 184ecb8de..5c85cee5f 100644 --- a/cella/cella.config.ts +++ b/cella/cella.config.ts @@ -56,7 +56,6 @@ export default defineConfig({ 'backend/src/schemas/app-schemas.ts', 'bench/src/seeds/ids.ts', 'frontend/src/placement-config.ts', - 'frontend/src/members-config.ts', 'frontend/src/routes-config.tsx', 'frontend/src/menu-config.tsx', 'frontend/src/alert-config.tsx', diff --git a/cella/cella.manifest.json b/cella/cella.manifest.json index 3ef2eb665..2220421e9 100644 --- a/cella/cella.manifest.json +++ b/cella/cella.manifest.json @@ -2,9 +2,9 @@ "upstream": { "repo": "cellajs/cella", "track": "branch", - "commit": "205bab62819bdcddd7f45595e3ace231d2faa821", + "commit": "d84e7e13fe862a8ed03d14f08a8207ff0f3958c1", "release": null, - "url": "https://github.com/cellajs/cella/commit/205bab62819bdcddd7f45595e3ace231d2faa821", - "syncedAt": "2026-09-30T15:52:15.600Z" + "url": "https://github.com/cellajs/cella/commit/d84e7e13fe862a8ed03d14f08a8207ff0f3958c1", + "syncedAt": "2026-10-02T09:56:26.186Z" } } diff --git a/cella/cella.migrations.json b/cella/cella.migrations.json deleted file mode 100644 index b8a968854..000000000 --- a/cella/cella.migrations.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "applied": [ - "20260722T0902-drop-entity-suffix-renames", - "20260722T1906-embedding-propagation-rename", - "20260722T2050-sonner-style-toaster-api", - "20260722T2105-prepared-mutation-compose", - "20260723T0739-hierarchy-owned-id-columns", - "20260723T0802-hierarchy-derived-entity-arrays", - "20260723T0814-hierarchy-instance-only-api", - "20260723T0822-drop-product-path-column", - "20260723T0824-public-read-flag", - "20260723T0959-permission-vocabulary", - "20260723T1237-typed-nullable-user-and-tree-rows", - "20260723T1311-batch-presigned-urls", - "20260723T1705-media-attachment-ref", - "20260723T2257-deploy-engine-waves", - "20260729T0922-app-product-mocks", - "20260730T0425-boot-image-rename", - "20260730T0624-attachment-keys-map", - "20260730T0858-frontend-module-placements", - "20260730T1009-owned-host-embedding", - "20260730T1258-cella-config-into-cella-folder", - "20260731T0844-iam-model-v2", - "20260804T1641-headless-settings-placements", - "20260811T0905-ts-import-extensions", - "20260812T1724-deploy-vocabulary", - "20260816T0704-dev-port-offsets", - "20260817T1053-product-view-count-helpers", - "20260817T1055-tools-config-channel-columns", - "20260817T1447-remove-filter-tab-ids", - "20260817T2052-comment-budget", - "20260821T1532-app-table-classifications-and-jobs", - "20260828T1711-formlabel-help-popover", - "20260828T2030-elevated-grants-root-roles", - "20260828T2157-notifications-module", - "20260831T1533-web-push", - "20260902T0906-generic-channel-path-resolver", - "20260902T0939-role-vocabulary-from-hierarchy", - "20260902T0945-attachment-placement-seam-v2", - "20260902T0949-generic-app-adoptions", - "20260902T0950-brand-and-app-locale-ignored", - "20260902T1355-members-config-and-hierarchy-test-seeds", - "20260902T1536-seam-consolidation", - "20260903T0640-notifications-contract", - "20260904T0746-organization-spine", - "20260904T0846-rls-defense-in-depth", - "20260904T0947-rls-owner-bypass", - "20260904T1349-table-bar-sticky-cleanup", - "20260907T0619-route-tree-generation-script", - "20260907T0711-description-seed-preview-hooks", - "20260909T0740-yjs-update-log", - "20260911T0759-registry-owned-iam-principals", - "20260917T1850-sign-out-lifecycle", - "20260917T2101-email-verification-token-removed", - "20260918T0711-email-ledger-proof-stamps", - "20260918T0831-identities-table", - "20260918T1245-invitee-onboarding", - "20260921T1433-devices-table", - "20260921T1436-identity-issuer-slug", - "20260921T1629-node-26", - "20260921T1945-devices-table-slim", - "20260922T1210-principals", - "20260922T1600-service-accounts", - "20260922T1900-oauth-server", - "20260922T1930-mcp-substrate", - "20260922T2010-app-voice", - "20260923T0803-auth-renames-3", - "20260923T0835-auth-resource-activities", - "20260923T0850-geoip-bucket-source", - "20260923T0902-principal-to-actor", - "20260923T1200-partition-maintenance-pg-cron", - "20260923T2319-session-revocation", - "20260923T2343-infra-operator-keys", - "20260926T0700-jobs-pg-boss", - "20260927T0704-access-hardening", - "20260930T0855-store-selectors-and-seams", - "20260930T1510-base-ui-toast" - ] -} diff --git a/cella/migrations/20260722T0902-drop-entity-suffix-renames/README.md b/cella/migrations/20260722T0902-drop-entity-suffix-renames/README.md deleted file mode 100644 index 9252e1f0f..000000000 --- a/cella/migrations/20260722T0902-drop-entity-suffix-renames/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# Drop the redundant `Entity` suffix from single-family identifiers - -## What & why - -Identifiers constrained to one entity family drop their redundant `Entity`: `ChannelEntityBase` -> -`ChannelBase`, `ProductEntityBase` -> `ProductBase` (with `*BaseSchema`, `channelBaseSelect`, -`mockChannelBase`/`mockProductBase`, `.openapi()` names), `getValidChannelEntity` -> `getValidChannel`, -`EnrichedChannelEntity` -> `EnrichedChannel`, `ChannelEntityView` -> `ChannelView`, -`ChannelEntityIdColumns` -> `ChannelIdColumns`, `channelEntityColumns` -> `channelColumns`, product -siblings (`getValidProduct`, `ProductView`, `productColumns`), bare `channelEntity` -> `channel`, -`channelEntityId`/`Ids`/`Key` -> `channelId`/`Ids`/`Key`, `ChannelScope` -> `AncestorChannelIds`. -Type-string unions (`ChannelEntityType`, `productEntityTypes`, `entityType`/`entityId`) keep -`entity`. Full map: `RENAMES` (48 ids) and `FILE_STEMS` (9 files) in -[`drop-entity-suffix-renames.ts`](./drop-entity-suffix-renames.ts), allow-listed and word-boundary -matched. - -## Blast radius - -Internal rename, ~120 upstream files. No wire-shape change (OpenAPI component names change, field -shapes identical; `oasdiff breaking` clean; no `clientCacheVersion` bump or lens). Public SDK type -names change: cut as `feat!`. Affected wherever an app references a renamed identifier or file; a -file pairing its own `channelEntity` with a distinct `channel` is reconciled by hand. - -## Run - -On app-specific code after pulling the upstream sweep: - -```sh -pnpm exec tsx cella/migrations/20260722T0902-drop-entity-suffix-renames/drop-entity-suffix-renames.ts inventory backend/src backend/tests backend/scripts frontend/src shared cdc/src yjs/src -pnpm exec tsx cella/migrations/20260722T0902-drop-entity-suffix-renames/drop-entity-suffix-renames.ts rewrite backend/src backend/tests backend/scripts frontend/src shared cdc/src yjs/src -``` - -App-specific identifiers go in `--extra-renames ` (a JSON `{ "old": "new" }` object), never in -the shipped script. - -## Manual steps - -1. In `backend/src/modules/memberships/memberships-mocks.ts`, rename the module-local - `type ChannelEntity = { id; tenantId }` to `ChannelRef` (the codemod has no `ChannelEntity` key). -2. `git mv` the renamed files (import paths are already rewritten): - - | old | new | - | --- | --- | - | `backend/src/permissions/get-channel-entity.ts` | `get-valid-channel.ts` | - | `backend/src/permissions/get-product-entity.ts` | `get-valid-product.ts` | - | `backend/src/db/utils/channel-entity-columns.ts` | `channel-columns.ts` | - | `backend/src/db/utils/product-entity-columns.ts` | `product-columns.ts` | - | `backend/src/schemas/channel-entity-included.ts` | `channel-included.ts` | - | `backend/src/mocks/mock-channel-entity-id-columns.ts` | `mock-channel-id-columns.ts` | - | `frontend/src/utils/channel-entity-route.ts` | `channel-route.ts` | - | `frontend/src/hooks/use-page-channel-entity-key.ts` | `use-page-channel-key.ts` | - | `frontend/src/modules/memberships/leave-channel-entity-button.tsx` | `leave-channel-button.tsx` | - -## Verify - -```sh -pnpm sdk # OpenAPI component + SDK type names changed -pnpm check # single gate: sdk regen + typecheck + lint:fix -``` - -## Not renamed (decided) - -`isChannelEntity` / `isProductEntity` stay: `isChannel` / `isProduct` already name the -`hierarchy.isChannel` / `topology.isProduct` methods the guards wrap. diff --git a/cella/migrations/20260722T0902-drop-entity-suffix-renames/drop-entity-suffix-renames.ts b/cella/migrations/20260722T0902-drop-entity-suffix-renames/drop-entity-suffix-renames.ts deleted file mode 100644 index c606fc941..000000000 --- a/cella/migrations/20260722T0902-drop-entity-suffix-renames/drop-entity-suffix-renames.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Codemod: drop the redundant `Entity` suffix from single-family channel/product identifiers. - * - * Cella has two entity families: channel entities (membership-scoped) and product entities - * (content). The generic word `entity` stays on genuinely entity-agnostic code and on the - * type-string unions (`ChannelEntityType`, `productEntityTypes`, `entityType`, …). This sweep - * only renames identifiers that were constrained to one family and wore a redundant `Entity`: - * base schemas keep `Base` (`ChannelBase`, `ProductBase`), everything else drops `Entity` - * (`getValidChannel`, `EnrichedChannel`, `isChannel`-style names, `channelColumns`, …). - * - * Whole-identifier allow-list, word-boundary matched: it can never touch `ChannelEntityType` or - * the ~140 bare `channelEntity` variables (those collide with existing `channel`/`channelId` and - * are deferred). Longest keys are matched first so nested names (…ButtonProps) are safe. - * - * Usage (from the repo root): - * pnpm exec tsx cella/migrations//drop-entity-suffix-renames.ts inventory - * pnpm exec tsx cella/migrations//drop-entity-suffix-renames.ts rewrite - * pnpm exec tsx cella/migrations//drop-entity-suffix-renames.ts rewrite --extra-renames app.json - */ - -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { extname, join } from 'node:path' - -/** Whole-identifier renames (old -> new), word-boundary matched. */ -const RENAMES: Record = { - // Permission subjects carry ancestor channel ids, not a computed authorization scope. - ChannelScope: 'AncestorChannelIds', - // Channel base entity: type/schema/mock/select and the channel included schema. - // Mirrors the product side (ProductBase / productBaseSchema / mockProductBase). - ChannelEntityBase: 'ChannelBase', - channelEntityBaseSchema: 'channelBaseSchema', - channelEntityBaseSelect: 'channelBaseSelect', - mockChannelEntityBase: 'mockChannelBase', - channelEntityIncludedSchema: 'channelIncludedSchema', - // Product base entity: type/schema/mock - ProductEntityBase: 'ProductBase', - productEntityBaseSchema: 'productBaseSchema', - mockProductEntityBase: 'mockProductBase', - // Fetch-and-authorize helpers and their result types - getValidChannelEntity: 'getValidChannel', - getValidProductEntity: 'getValidProduct', - ValidChannelEntityResult: 'ValidChannelResult', - ValidProductEntityResult: 'ValidProductResult', - // Channel enrichment - EnrichedChannelEntity: 'EnrichedChannel', - EnrichableChannelEntity: 'EnrichableChannel', - initChannelEntityEnrichment: 'initChannelEnrichment', - ChannelEntityEnrichment: 'ChannelEnrichment', - // Channel route + list-query wiring - getChannelEntityRoute: 'getChannelRoute', - channelEntityRouteConfig: 'channelRouteConfig', - ChannelEntityRouteEntry: 'ChannelRouteEntry', - getChannelEntityKeys: 'getChannelKeys', - channelEntityListQueriesByType: 'channelListQueriesByType', - ChannelEntityListQueryMap: 'ChannelListQueryMap', - ChannelEntityListQueryFactory: 'ChannelListQueryFactory', - channelEntityConfigs: 'channelConfigs', - channelEntityResults: 'channelResults', - channelEntityData: 'channelData', - usePageChannelEntityKey: 'usePageChannelKey', - // UI - ChannelEntityView: 'ChannelView', - ProductEntityView: 'ProductView', - ChannelEntityGridTile: 'ChannelGridTile', - LeaveChannelEntityButton: 'LeaveChannelButton', - LeaveChannelEntityButtonProps: 'LeaveChannelButtonProps', - leaveChannelEntity: 'leaveChannel', - // Id columns / tables / db side-effects - ChannelEntityIdColumns: 'ChannelIdColumns', - ChannelEntityIdOverrides: 'ChannelIdOverrides', - channelEntityColumns: 'channelColumns', - productEntityColumns: 'productColumns', - channelEntityTables: 'channelTables', - channelEntityTableNames: 'channelTableNames', - productEntitySet: 'productSet', - productEntityImmutableColumns: 'productImmutableColumns', - productEntityImmutabilityFunctionSQL: 'productImmutabilityFunctionSQL', - noRlsProductEntityNames: 'noRlsProductNames', - // Instance variables / params, verified collision-free per file (no real `channel`/`channelId` - // identifier co-occurs; earlier whole-repo counts were the English word in comments). Longest - // keys match first, so the id/ids/key variants win over bare `channelEntity`. - channelEntityIds: 'channelIds', - channelEntityId: 'channelId', - channelEntityKey: 'channelKey', - channelEntity: 'channel', -} - -/** Kebab file-stem renames applied inside import path strings (old -> new). */ -const FILE_STEMS: Record = { - 'mock-channel-entity-id-columns': 'mock-channel-id-columns', - 'use-page-channel-entity-key': 'use-page-channel-key', - 'leave-channel-entity-button': 'leave-channel-button', - 'channel-entity-columns': 'channel-columns', - 'product-entity-columns': 'product-columns', - 'channel-entity-included': 'channel-included', - 'channel-entity-route': 'channel-route', - 'get-channel-entity': 'get-valid-channel', - 'get-product-entity': 'get-valid-product', -} - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.turbo']) -const EXTS = new Set(['.ts', '.tsx']) - -/** Escape a string for use inside a RegExp. */ -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -/** Build one alternation regex, longest keys first, so nested identifiers match whole. */ -function buildRegex(keys: string[], wordBoundary: boolean): RegExp { - const alts = [...keys].sort((a, b) => b.length - a.length).map(escapeRegExp) - const body = `(${alts.join('|')})` - return new RegExp(wordBoundary ? `\\b${body}\\b` : body, 'g') -} - -/** Recursively collect .ts/.tsx files under a root, skipping generated and vendored dirs. */ -function collect(root: string, out: string[]): void { - let entries: ReturnType - try { - entries = readdirSync(root, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - const full = join(root, entry.name) - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) collect(full, out) - } else if (EXTS.has(extname(entry.name)) && !entry.name.endsWith('.gen.ts') && !full.includes('/gen/')) { - out.push(full) - } - } -} - -function main(): void { - const [mode, ...rest] = process.argv.slice(2) - if (mode !== 'inventory' && mode !== 'rewrite') { - console.error('Usage: [--extra-renames ]') - process.exit(1) - } - - const extraIdx = rest.indexOf('--extra-renames') - const roots = (extraIdx === -1 ? rest : rest.slice(0, extraIdx)).filter((a) => !a.startsWith('--')) - const renames = { ...RENAMES } - if (extraIdx !== -1) { - const file = rest[extraIdx + 1] - Object.assign(renames, JSON.parse(readFileSync(file, 'utf8')) as Record) - } - if (roots.length === 0) { - console.error('Pass at least one root directory (e.g. backend/src frontend/src shared cdc/src).') - process.exit(1) - } - - const idRegex = buildRegex(Object.keys(renames), true) - const stemRegex = buildRegex(Object.keys(FILE_STEMS), false) - - const files: string[] = [] - for (const root of roots) { - if (statSync(root).isDirectory()) collect(root, files) - else files.push(root) - } - - const counts: Record = {} - let changedFiles = 0 - for (const file of files) { - const before = readFileSync(file, 'utf8') - const after = before - .replace(idRegex, (m) => { - counts[m] = (counts[m] ?? 0) + 1 - return renames[m] - }) - .replace(stemRegex, (m) => FILE_STEMS[m]) - if (after !== before) { - changedFiles += 1 - if (mode === 'rewrite') writeFileSync(file, after) - } - } - - const verb = mode === 'rewrite' ? 'Rewrote' : 'Would rewrite' - console.info(`${verb} ${changedFiles} file(s) across ${files.length} scanned.`) - const hits = Object.entries(counts).sort((a, b) => b[1] - a[1]) - for (const [name, n] of hits) console.info(` ${name} -> ${renames[name]} (${n})`) - if (mode === 'inventory') console.info('\nRun with `rewrite` to apply, then `git mv` the files listed in the README.') -} - -main() diff --git a/cella/migrations/20260722T1906-embedding-propagation-rename/README.md b/cella/migrations/20260722T1906-embedding-propagation-rename/README.md deleted file mode 100644 index cc833a418..000000000 --- a/cella/migrations/20260722T1906-embedding-propagation-rename/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Rename embedding propagation contract to embedded/host product - -## What & why - -`PropagationHint` fields use the `productEmbeddings` vocabulary: `sourceType` -> `embeddedProduct`, -`targetType` -> `hostProduct`, `field` -> `hostColumn`. Touched: the exported `PropagationHint` -type (`shared`), the wire schema `propagationHintSchema` on `StreamNotification.propagation` and -catchup `changes[].propagation`, `build-message.ts`, `propagation-hints.ts` (`propagationTargets` --> `hostsByEmbeddedProduct`), `propagation.ts`. The two product-type wire fields tighten from -`z.string()` to `z.enum(productEntityTypes)`. - -## Blast radius - -Sync-breaking and cache-bumping; no database change; `productEmbeddings` config keys unchanged. The -`StreamNotification.propagation` wire shape changed, so `schema-bust-gate` demands a -`clientCacheVersion` bump even with `productEmbeddings: []`. App code reading `.sourceType` / -`.targetType` / `.field` on a hint or constructing a `PropagationHint` fails `pnpm check`; apps -with no custom propagation code only need the bump. - -## Run - -No script, manual (`sourceType`, `targetType`, `field` are too generic for a safe codemod). - -## Manual steps - -1. Grep for hint field reads outside the upstream files; rename - `sourceType` -> `embeddedProduct`, `targetType` -> `hostProduct`, `field` -> `hostColumn` in - propagation hints only (leave `resourceType`, data-grid columns, form fields alone): - - ```sh - grep -rnE "\.(sourceType|targetType)\b" --include=*.ts --include=*.tsx \ - backend/src frontend/src cdc/src shared | grep -v resourceType - grep -rn "PropagationHint" --include=*.ts backend/src frontend/src shared - ``` - -2. `propagationTargets` references become `hostsByEmbeddedProduct`. -3. Bump `clientCacheVersion` in `shared/config/config.default.ts` (any new value); queued - mutations survive the wipe. - -## Verify - -```sh -pnpm sdk # regenerate the SDK from the renamed wire schema -pnpm check # typecheck catches any missed hint-field reference -``` diff --git a/cella/migrations/20260722T2050-sonner-style-toaster-api/README.md b/cella/migrations/20260722T2050-sonner-style-toaster-api/README.md deleted file mode 100644 index f86112693..000000000 --- a/cella/migrations/20260722T2050-sonner-style-toaster-api/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Adopt the Sonner-style toaster API - -## What & why - -`toaster` follows Sonner's callable API: positional `toaster(message, severity, options)` becomes -`toaster.success(message, options)`, `toaster.info(...)`, `toaster.warning(...)`, or -`toaster.error(...)`; `toaster(message, options)` stays for default toasts. The wrapper also exposes -Sonner's `loading`, `message`, `promise`, `custom`, `dismiss`, `getHistory`, and `getToasts`, accepts -Sonner's full options type, and returns toast ids. String messages without `options.id` get a stable -Cella id (a repeated message updates one toast); pass your own id for a distinct identity. - -## Blast radius - -Sync-breaking for frontend code passing severity as the second positional argument; the codemod -rewrites literal severities and reports dynamic ones. Apps without custom toaster calls are -unaffected. No database, OpenAPI, SDK, or wire-shape change; no `clientCacheVersion` bump or lens. - -## Run - -Inventory, then rewrite: - -```sh -pnpm exec tsx cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts inventory frontend/src -pnpm exec tsx cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts rewrite frontend/src -``` - -For a `toaster` imported from another module path, add the repeatable `--module` flag: - -```sh -pnpm exec tsx cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts rewrite frontend/src --module "~/app/toaster" -``` - -## Manual steps - -1. Resolve every call reported as a dynamic second argument. Value restricted to - `success | error | info | warning`: - - ```ts - toaster[severity](message, options); - ``` - - Value that can also be `default`: - - ```ts - if (severity === 'default') toaster(message, options); - else toaster[severity](message, options); - ``` - -2. Review direct `toast` imports from `sonner` (not rewritten). Notifications needing Cella's - duplicate-message policy import `toaster` from `~/modules/common/toaster/toaster`. -3. Review calls skipped because the imported binding is shadowed: rename the nested binding and - rerun the codemod, or update the call by hand. - -## Verify - -The second inventory must report zero rewrites and no skipped legacy calls: - -```sh -pnpm exec tsx cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.test.ts -pnpm exec tsx cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts inventory frontend/src -pnpm check -``` diff --git a/cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.test.ts b/cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.test.ts deleted file mode 100644 index cae5ed321..000000000 --- a/cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import assert from 'node:assert/strict'; -import { transformSource } from './sonner-style-toaster-api'; - -const modulePath = '~/modules/common/toaster/toaster'; - -const legacy = ` -import { toaster as notify } from '${modulePath}'; - -notify(message, 'success'); -notify( - translate('failed'), - 'error', - { description: details }, -); -notify(message, severity); -`; - -const transformed = transformSource(legacy, 'fixture.ts'); -assert.equal(transformed.rewrites, 2); -assert.deepEqual(transformed.rewritesBySeverity, { error: 1, success: 1 }); -assert.match(transformed.output, /notify\.success\(message\)/); -assert.match(transformed.output, /notify\.error\(translate\('failed'\), \{ description: details \}\)/); -assert.match(transformed.output, /notify\(message, severity\)/); -assert.equal(transformed.skipped.length, 1); -assert.match(transformed.skipped[0].reason, /dynamic second argument/); - -const defaults = transformSource( - `import { toaster } from '${modulePath}';\ntoaster(message, 'default', options);\ntoaster(message, options);`, - 'defaults.ts', -); -assert.match(defaults.output, /toaster\(message, options\);\ntoaster\(message, options\);/); -assert.equal(defaults.rewrites, 1); -assert.equal(defaults.skipped.length, 1); - -const unrelated = transformSource( - `import { toaster } from 'another-package';\ntoaster(message, 'info');`, - 'unrelated.ts', -); -assert.equal(unrelated.output, `import { toaster } from 'another-package';\ntoaster(message, 'info');`); -assert.equal(unrelated.rewrites, 0); - -const alreadyMigrated = transformSource( - `import { toaster } from '${modulePath}';\ntoaster.warning(message);`, - 'migrated.ts', -); -assert.equal(alreadyMigrated.output, `import { toaster } from '${modulePath}';\ntoaster.warning(message);`); -assert.equal(alreadyMigrated.rewrites, 0); - -const shadowed = transformSource( - `import { toaster } from '${modulePath}';\nfunction run(toaster) { toaster(message, 'error'); }`, - 'shadowed.ts', -); -assert.equal(shadowed.rewrites, 0); -assert.match(shadowed.skipped[0].reason, /shadowed/); - -const idempotent = transformSource(transformed.output, 'fixture.ts'); -assert.equal(idempotent.rewrites, 0); -assert.equal(idempotent.output, transformed.output); - -const comments = transformSource( - `import { toaster } from '${modulePath}';\ntoaster(\n // Keep this message context.\n message,\n 'info',\n);`, - 'comments.ts', -); -assert.match(comments.output, /toaster\.info\(\/\/ Keep this message context\.\n message\)/); - -console.info('sonner-style-toaster-api codemod tests passed'); diff --git a/cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts b/cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts deleted file mode 100644 index 5e16d86ef..000000000 --- a/cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Codemod: convert Cella's positional toaster severity to Sonner-style methods. - * - * Usage from the repository root: - * pnpm exec tsx cella/migrations//sonner-style-toaster-api.ts inventory frontend/src - * pnpm exec tsx cella/migrations//sonner-style-toaster-api.ts rewrite frontend/src - */ - -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { extname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; - -const defaultModule = '~/modules/common/toaster/toaster'; -const severities = new Set(['success', 'error', 'info', 'warning']); -const extensions = new Set(['.js', '.jsx', '.ts', '.tsx']); -const skipDirectories = new Set(['.git', '.turbo', 'build', 'coverage', 'dist', 'node_modules']); - -interface Rewrite { - end: number; - severity: string; - start: number; - text: string; -} - -/** A call the codemod cannot safely classify without project-specific type information. */ -export interface SkippedCall { - column: number; - expression: string; - line: number; - reason: string; -} - -/** Result of transforming one JavaScript or TypeScript source file. */ -export interface TransformResult { - output: string; - rewrites: number; - rewritesBySeverity: Record; - skipped: SkippedCall[]; -} - -function scriptKind(file: string): ts.ScriptKind { - if (file.endsWith('.tsx')) return ts.ScriptKind.TSX; - if (file.endsWith('.jsx')) return ts.ScriptKind.JSX; - if (file.endsWith('.js')) return ts.ScriptKind.JS; - return ts.ScriptKind.TS; -} - -function matchesModule(specifier: string, modules: Set): boolean { - return modules.has(specifier) || specifier.endsWith('/modules/common/toaster/toaster'); -} - -function importedBindings(sourceFile: ts.SourceFile, modules: Set): Set { - const bindings = new Set(); - for (const statement of sourceFile.statements) { - if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; - if (!matchesModule(statement.moduleSpecifier.text, modules)) continue; - const namedBindings = statement.importClause?.namedBindings; - if (!namedBindings || !ts.isNamedImports(namedBindings)) continue; - for (const element of namedBindings.elements) { - const importedName = element.propertyName?.text ?? element.name.text; - if (importedName === 'toaster') bindings.add(element.name.text); - } - } - return bindings; -} - -function bindingContains(name: ts.BindingName, identifier: string): boolean { - if (ts.isIdentifier(name)) return name.text === identifier; - return name.elements.some((element) => !ts.isOmittedExpression(element) && bindingContains(element.name, identifier)); -} - -function hasNestedDeclaration(sourceFile: ts.SourceFile, identifier: string): boolean { - let found = false; - const visit = (node: ts.Node): void => { - if (found) return; - if ( - (ts.isVariableDeclaration(node) || ts.isParameter(node)) && - bindingContains(node.name, identifier) - ) { - found = true; - return; - } - if ( - (ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isClassDeclaration(node) || - ts.isClassExpression(node)) && - node.name?.text === identifier - ) { - found = true; - return; - } - ts.forEachChild(node, visit); - }; - ts.forEachChild(sourceFile, visit); - return found; -} - -function argumentText(source: string, sourceFile: ts.SourceFile, argument: ts.Expression): string { - return source.slice(argument.getFullStart(), argument.getEnd()).trim(); -} - -function callText( - source: string, - sourceFile: ts.SourceFile, - callee: string, - severity: string, - message: ts.Expression, - options: ts.Expression | undefined, -): string { - const target = severity === 'default' ? callee : `${callee}.${severity}`; - const messageText = argumentText(source, sourceFile, message); - const args = options ? `${messageText}, ${argumentText(source, sourceFile, options)}` : messageText; - return `${target}(${args})`; -} - -/** Transform legacy toaster calls in one source string. */ -export function transformSource( - source: string, - file = 'source.ts', - moduleSpecifiers: Iterable = [defaultModule], -): TransformResult { - const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file)); - const bindings = importedBindings(sourceFile, new Set(moduleSpecifiers)); - const shadowedBindings = new Set([...bindings].filter((binding) => hasNestedDeclaration(sourceFile, binding))); - const edits: Rewrite[] = []; - const skipped: SkippedCall[] = []; - - const skip = (node: ts.CallExpression, reason: string): void => { - const location = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); - skipped.push({ - line: location.line + 1, - column: location.character + 1, - reason, - expression: node.getText(sourceFile).replace(/\s+/g, ' '), - }); - }; - - const visit = (node: ts.Node): void => { - if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && bindings.has(node.expression.text)) { - const callee = node.expression.text; - if (shadowedBindings.has(callee)) { - skip(node, `the imported binding '${callee}' is shadowed elsewhere in this file`); - } else if (node.arguments.length >= 2) { - const [message, severityNode, options] = node.arguments; - const severity = - ts.isStringLiteral(severityNode) || ts.isNoSubstitutionTemplateLiteral(severityNode) - ? severityNode.text - : undefined; - - if (severity && (severities.has(severity) || severity === 'default')) { - if (node.arguments.length > 3) skip(node, 'legacy toaster calls accept at most three arguments'); - else { - edits.push({ - start: node.getStart(sourceFile), - end: node.getEnd(), - severity, - text: callText(source, sourceFile, callee, severity, message, options), - }); - } - } else if (severity) skip(node, `unsupported severity '${severity}'`); - else if (!ts.isObjectLiteralExpression(severityNode) && severityNode.kind !== ts.SyntaxKind.UndefinedKeyword) { - skip(node, 'dynamic second argument; verify whether it is a severity or Sonner options'); - } - } - } - ts.forEachChild(node, visit); - }; - visit(sourceFile); - - let output = source; - const rewritesBySeverity: Record = {}; - for (const edit of edits.sort((a, b) => b.start - a.start)) { - output = `${output.slice(0, edit.start)}${edit.text}${output.slice(edit.end)}`; - rewritesBySeverity[edit.severity] = (rewritesBySeverity[edit.severity] ?? 0) + 1; - } - - return { output, rewrites: edits.length, rewritesBySeverity, skipped }; -} - -function collectFiles(root: string, output: string[]): void { - const stats = statSync(root); - if (!stats.isDirectory()) { - if (extensions.has(extname(root))) output.push(root); - return; - } - - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (entry.isDirectory() && skipDirectories.has(entry.name)) continue; - const file = join(root, entry.name); - if (entry.isDirectory()) collectFiles(file, output); - else if (extensions.has(extname(entry.name)) && !entry.name.includes('.gen.')) output.push(file); - } -} - -function parseArguments(args: string[]): { mode: 'inventory' | 'rewrite'; modules: Set; roots: string[] } { - const [mode, ...rest] = args; - if (mode !== 'inventory' && mode !== 'rewrite') { - throw new Error('Usage: [--module ]'); - } - - const modules = new Set([defaultModule]); - const roots: string[] = []; - for (let index = 0; index < rest.length; index += 1) { - if (rest[index] === '--module') { - const specifier = rest[index + 1]; - if (!specifier) throw new Error('--module requires an import specifier'); - modules.add(specifier); - index += 1; - } else roots.push(rest[index]); - } - if (roots.length === 0) throw new Error('Pass at least one source root, for example frontend/src.'); - return { mode, modules, roots }; -} - -function main(): void { - let parsed: ReturnType; - try { - parsed = parseArguments(process.argv.slice(2)); - } catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - return; - } - - const files: string[] = []; - for (const root of parsed.roots) collectFiles(root, files); - - let changedFiles = 0; - let rewriteCount = 0; - const rewriteCounts: Record = {}; - const skipped: Array = []; - for (const file of files) { - const source = readFileSync(file, 'utf8'); - const result = transformSource(source, file, parsed.modules); - rewriteCount += result.rewrites; - for (const [severity, count] of Object.entries(result.rewritesBySeverity)) { - rewriteCounts[severity] = (rewriteCounts[severity] ?? 0) + count; - } - skipped.push(...result.skipped.map((item) => ({ ...item, file }))); - if (result.output === source) continue; - changedFiles += 1; - if (parsed.mode === 'rewrite') writeFileSync(file, result.output); - } - - const verb = parsed.mode === 'rewrite' ? 'Rewrote' : 'Would rewrite'; - console.info(`${verb} ${rewriteCount} call(s) in ${changedFiles} file(s) across ${files.length} scanned.`); - for (const [severity, count] of Object.entries(rewriteCounts).sort()) { - console.info(` ${severity}: ${count}`); - } - if (skipped.length > 0) { - console.info(`Skipped ${skipped.length} ambiguous call(s) for manual review:`); - for (const item of skipped) { - console.info(` ${item.file}:${item.line}:${item.column} ${item.reason}\n ${item.expression}`); - } - } -} - -const entryFile = process.argv[1] ? resolve(process.argv[1]) : undefined; -if (entryFile === fileURLToPath(import.meta.url)) main(); diff --git a/cella/migrations/20260722T2105-prepared-mutation-compose/README.md b/cella/migrations/20260722T2105-prepared-mutation-compose/README.md deleted file mode 100644 index b2f1e5d29..000000000 --- a/cella/migrations/20260722T2105-prepared-mutation-compose/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Compose prepared mutations over useMutation (drop usePreparedMutation) - -## What & why - -`usePreparedMutation` is removed from -[`frontend/src/query/offline/prepared-mutation.ts`](../../../frontend/src/query/offline/prepared-mutation.ts); -it hid `useMutation` behind five explicit generics. `buildPreparedHandlers(mutation, prepare)` -stays; hooks call `useMutation` and spread the handlers: - -```ts -const mutation = useMutation(options); -return { ...mutation, ...buildPreparedHandlers(mutation, prepare) }; -``` - -Three generics infer; the `mutation as Mutatable` cast is gone (`Mutatable` uses method syntax). -`PreparedVars`, `COALESCED`, and `buildPreparedHandlers` are unchanged and still exported. - -## Blast radius - -Sync-breaking, frontend only; `pnpm check` catches every site: app hooks importing -`usePreparedMutation` (per-entity `query.ts` offline create/update/delete, e.g. `attachment`; -`label` and `task` in a task-style app). `prepare` functions, squash/coalesce logic, and -`COALESCED` call sites are unchanged. No wire-shape, `clientCacheVersion`, lens, or database -change; apps that only consume the entity hooks are unaffected. - -## Run - -No script, manual (one block per hook). - -## Manual steps - -1. Find every app call site (upstream files arrive migrated): - - ```sh - grep -rn "usePreparedMutation" frontend/src --include=*.ts --include=*.tsx - ``` - -2. In each matching `query.ts`, add `useMutation` to the `@tanstack/react-query` import and swap - `usePreparedMutation` for `buildPreparedHandlers`, keeping `PreparedVars` (and `COALESCED` if - imported): - - ```ts - import { buildPreparedHandlers, type PreparedVars } from '~/query/offline/prepared-mutation'; - ``` - -3. Rewrite each hook. Create (inline `prepare`, annotate its input): - - ```ts - // before - return usePreparedMutation( - createOptions(queryClient), - (data) => ({ kind: 'run', vars: { tenantId, organizationId, data, stx: createStxForCreate() } }), - ); - - // after - const mutation = useMutation(createOptions(queryClient)); - const prepare = (data: CreateInput): PreparedVars => ({ - kind: 'run', - vars: { tenantId, organizationId, data, stx: createStxForCreate() }, - }); - return { ...mutation, ...buildPreparedHandlers(mutation, prepare) }; - ``` - - Update/delete (`prepare` already annotated `(input): PreparedVars => …`; swap only the - return): - - ```ts - // before - return usePreparedMutation( - updateOptions(queryClient), - prepare, - ); - - // after - const mutation = useMutation(updateOptions(queryClient)); - // ... existing const prepare = (...) => { ... } ... - return { ...mutation, ...buildPreparedHandlers(mutation, prepare) }; - ``` - -4. Leave `COALESCED` call sites unchanged: `mutateAsync` still resolves to `TData | typeof - COALESCED`, so `createdEntity !== COALESCED` guards keep narrowing. - -## Verify - -```sh -pnpm check # flags any remaining usePreparedMutation reference or missed generic -``` diff --git a/cella/migrations/20260723T0739-hierarchy-owned-id-columns/README.md b/cella/migrations/20260723T0739-hierarchy-owned-id-columns/README.md deleted file mode 100644 index b2e6859d0..000000000 --- a/cella/migrations/20260723T0739-hierarchy-owned-id-columns/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Hierarchy-owned id-column keys and row-location API - -## What & why - -`EntityHierarchy` instances own row location: `idColumnKeys` (`organization` to `organizationId`), -`idColumnKey(type)`, `idColumnName(type)`, `resolveNonNullAncestors`, `resolveDeepestAncestorId`, -`possibleHomeChannels`, `computeAncestorPath`, `computeProductPath`, `computeChannelPath`, -`pathColumnSql`, `deepestAncestorSql`. The SQL builders moved from -`backend/src/db/utils/path-column.ts` and `recalculate-counters.ts` to -`shared/src/config-builder/row-path.ts` (`pathColumnSql` / `deepestAncestorSql`); the free -functions stay exported. `config.default.ts` now sets `entityIdColumnKeys: hierarchy.idColumnKeys` -(a literal map drifts). - -## Blast radius - -Sync-breaking on config only. No wire-shape change, no `clientCacheVersion` bump, no database change -(generated `path` SQL is byte-identical). Apps that never touched the `entityIdColumnKeys` block and -never imported `pathColumnExpression` need only the config edit. Callers of -`pathColumnExpression(entityType, appendOwnId, h, idColumnKeys)` switch to -`h.pathColumnSql(entityType, appendOwnId)` or shared `pathColumnSql(h, ...)` (no injectable -`idColumnKeys`). - -## Run - -No script, manual. - -## Manual steps - -1. In `config.default.ts`, replace the literal `entityIdColumnKeys: { ... }` map with - `entityIdColumnKeys: hierarchy.idColumnKeys,` (import `hierarchy` from your hierarchy config - module if the file only re-exports it). -2. Replace app imports of `pathColumnExpression` from `backend/src/db/utils/path-column.ts` with - `hierarchy.pathColumnSql(type, appendOwnId)` or `pathColumnSql` from `shared`. -3. Optional: helpers hand-writing `` `${type}Id` `` or `` `${snake}_id` `` can use - `entityIdColumnKey(type)` / `entityIdColumnName(type)` from `shared`, or - `hierarchy.idColumnKey` / `hierarchy.idColumnName`. - -## Verify - -```sh -pnpm check -pnpm generate # must produce no new migration -``` diff --git a/cella/migrations/20260723T0802-hierarchy-derived-entity-arrays/README.md b/cella/migrations/20260723T0802-hierarchy-derived-entity-arrays/README.md deleted file mode 100644 index e6ca43654..000000000 --- a/cella/migrations/20260723T0802-hierarchy-derived-entity-arrays/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Hierarchy-derived entity arrays in config - -## What & why - -The entity taxonomy is declared once, in the hierarchy builder. `config.default.ts` derives -`entityTypes`, `channelEntityTypes`, and `productEntityTypes` from `hierarchy.allTypes` / -`hierarchy.channelTypes` / `hierarchy.productTypes` through the new shared `nonEmpty()` helper, -which narrows to the non-empty tuple drizzle and zod enum sites require. The bidirectional -compile-time checks in `config-validation.ts` are deleted and the orphaned -`EntityIdColumnKeysShape` export is removed. - -## Blast radius - -Sync-breaking on `config.default.ts` only; call sites keep their literal-union element types and -tuple shape, so `z.enum(appConfig.productEntityTypes)` and `varchar({ enum: ... })` compile as -before. No wire or DB change. Literal arrays still compile but reintroduce unchecked drift: derive, -do not redeclare. App imports of `EntityIdColumnKeysShape` must go. - -## Run - -No script, manual. - -## Manual steps - -1. In `config.default.ts`, delete the hand-written `entityTypes`, `channelEntityTypes`, and - `productEntityTypes` arrays (including hoisted `const`s) and write - `entityTypes: nonEmpty(hierarchy.allTypes)`, `channelEntityTypes: nonEmpty(hierarchy.channelTypes)`, - `productEntityTypes: nonEmpty(hierarchy.productTypes)`; import `nonEmpty` from - `../src/config-builder/utils` and `hierarchy` from your hierarchy config module. -2. Rewrite `(typeof productEntityTypes)[number]` style references to - `(typeof hierarchy.productTypes)[number]`. -3. Remove app imports of `EntityIdColumnKeysShape`. - -## Verify - -```sh -pnpm check # EntityType/ChannelEntityType/ProductEntityType unions unchanged; a type error at an enum site means a hand-written array survived -``` diff --git a/cella/migrations/20260723T0814-hierarchy-instance-only-api/README.md b/cella/migrations/20260723T0814-hierarchy-instance-only-api/README.md deleted file mode 100644 index 16965dfcd..000000000 --- a/cella/migrations/20260723T0814-hierarchy-instance-only-api/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Hierarchy instance-only row-location API - -## What & why - -The `EntityHierarchy` instance is the only entry point for row location and entity-kind guards. -Removed from the `shared` barrel: free functions `resolveDeepestAncestorId`, -`resolveNonNullAncestors`, `possibleHomeChannels`, `computeAncestorPath`, `computeProductPath`, -`computeChannelPath`, `pathColumnSql`, `deepestAncestorSql` (same-named instance methods); guards -`isChannelEntity`, `isProductEntity`, `getChannelRoles` (`shared/src/entity-guards.ts` deleted; use -`hierarchy.isChannel` / `hierarchy.isProduct` / `hierarchy.getRoles`, which accept `null | undefined` -and return false; `shared` re-exports the app singleton's bound `isChannel` / `isProduct` as -aliases); types `AncestorSource`, `CountsHierarchy`, `TopologyHierarchy` (annotate -`EntityHierarchy`; `PermissionTopology.hierarchy` is one). `entityIdColumnName(type)` delegates to -`toColumnName`. - -## Blast radius - -Sync-breaking on imports and call shape, mechanical. No wire, DB, or behavior change. Test mocks -replacing `hierarchy` must override `isChannel`/`isProduct` from the same synthetic instance; -hand-rolled `{ getOrderedAncestors: ... }` fakes become real `createEntityHierarchy` instances (see -`shared/testing/deep-fixture.ts` and `wide-fixture.ts`). - -## Run - -No script, manual search-and-replace: - -- `fn(h, a, b)` becomes `h.fn(a, b)` for the eight row-location functions. -- `isProductEntity(x)` becomes `hierarchy.isProduct(x)` (same for channel/roles variants). -- `AncestorSource` / `CountsHierarchy` / `TopologyHierarchy` annotations become `EntityHierarchy` - (import type from `shared`). - -## Manual steps - -1. Sweep app code for the removed imports and apply the patterns above: `grep -rn "isProductEntity\|isChannelEntity\|getChannelRoles\|AncestorSource\|resolveDeepestAncestorId\|computeProductPath\|computeChannelPath\|computeAncestorPath\|possibleHomeChannels\|resolveNonNullAncestors" src/` -2. Replace hand-rolled hierarchy fakes in tests with real builder instances. -3. Code relying on `isProductEntity(nullableValue)` null tolerance: the instance methods accept - `null | undefined` directly. - -## Verify - -```sh -pnpm check -``` diff --git a/cella/migrations/20260723T0822-drop-product-path-column/README.md b/cella/migrations/20260723T0822-drop-product-path-column/README.md deleted file mode 100644 index 970c09b38..000000000 --- a/cella/migrations/20260723T0822-drop-product-path-column/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Drop the product tables' stored path column - -## What & why - -Product tables lose their generated `path` column; `hierarchy.computeProductPath` derives it from -the row's ancestor id columns at the three consumers: CDC batch grouping (`activity-service`), CDC -move detection (`update.ts`, old vs new location from the REPLICA IDENTITY FULL images), and -stream notifications (`build-message.ts`, including the moveOut path from `movedFrom`). SQL and JS -path rules are parity-tested, so values are byte-identical. Channel tables keep their generated -`path` (mirrored to `channel_counters.path`). `productPathColumn` leaves -`backend/src/db/utils/path-column.ts` (`channelPathColumn` stays); `'path'` leaves the CDC -permission-row subset (`permission-row-data.ts`). - -## Blast radius - -Sync-breaking with a wire-shape change: product REST responses lose `path`, so `clientCacheVersion` -is bumped (`v4-no-product-path`). SSE notifications still carry a computed `path`. DB: one dropped -column per product table via `pnpm generate`. Client code reading `path` off cached PRODUCT rows (no -current app does) switches to `hierarchy.computeProductPath(type, row)`; channel rows are unaffected. - -## Run - -No script, manual. - -## Manual steps - -1. Pull the template change and run `pnpm generate` so drizzle emits the DROP COLUMN migration for - every product table. -2. Remove app mock/seed code that sets `path` on product rows or product response mocks. -3. Replace client reads of `path` from cached product entities with - `hierarchy.computeProductPath(entityType, row)`; channel-row `path` reads stay. -4. Bump your app's `clientCacheVersion` if you maintain your own (the template bump arrives with the - sync). - -## Verify - -```sh -pnpm generate -pnpm check -pnpm test # backend/src/db/utils/path-column.test.ts parity suite; CDC and stream tests cover grouping, move detection, moveOut -``` diff --git a/cella/migrations/20260723T0824-public-read-flag/README.md b/cella/migrations/20260723T0824-public-read-flag/README.md deleted file mode 100644 index 5bb7e1a9c..000000000 --- a/cella/migrations/20260723T0824-public-read-flag/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Public read is a flag, not a mode - -## What & why - -`PublicReadMode` (the single-member `'publicSelf'` union nothing branched on) is deleted from -`shared/src/permissions/public-read.ts` and both barrels (`shared/src/permissions/index.ts`, -`shared/index.ts`). `PublicReadGrants` is `Partial>`, -the config builder's `publicRead()` takes no argument, `GrantSource`'s public variant is -`{ type: 'public' }`, and `formatGrant` prints `public`. Decision logic is untouched (the shared -`'public'` row condition over `publicAt`); a second actor-independent read flavour is a new -`RowConditionName` (`matchesRowCondition` case + SQL twin), not a mode string. - -## Blast radius - -Sync-breaking at the type level, only for apps using public read. No wire-shape change, no -`clientCacheVersion` bump, no database change (`publicAt` columns untouched). Affected if the app -calls `publicRead('publicSelf')` in `shared/config/permissions-config.ts`, imports `PublicReadMode`, -hand-builds a `PublicReadGrants` literal, or asserts on `{ type: 'public', mode: … }`. - -## Run - -No script; find every site: - -```sh -grep -rn "publicSelf\|PublicReadMode" --include="*.ts" --include="*.tsx" --include="*.md" . -``` - -## Manual steps - -1. `shared/config/permissions-config.ts`: `publicRead('publicSelf')` -> `publicRead()`. -2. Drop `PublicReadMode` imports; hand-built grant map values become `true` - (`{ attachment: 'publicSelf' }` -> `{ attachment: true }`). -3. Test assertions: `{ type: 'public', mode: 'publicSelf' }` -> `{ type: 'public' }`; debug snapshots - containing `public:publicSelf` become `public`. -4. App docs showing the call site (upstream updated `cella/PERMISSIONS.md` and `cella/ADD_ENTITY.md`). - -## Verify - -```sh -pnpm check -pnpm test --filter shared -``` diff --git a/cella/migrations/20260723T0959-permission-vocabulary/README.md b/cella/migrations/20260723T0959-permission-vocabulary/README.md deleted file mode 100644 index 0403b4724..000000000 --- a/cella/migrations/20260723T0959-permission-vocabulary/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Permission vocabulary consolidation - -## What & why - -Permission naming follows the hierarchy vocabulary (rule in `cella/PERMISSIONS.md`; `subject` -stays the engine-only noun for the checked instance). The legacy `context` vocabulary, the -`Access*`/`Permission*` split, the `topology` wrapper, and `can`/`enabled`/`isAllowed` are gone: - -| Old | New | -| --- | --- | -| `AccessPolicies` | `PolicyMatrix` | -| `SubjectAccessPolicies` | `EntityPolicies` | -| `AccessPolicyEntry` | `PolicyEntry` | -| `AccessPolicyCallback` | `PolicyCallback` | -| `AccessPolicyConfiguration` | `PolicyConfiguration` | -| `accessPolicies` (config export) | `policyMatrix` | -| `configureAccessPolicies` (testing) | `configurePolicyMatrix` | -| `getSubjectPolicies` | `getEntityPolicies` | -| `PermissionValue` | `PolicyCellInput` (`Exclude`) | -| `NormalizedPermissionValue` | `PolicyCell` (`0 \| 1 \| RowConditionName`) | -| `ActionPermissionState` | `CanState` | -| `resolvePermission` | `resolveCan` | -| `isUnconditionalPermission` | `isUnconditionalCan` | -| `PermissionMembership` | `AccessMembership` | -| `PermissionResult.isAllowed` | `PermissionResult.allowed` | -| `ActionAttribution.enabled` | `ActionAttribution.allowed` | -| `PermissionTopology` / `options.topology` | removed; `options.hierarchy` + `options.entityActions` | -| `AncestorScope` / `filter.ancestorScopes` | `IntermediateScope` / `filter.intermediateScopes` | -| `CollectionReadFilter.subChannelIds` | `homeChannelIds` | -| `requested.subChannelId(s)` | `requested.homeChannelId(s)` | -| scope slices' `subChannelIds` | `channelIds` (level given by the slice's `channelType`) | - -Config DSL (`shared/config/permissions-config.ts`): the callback receives `({ entityType, channels })` -instead of `({ subject, contexts })`; branch on `entityType`, declare cells via -`channels..({ ... })`. Files: `shared/src/permissions/permission-manager/` -> -`shared/src/permissions/engine/`; `check-permission.ts` -> `check-access.ts` (shared and backend); -backend `permissions/actor.ts` -> `permissions/access.ts`; `access-policies.ts` -> -`policy-matrix.ts`; `engine/topology.ts` + `engine/resolve-topology.ts` -> -`engine/resolve-hierarchy.ts` (`HierarchyOverrides`, `resolveHierarchy`); fixtures `wideTopology` / -`deepTopology` -> `wideOverrides` / `deepOverrides`. - -## Blast radius - -Sync-breaking at the type and config level for every app: `shared/config/permissions-config.ts` -uses the renamed DSL, and code importing renamed symbols stops compiling. No wire-shape change, no -`clientCacheVersion` bump, no lens, no database change; decision logic untouched. - -## Run - -No script; word-boundary symbol swaps, grep-guided: - -```sh -grep -rnE "AccessPolic|accessPolicies|PermissionValue|ActionPermissionState|resolvePermission|isUnconditionalPermission|PermissionMembership|isAllowed|PermissionTopology|topology|subChannel|ancestorScopes|subject, contexts|permission-manager|check-permission" --include="*.ts" --include="*.tsx" backend frontend/src shared yjs/src -``` - -## Manual steps - -1. Apply the table's symbol renames (word-boundary; skip unrelated `isAllowed`/`enabled` outside - permission code). -2. `configurePermissions` callback: `({ subject, contexts })` -> `({ entityType, channels })`, - `switch (subject.name)` -> `switch (entityType)`. -3. `options.topology`/`{ topology: { hierarchy: h } }` -> `{ hierarchy: h }` (plus `entityActions` - if overridden). -4. `git mv` any app-specific imports of the renamed files (`#/permissions/actor` -> - `#/permissions/access`, `shared/src/permissions/permission-manager/*` -> `.../engine/*`). -5. Collection-scope consumers: rename filter fields per the table (top-level `subChannelIds` -> - `homeChannelIds`; inside scope slices -> `channelIds`). - -## Verify - -```sh -pnpm sdk -pnpm check -pnpm --filter shared exec vitest run src/permissions src/testing -pnpm --filter backend exec vitest run src/permissions -``` diff --git a/cella/migrations/20260723T1237-typed-nullable-user-and-tree-rows/README.md b/cella/migrations/20260723T1237-typed-nullable-user-and-tree-rows/README.md deleted file mode 100644 index b6a6a5cc2..000000000 --- a/cella/migrations/20260723T1237-typed-nullable-user-and-tree-rows/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Nullable store user and TreeItem-constrained tree rows - -## What & why - -`useUserStore().user` is `MeUser | null`: `frontend/src/modules/user/user-store.ts` seeded it with -`null as unknown as MeUser` (36 unguarded upstream reads threw while signed out: before sign-in, -after `teardownUserState(false)`, on public routes). Authenticated code -uses `useCurrentUser(): MeUser` (components) or `getCurrentUser(): MeUser` (imperative); both throw -while signed out. `useTreeRows` requires `T extends TreeItem`; `buildTree` -(`frontend/src/modules/common/data-table/tree/build-tree.ts`) is overloaded so a non-`TreeItem` row -must supply `getId`, `getParentId`, `getDisplayOrder` (no more silently flat trees). Also: -`getEntityPolicies` / `getPolicyPermissions` take `string`; `actorFrom` / `accessFrom` take a -structural `AccessContext`; `actorFrom` returns `{ anonymous: true }` without a `userId`. - -## Blast radius - -Sync-breaking at the type level; no wire-shape change, no `clientCacheVersion` bump, no database -change. Affected if the app reads `useUserStore().user` (almost certainly) or calls `useTreeRows` -with rows lacking `id`/`parentId`/`displayOrder`. `pnpm check` lists every unguarded read. - -## Run - -No script; scope with: - -```sh -grep -rn "useUserStore" --include="*.ts" --include="*.tsx" frontend/src -grep -rn "useTreeRows\|buildTree" --include="*.ts" --include="*.tsx" frontend/src -``` - -## Manual steps - -1. Run `pnpm check` and collect the `'user' is possibly 'null'` errors. -2. Authenticated-route code: components replace `const { user } = useUserStore()` (or - `useUserStore((s) => s.user)`) with `const user = useCurrentUser()`; imperative code replaces - `useUserStore.getState().user` with `getCurrentUser()`. Public routes, sign-out paths, and store - subscribers keep `useUserStore().user` and handle `null` (upstream: `unsubscribed-page.tsx`, the - Gleap subscriber). -3. Import `useCurrentUser` / `getCurrentUser` from `~/modules/user/user-store`; drop unreferenced - `useUserStore` imports. -4. `useUserStore.setState({ user: ... })` for a signed-out state passes `null` (no - `as unknown as MeUser` cast). -5. For each `useTreeRows` call, confirm `T` has `id: string`, `parentId: string | null`, and - `displayOrder: number`; otherwise add the fields or call `buildTree` directly with `getId`, - `getParentId`, `getDisplayOrder`. -6. Delete app-defined default accessors that cast to `TreeItem`; import `treeItemAccessors` from - `~/modules/common/data-table/tree/build-tree`. - -## Verify - -```sh -pnpm check -pnpm test --filter frontend -``` - -Then load a public route, sign out and back in: a missed `useCurrentUser()` throws -`[userStore] Read the signed-in user while signed out`, naming the file in the stack trace. diff --git a/cella/migrations/20260723T1311-batch-presigned-urls/README.md b/cella/migrations/20260723T1311-batch-presigned-urls/README.md deleted file mode 100644 index 317117f61..000000000 --- a/cella/migrations/20260723T1311-batch-presigned-urls/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Batch presigned URLs replace the single presign endpoint - -## What & why - -`GET /{tenantId}/{organizationId}/attachments/presigned-url` (`getPresignedUrl`) is replaced by -`POST /{tenantId}/{organizationId}/attachments/presigned-urls` (`getPresignedUrls`): up to 50 -`{ attachmentId, variant }` items per call (one RLS read via `findAttachmentsByIds`, one -`checkAccessBatch` pass, N local signatures); missing and denied ids merge into `rejectedIds`, -closing the 403-vs-404 existence oracle. Frontend `getPrivateFileUrlById` delegates to the -coalescer in `frontend/src/modules/attachment/presign-batch.ts` (batches concurrent requests, -dedupes in-flight pairs, memoizes signed URLs for an hour). Removed: `getPresignedUrlOp`, -`findAttachmentById`, `presignedUrlQuerySchema`; superseded by `getPresignedUrlsOp`, -`findAttachmentsByIds`, `presignedUrlsBodySchema` + `presignedUrlItemSchema`. - -## Blast radius - -Sync-breaking for app code calling the removed SDK function `getPresignedUrl` or the removed -backend symbols. Bumps `clientCacheVersion` (`v6-batch-presigned-urls`). No database change. Apps -whose presign sites are only synced cella files (`file-url.ts`, `resolve-url.ts`, -`download-service.ts`, attachment table/carousel) need nothing beyond the checks. Apps that widened -`findAttachmentById` (or lack the `deletedAt` filter) adopt `findAttachmentsByIds`, which keeps the -`isNull(deletedAt)` guard. - -## Run - -No script; these greps find every site: - -```sh -grep -rn "getPresignedUrl\b\|getPresignedUrlOp\|findAttachmentById\|presignedUrlQuerySchema" \ - --include="*.ts" --include="*.tsx" backend/src frontend/src -``` - -## Manual steps - -1. Frontend `getPresignedUrl({ path, query })` -> `getPresignedUrls({ path, body: { items: [{ attachmentId, variant }] } })`, - or preferably `getPrivateFileUrlById` / `getCloudUrl` to inherit batching and memo. -2. Backend imports of `getPresignedUrlOp` / `findAttachmentById` / `presignedUrlQuerySchema` -> the - batch twins above; `selectVariantKey` stays private to the batch op. -3. A denied or missing id is an entry in `rejectedIds` on a 200, not HTTP 403/404; catch - `PresignRejectedError` from `~/modules/attachment/presign-batch` (permanent; do not retry). -4. Bump `clientCacheVersion` if your app overrides the default config. - -## Verify - -```sh -pnpm sdk -pnpm check -pnpm test -``` diff --git a/cella/migrations/20260723T1705-media-attachment-ref/README.md b/cella/migrations/20260723T1705-media-attachment-ref/README.md deleted file mode 100644 index 70a3d4991..000000000 --- a/cella/migrations/20260723T1705-media-attachment-ref/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Media blocks carry an attachment reference - -## What & why - -Media blocks (`image`, `video`, `audio`, `file`) gain an `attachmentId` prop through -`withAttachmentRef` (`shared/utils/blocknote-schema-configs`), applied to both schemas that -round-trip a shared Y.Doc: `frontend/src/modules/common/blocknote/blocknote-config.ts` and -`yjs/src/lib/blocknote-seed.ts`. `UppyFilePanel` stamps it on upload alongside `url`, so -references never parse a URL. Also: `checklistGroupConfig` and the -unreferenced `checklist-group-block.tsx` / `checklist-group-render.tsx` are deleted (never -registered in any schema); the four `mediaBlockTypes` copies consolidate onto -`shared/utils/text-from-block` (`shared/blocknote`); new `shared/utils/derive-description-core.ts` -holds the shared block walk (checkbox and media counts, attachment-id collection, summary source). - -## Blast radius - -Sync-breaking only for apps importing `checklistGroupConfig` / `checklistGroupBlock` / -`getChecklistGroupSlashItem` or keeping their own media block specs. No database or wire-shape -change, no `clientCacheVersion` bump (BlockNote fills the prop with `''` on older content). Silent -failure: extending only one of the two schemas drops the prop on every Y.Doc round-trip. - -## Run - -No script, manual. - -## Manual steps - -1. If your app defines its own editor schema, wrap the four media specs in both schemas: - `audio/file/image/video: withAttachmentRef(defaultBlockSpecs.)` (the yjs relay does the - same on `defaultBlockSpecs`). -2. If your app stamps uploaded media into blocks outside `UppyFilePanel`, add - `attachmentId: attachment.id` to the props it writes. -3. Delete local `checklistGroup` wiring (`checklistGroupConfig` import, slash-menu item, schema - entry); an app that registered the block keeps its own copy of the config. -4. Replace local `new Set(['image', 'video', 'audio', 'file'])` with - `import { mediaBlockTypes } from 'shared/blocknote'`. -5. Optional: derive descriptions via `countDescriptionBlocks` / `findSummarySource` / - `blockPlainText` from `shared/utils/derive-description-core`. - -## Verify - -```sh -pnpm check -pnpm --filter yjs-worker exec vitest run src/tests/blocknote-seed.test.ts # proves both schemas agree: attachmentId survives blocks -> Y.Doc -> blocks -pnpm --filter shared exec vitest run -``` diff --git a/cella/migrations/20260723T2257-deploy-engine-waves/README.md b/cella/migrations/20260723T2257-deploy-engine-waves/README.md deleted file mode 100644 index 628040a31..000000000 --- a/cella/migrations/20260723T2257-deploy-engine-waves/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# Deploy engine: waved rollout, internal routes, one deploy command - -## What & why - -Two-wave rollout (`infra/tasks/rollout.ts`): wave 1 provisions and cuts over the primary service -(backend); wave 2 provisions every remaining service's generation in one stack update and cuts -them over concurrently; one final update reaps displaced generations. One command, -`pnpm --filter infra deploy` (`infra/tasks/deploy.ts`), replaces the -`pulumi`/`roll-backend`/`roll-rest`/`publish-frontend`/`smoke-tests` jobs in -`.github/workflows/deploy.yml` with one `deploy` job. Bindings that baked a generation IP -(`@{backend.privateIp}`) use the LB's ACL-guarded internal route: registry `internalRoute: true` -(backend), cdc binding `ws://@{backend.internalHost}:@{backend.internalPort}/internal/cdc`. -Sequential per-service `pulumi up` pairs blew the 20-minute job timeout (raak run 30040238120). - -## Blast radius - -Sync-breaking for every app (all sync `.github/workflows/deploy.yml` and `infra/`). No -`clientCacheVersion` bump, no lens, no database or wire change. App-added `@{.privateIp}` -bindings keep working in the default monolith topology but must move to `internalRoute` + -`internalHost`/`internalPort` before adopting the micro stack topology. - -## Run - -No script, manual. - -## Manual steps - -1. `infra/config/services.config.ts`: add `internalRoute: true` to the primary (backend) service; - set cdc's binding to - `API_WS_URL: 'ws://@{backend.internalHost}:@{backend.internalPort}/internal/cdc'`; same for any - app service others dial by `@{...privateIp}`. -2. `pnpm --filter infra compose:generate`. -3. GitHub branch protection (and deployment dashboards): `roll-backend`, `roll-rest`, `smoke-tests` - no longer exist; require `deploy`. -4. The first deploy replaces the cdc VM (its binding changed its genId) and creates the internal LB - pool/frontend/ACLs; deploy staging before production. -5. Optional, default off: `INFRA_PULUMI_DRIVER=automation` (Automation API driver), - `INFRA_STACK_TOPOLOGY=micro` (per-service generation stacks); see `infra/README.md` (Stacks, - Vocabulary). - -## Verify - -```sh -pnpm --filter infra compose:check -pnpm --filter infra exec vitest run -pnpm check -``` - -Then one staging deploy: wave logs show `[rollout] wave 1/2`, -`curl -m 5 http://:1/health` from outside the VPC is denied, cdc reconnects and -CDC events flow end-to-end. diff --git a/cella/migrations/20260729T0922-app-product-mocks/README.md b/cella/migrations/20260729T0922-app-product-mocks/README.md deleted file mode 100644 index 36993cb62..000000000 --- a/cella/migrations/20260729T0922-app-product-mocks/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Rename the app product mock registry - -## What & why - -The app-owned product mock registry uses app vocabulary in its filename and export: the shared -registry imports `appProductMocks` from `backend/src/mocks/app-product-mocks.ts`. - -## Blast radius - -Sync-breaking for apps that customized the previous pinned mock registry. No wire-shape, -client-cache, or database change. - -## Run - -No script, manual. - -## Manual steps - -1. Rename `backend/src/mocks/fork-product-mocks.ts` to `backend/src/mocks/app-product-mocks.ts`. -2. Rename the `forkProductMocks` export to `appProductMocks` and update imports. -3. Update the pinned path in `cella.config.ts`. - -## Verify - -```sh -pnpm check -``` diff --git a/cella/migrations/20260730T0425-boot-image-rename/README.md b/cella/migrations/20260730T0425-boot-image-rename/README.md deleted file mode 100644 index 0e2db2936..000000000 --- a/cella/migrations/20260730T0425-boot-image-rename/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Rename the boot runner image (cella-boot to infra-boot) - -## What & why - -The boot runner image (the container every VM runs at first boot) is renamed `cella-boot` -> -`infra-boot`, single-sourced as `BOOT_IMAGE_NAME` in `infra/lib/scaleway/boot-image.ts` (used by -`infra/tasks/build-images.ts`, `.github/workflows/deploy-pipeline.yml`, `infra/resources/compute.ts`). -Generations pin the image by name plus sha and a digest is only pullable from its own repository, -so `resolveBootImage` tries `infra-boot`, then on 404 each of `LEGACY_BOOT_IMAGE_NAMES` -(`cella-boot`), threading the resolved name into cloud-init; a pre-existing generation whose image -is gone degrades to an unpinned tag with a warning (cloud-init has `ignoreChanges`); a newly -rolling generation still needs a pinnable image. - -## Blast radius - -Sync-breaking for every app (all sync `infra/` and `.github/workflows/`), at deploy time only. No -`clientCacheVersion` bump, no lens, no wire-shape or database change, no app code edits. The first -post-pull deploy pushes `infra-boot:` and resolves pre-rename generations via the legacy -fallback (or degrades them). See `cella/DEPLOYMENT.md`, "Updating the boot runner". - -## Run - -No script, manual. - -## Manual steps - -1. No app code changes. -2. Deploy staging before production; a one-time warning that a pre-existing generation resolved - under the legacy name or degraded to an unpinned tag is expected on the first deploy. -3. Optional cleanup, once no environment runs a pre-rename generation (one successful deploy per - environment): remove `'cella-boot'` from `LEGACY_BOOT_IMAGE_NAMES` in - `infra/lib/scaleway/boot-image.ts`. - -## Verify - -```sh -pnpm --filter infra exec vitest run -pnpm check -``` - -Then one staging deploy: `build-boot-image` pushes `infra-boot:`, the rollout cuts over -healthy, and `curl -sI https:/// | grep -i x-app-version` reports the deployed sha. diff --git a/cella/migrations/20260730T0624-attachment-keys-map/README.md b/cella/migrations/20260730T0624-attachment-keys-map/README.md deleted file mode 100644 index 7075b88b0..000000000 --- a/cella/migrations/20260730T0624-attachment-keys-map/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Attachment variant keys collapse to a single jsonb map - -## What & why - -The `attachments` columns `originalKey`, `convertedKey`, `thumbnailKey`, `thumbnailTinyKey` -collapse into one `keys` jsonb map (`AttachmentKeys`: `{ original, preview?, thumbnail?, converted? }`); -a lookup is `attachment.keys[variant]` and the variant set lives in `attachmentKeysSchema` -(`backend/src/modules/attachment/attachment-schema.ts`). Variants renamed: mid-size `thumbnail` -> -`preview`, grid-cell `thumbnail-tiny` -> `thumbnail`; `attachmentVariantSchema` and the frontend -`BlobVariant` are `original | preview | thumbnail | converted`. - -## Blast radius - -Sync-breaking. Wire: four `*Key` fields removed, `keys` added, variant enum renamed; -`clientCacheVersion` bumped to `v8-attachment-keys`. DB: four `*_key` columns dropped, -`keys jsonb NOT NULL DEFAULT '{}'` added with a backfill. Every reader of the old `*Key` fields or -`thumbnail-tiny` is affected. App-owned `taskId`/`projectId` homing columns are untouched. - -## Run - -No script, manual. - -## Manual steps - -1. `attachment-db.ts`: replace the `originalKey`/`convertedKey`/`thumbnailKey`/`thumbnailTinyKey` - `varchar` columns with `keys: jsonb().$type().notNull().default({})`; import - `AttachmentKeys` (type-only) from `attachment-schema`. -2. `attachment-schema.ts`: add `attachmentKeysSchema`/`AttachmentKeys`; pass `{ keys: attachmentKeysSchema }` - as the refinement to `createInsertSchema`/`createSelectSchema`; one `keys` field doc replaces the - four `*Key` docs; pick `keys` (not the `*Key` fields) in the create body and require it via - `.extend({ keys: attachmentKeysSchema })`; `attachmentVariantSchema` becomes - `z.enum(['original', 'preview', 'thumbnail', 'converted'])`; drop the `originalKey` update op. -3. Remap every reader (`thumbnail` -> `preview`, `thumbnail-tiny` -> `thumbnail`): `get-presigned-urls.ts` - (`keys[variant] ?? keys.original`), `create-attachments.ts` (drop the per-key coalesce), mocks, - seeds, security tests, and on the frontend `file-url.ts`, `helpers/resolve-url.ts`, - `helpers/parse-uploaded.ts`, `hooks/use-attachment-url.ts`, `hooks/use-resolved-attachments.ts`, - `offline/attachments-db.ts` (`BlobVariant`), `offline/download-queue.ts`, `offline/download-service.ts`, - `offline/storage-service.ts`, `table/attachment-cells.tsx`, and the uppy upload panel. -4. `parse-uploaded.ts`: keep your Transloadit step names but map them to the new variants - (`thumb_image_tiny` -> `keys.thumbnail`, other `thumb_*` -> `keys.preview`, `converted_*` -> `keys.converted`); - if you also rename the Transloadit steps, update the server-side template too. -5. DB migration: run `pnpm generate`, answer "create column" for `keys` (not a rename), then edit the - generated `migration.sql` to backfill before the drops: - ```sql - UPDATE "attachments" SET "keys" = jsonb_strip_nulls(jsonb_build_object( - 'original', "original_key", 'converted', "converted_key", - 'preview', "thumbnail_key", 'thumbnail', "thumbnail_tiny_key")); - ``` -6. Bump `clientCacheVersion` in `shared/config/config.default.ts`. - -## Verify - -```sh -pnpm generate -pnpm sdk -pnpm check -``` diff --git a/cella/migrations/20260730T0858-frontend-module-placements/README.md b/cella/migrations/20260730T0858-frontend-module-placements/README.md deleted file mode 100644 index 575e30a36..000000000 --- a/cella/migrations/20260730T0858-frontend-module-placements/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Frontend module registry and UI placements - -## What & why - -Frontend modules register through `defineFrontendModule` (`frontend/src/lib/module.ts`), mirroring -`defineBackendModule`; `frontend/src/modules.ts` (imported by `main.tsx`) glob-imports every -`frontend/src/modules/*/*-module.ts(x)` before first render. UI placements -(`frontend/src/lib/placements.ts`): a tool is a component placed into a slot; the consumer is the -hosting page. Modules declare `tools`; consumers read `getTools(slot)` (typed by `SlotContexts`) -and `resolvePlacementList`. Slots `` `${channelType}.settings` `` and `account.settings`; `render` -returns the full card and lazy-loads heavy UI; render context `ChannelSettingsEntityByType`, -widened via `declare module '~/lib/placements'`. Built-in settings sections (organization -general/details/danger zone; account general/sessions/authentication/danger zone) are tools; the -pages are pure consumers. Gating: `requires` (a grant) and `visibleTo` (context-role pairs such as -`'organization.admin'`, `'course.staff'`, matched over the ancestor chain by `heldContextRoles`, -hierarchy-validated); UI visibility only, never authorization. Arrangement layers: manifest -defaults, pinned `frontend/src/placement-config.ts` overrides, then the row's `toolsConfig` jsonb -(new `organizations` column; per-slot `order`/`hidden`/`settings`; admin "Tools" card; fail-closed -reconciliation; `locked` tools ignore hiding). Tabs: `resolveNavTabs` merges `staticData.navTab` -child routes (typed `PlacementDescriptor`) with the `staticData.tabsSlot` slot's `.tabs` tools via -a `$tool` host route (`SlotTabHost`); `organization.tabs` and `system.tabs` ship one; default tab -is the first visible (`defaultNavTabPath`). `nav-buttons.tsx` special-cases moved to `navItems` -`iconSlot`/`badgeSlot` in the pinned `frontend/src/nav-config.tsx`. - -## Blast radius - -Sync-breaking for every app, with a database change: `organizations.tools_config` jsonb (not null, -default `{}`); run `pnpm generate` after sync (`backend/drizzle` is app-owned). Organization -response and update body gain optional `toolsConfig` (additive, no `clientCacheVersion` bump). The -pinned `organizationSettingsSections` file and `OrganizationSettingsSection` type are removed. -Territory scanning of `defineFrontendModule`/`defineBackendModule` needs `@cellajs/cli` with the -widened call matcher. - -## Run - -No script, manual. - -## Manual steps - -1. Convert every app-owned frontend `*-module.ts` (raak: label, marketing, project, task, workspace; - projectcampus: its app modules) from `registerModule` (shared/module-registry) to - `defineFrontendModule` (`~/lib/module`); `*-module.tsx` when it declares JSX tools. Backend - modules keep `defineBackendModule`. -2. Move settings-section extensions into a module's `tools`; raak example, - `frontend/src/modules/label/label-module.tsx`: - - ```tsx - defineFrontendModule({ - name: 'labels', - // ...existing metadata... - tools: [ - { - slot: 'organization.settings', - id: 'update-primary-labels', - label: 'c:primary_labels', - visibleTo: ['organization.admin'], - render: (organization) => , - }, - ], - }); - ``` - - Wrap the form in `ToolCard` (`~/modules/common/tool-card`), lazy-loaded via `lazyNamed`; - `organization` is `EnrichedOrganization` via `ChannelSettingsEntityByType`. -3. Delete `frontend/src/modules/organization/organization-settings-sections.tsx` and its pinned - entry in `cella.config.ts`; add `frontend/src/placement-config.ts` to `overrides.pinned`; replace - `OrganizationSettingsSection` imports with the `Tool` types from `~/lib/placements`. -4. Run `pnpm generate` to pick up the `tools_config` column. -5. Deep hierarchies (projectcampus), one settings slot per channel entity: - - Augment the render-context map once in an app-owned module: - `declare module '~/lib/placements' { interface ChannelEntityByType { course: EnrichedCourse; courseSection: EnrichedCourseSection; project: EnrichedProject } }` - (types both the settings and tabs slots). - - In the channel's module file: - `tools: channelSettingsTools({ channelType: 'course', resource: 'c:course', toolsCardVisibleTo: ['course.staff', 'organization.admin'], renderGeneral, renderDetails?, renderTools, renderDeleteDialog })` - (`~/modules/entities/channel-settings-tools`); `renderTools` wires `ToolsArrangementCard` to - the channel's update mutation (four thin wrappers in - `frontend/src/modules/organization/settings-tools.tsx`). - - Settings route: `` (`~/modules/entities/channel-settings-page`). - - Persist per-channel arrangement by threading `toolsConfig` through the channel's update schema - and query like `setupConfig`. (SUPERSEDED in part by `20260817T1055-tools-config-channel-columns`: - the jsonb column now comes from `channelColumns()`; do NOT hand-copy it.) - - `visibleTo` pairs may name any hierarchy role (`'course.staff'`, `'project.owner'`); elevation - is explicit, so a tool org admins should see must list `'organization.admin'`; repeated - audiences go in app-owned preset constants. - - Standalone cards use `ToolCard` (`~/modules/common/tool-card`). -6. In the pinned `frontend/src/nav-config.tsx`, copy the template's `iconSlot`/`badgeSlot` entries - (account avatar, home loader, menu unseen badge) or they disappear. -7. Account settings page or system panel customizations made in template files move to - `account.settings` tools, `routes/_app/system/*.tsx` tab routes, or `placementOverrides` entries. - -## Verify - -```sh -pnpm generate -pnpm sdk -pnpm check -pnpm test -``` diff --git a/cella/migrations/20260730T1009-owned-host-embedding/README.md b/cella/migrations/20260730T1009-owned-host-embedding/README.md deleted file mode 100644 index fb2778208..000000000 --- a/cella/migrations/20260730T1009-owned-host-embedding/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Product host FKs move to owned embeddings - -## What & why - -Child-side host FKs for product-to-product ownership (a nullable `Id` column such as -`attachments.taskId`) are deprecated in favor of a host-side id array registered in -`appConfig.productEmbeddings` with `lifecycle: 'owned'` (a child-side FK is invisible to sync -views, CDC cleanup, propagation hints, counters, client cache patching). The template ships the -machinery: the `lifecycle: 'shared' | 'owned'` discriminant (`shared/src/config-builder/types.ts`), -the CDC owned-embedding GC (`cdc/src/utils/owned-embedding-gc.ts`, dispatched in -`cdc/src/pipeline/process-events.ts`), `withAttachmentRef` (`shared/utils/blocknote-schema-configs`, -see `20260723T1705-media-attachment-ref`), `shared/utils/derive-description-core`, and id-array -patching in `frontend/src/query/realtime/propagation.ts`. An `owned` entry activates the GC. - -## Blast radius - -Template-side: no DB, wire, or `clientCacheVersion` change. Apps without a product-to-product host -FK are unaffected. Apps with one (`taskId`-style) flip on their own schedule, with their own DB -migration and cache bump or lens. - -## Run - -No script, manual. - -## Manual steps - -1. Add the host array to the HOST product's table (app-owned): `uuid().array().notNull()`, default - `'{}'::uuid[]`, plus a GIN index (the GC refcount check reads it). -2. Register the embedding: - `{ embeddedProduct: '', hostProduct: '', hostColumn: '', lifecycle: 'owned' }`. -3. Derive the array from the app's linkage source; for description-hosted media, collect - `attachmentId` block props via `countDescriptionBlocks` on create/update and narrow to live - in-org child rows inside the write transaction. -4. In the same drizzle migration: backfill the host arrays from the legacy FK (`array_agg` over the - child table grouped by the FK, excluding soft-deleted rows), then drop the FK column and index. -5. Delete the FK-based lifecycle code (delete-cascade query, `onMutation` handlers soft-deleting - children in-request); the CDC GC soft-deletes orphans asynchronously on array shrink or host - soft-delete (tombstones arrive at CDC latency). -6. Remove the FK from the child's create body schema, create operation, mocks, and any client upload - flow that stamped it. -7. Host gains the array field and child loses the FK: bump `clientCacheVersion` (or ship a lens) in - the same PR, titled `feat!:`. - -## Verify - -```sh -pnpm generate -pnpm sdk -pnpm check -pnpm --filter cdc-worker exec vitest run src/tests/owned-embedding-gc.test.ts -``` - -Then at runtime: removing an embedded reference (or deleting the host) tombstones the orphaned child -via CDC within seconds; never-referenced child rows stay untouched. diff --git a/cella/migrations/20260730T1258-cella-config-into-cella-folder/README.md b/cella/migrations/20260730T1258-cella-config-into-cella-folder/README.md deleted file mode 100644 index de7360da6..000000000 --- a/cella/migrations/20260730T1258-cella-config-into-cella-folder/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Move cella sync files into the `cella/` folder - -## What & why - -The three cella-owned control files move from the repo root into `cella/`: `cella.config.ts` -> -`cella/cella.config.ts`, `cella.manifest.json` -> `cella/cella.manifest.json`, -`cella.migrations.json` -> `cella/cella.migrations.json`. `@cellajs/cli` discovers each at its -`cella/` path (config loader, `MANIFEST_FILE`, the managed-file/ignore match) and -`cella/migrations/run.ts` reads/writes the applied-set from `cella/cella.migrations.json`. App -`localPath` values still resolve against the repo root; config contents are untouched. - -## Blast radius - -Sync-breaking for every app; no `clientCacheVersion` bump, no database change. The CLI looks only in -`cella/`, so `cella` commands fail with "config file not found" until the `git mv` below. Requires -the `@cellajs/cli` release with `cella/`-folder discovery. `run.ts` keeps a read-only fallback to -the root `cella.migrations.json`, so the pending plan stays correct in between. - -## Run - -No script, manual. - -## Manual steps - -From the repo root, after `pnpm install` brought in the `@cellajs/cli` release with `cella/` -discovery: - -1. `git mv cella.config.ts cella/cella.config.ts` -2. `git mv cella.manifest.json cella/cella.manifest.json` (skip if your app has no manifest yet) -3. `git mv cella.migrations.json cella/cella.migrations.json` (skip if your app has no applied-set yet) -4. If your app pins any of these paths in `overrides` (unusual; `cella.config.ts` is auto-managed), - update them to the new `cella/…` paths. - -## Verify - -```sh -pnpm exec tsx cella/migrations/run.ts status # applied/pending reads the moved file -pnpm cella # CLI loads cella/cella.config.ts -pnpm check -``` diff --git a/cella/migrations/20260731T0844-iam-model-v2/README.md b/cella/migrations/20260731T0844-iam-model-v2/README.md deleted file mode 100644 index b18e01620..000000000 --- a/cella/migrations/20260731T0844-iam-model-v2/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# IAM model v2: per-mode/per-service principals, per-deploy keys, S3 key retirement - -> **2026-08 update:** the legacy (v1) code paths, the `infra:iamModel` flag, and the -> **Migrate IAM model** CLI action are removed (v2 only). A stack still on v1 must run the steps -> below from a checkout before cella's `refactor/iam-v2-only` change, then sync past it. - -## What & why - -Credential model rebuilt (cella `refactor/iam-rewrite`, P1-P4): IAM principals per app x mode -(`--ci-deploy`, `--vm-`, `--admin`, -`--boot`) in one IAM group; the keyless `-operator` app becomes an admin app with -a real key; per-service Secret Manager folders with resource-level IAM conditions; CI mints fresh -service keys every deploy (conditioned `IAMApplicationManager`); VMs get their key via a -single-access handoff bundle (tamper alarm). The `s3` managed key and `s3AccessKeyId` / -`s3AccessKeySecret` runtime secrets are removed: the backend signs S3 with its own service key, -exported by the boot runner as `S3_ACCESS_KEY_ID`/`S3_ACCESS_KEY_SECRET` (names unchanged, no -backend edit). - -## Blast radius - -Infra-only; not sync-breaking, no `clientCacheVersion` bump, no database change. Every bootstrapped -Scaleway stack is affected operationally: existing stacks keep deploying on the legacy model (name -fallbacks) but should migrate promptly. Customized `infra/config/managed-keys.config.ts`, -`runtime-secrets.config.ts`, or `services.config.ts` merge by hand (s3 entries gone, backend -service gains `s3Access: true`); apps that never customized infra are unaffected until they migrate. - -## Run - -No script, manual (operational migration). - -## Manual steps - -1. Pull the sync; resolve `infra/config/*.config.ts` conflicts (drop s3 entries, keep - `s3Access: true` on the backend service). -2. Per environment (staging first): `pnpm infra` -> **Manage keys & secrets** -> **Migrate IAM - model** -> *Migrate to v2* (needs a fresh console bootstrap key). -3. Commit the `infra/Pulumi..yaml` change (`infra:iamModel: v2`). -4. Run **Stack setup -> Apply infra change** with the bootstrap key (per-service IAM policies + - secret folder moves; CI cannot write IAM). -5. Deploy (CI or local); the first v2 deploy re-rolls all VMs onto the handoff flow. -6. After the deploy is verified green: **Migrate IAM model** -> *Clean up legacy principals*. -7. Revoke the bootstrap key. - -## Verify - -- `pnpm --filter infra test`, `pnpm check`. -- Deploy step "Verify VM IAM grants" passes (per-app sets + exact path conditions). -- Upload an attachment and open a presigned URL. -- `pnpm --filter infra status` shows the admin app; the Scaleway console shows the `-` - group containing every app. diff --git a/cella/migrations/20260804T1641-headless-settings-placements/README.md b/cella/migrations/20260804T1641-headless-settings-placements/README.md deleted file mode 100644 index c221081eb..000000000 --- a/cella/migrations/20260804T1641-headless-settings-placements/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Headless settings placements: sections hook, descriptor bases, consumers as presentation - -## What & why - -Settings-slot resolution moves into `useChannelSettingsSections(entity)` -(`frontend/src/modules/entities/use-channel-settings-sections.ts`: grants from `can`, held -context-role pairs, app overrides, stored `toolsConfig`), ending raak#99-style copies of page -gating; `ChannelSettingsPage` and the new -`ChannelSettingsSheet` (`frontend/src/modules/entities/channel-settings-sheet.tsx`) are -presentation-only maps over it. `channelSettingsTools(...)` is removed; -`frontend/src/modules/entities/channel-settings-tools.tsx` exports spreadable `generalToolBase` -(`requires: 'update'`), `detailsToolBase`, `tabsToolBase`, `dangerToolBase(channelType, resource)`, -and `DeleteToolCard`; a module spreads a base plus `slot`, conditions, and a full-card `render`. -`ToolsArrangementCard` is deleted; `TabsArrangementCard` -(`frontend/src/modules/entities/tabs-arrangement-card.tsx`) writes `toolsConfig['.tabs']` -via the channel update mutation, listing `getNavTabCandidates` (`~/modules/common/page/tab-nav`); -stored settings-section arrangement stays honored by `resolvePlacementList` but no UI writes it; -organization settings demos it (`tabsToolBase` + `OrganizationTabsCard`, `settings` navTab -`locked: true`). Tab visibility is presentation only, never authorization (enforce via permissions -or quota restrictions). `getTools` applies default order 50 (`getSlotDescriptors` stays raw, tab -default 0); `getChannelSettingsTools` and `ChannelSettingsToolFor` are removed. Backend -`mergeJsonbShallow(column, value)` (`backend/src/db/utils/jsonb-merge.ts`) replaces inlined -`column || value::jsonb`. Trims: `SlotToolsConfig.settings` removed (stored config is `order` + -`hidden`); `PlacementOverride` narrows to `hidden`/`order`; `heldContextRoles` loses its -entity-less overload (a `visibleTo` on a `system.tabs` tool hides it for everyone). Prefer -`requires` over `visibleTo` (grants inherit down the ancestor chain). Supersedes step 5 of -`20260730T0858-frontend-module-placements`: spread bases, not `channelSettingsTools(...)`. - -## Blast radius - -Sync-breaking for apps calling `channelSettingsTools` or `getChannelSettingsTools`, importing the -organization `settings-tools` forms by old names (`Organization*Form` -> `Organization*Card`), or -setting `requires`/`visibleTo` in `placement-config.ts` overrides (cella and raak ship empty maps). -Wire: the unused `settings` key leaves the `toolsConfig` slot schema (additive). No database -change, no `clientCacheVersion` bump. Apps that never registered settings tools are unaffected. - -raak (raak#99): drop the app copy of `frontend/src/modules/entities/channel-settings-sheet.tsx`; -`project-module.tsx` / `workspace-module.tsx` spread bases instead of `channelSettingsTools({...})` -plus `.map`/`.flatMap` patches (workspace danger: -`{ ...dangerToolBase('workspace', 'c:workspace'), slot: 'workspace.settings', render: ... }`); drop -the `*ToolsCard` wrappers around `ToolsArrangementCard` (optionally `tabsToolBase` + -`TabsArrangementCard` with the channel page's `parentRouteId`, after marking settings tabs -`locked: true`); wrap `settings-tools.tsx` general forms in `ToolCard`; -`updateProject`/`updateWorkspace` use `mergeJsonbShallow`; URL-driven sheet handlers stay app-owned. - -projectcampus (synced at 0.6.1): tab visibility moves to `toolsConfig['course.tabs']` (and -courseSection/project/organization) via `TabsArrangementCard`, replacing the course boolean columns -(`streamEnabled`, `projectsEnabled`, `sectionsEnabled`, `materialsEnabled`) and the hardcoded -`tabIds` filter in `course-page.tsx`; real restrictions go through permissions or quota -restrictions (a fully disabled feature is expressed twice); mark every settings navTab -`locked: true` before shipping the card. - -## Run - -No script, manual. - -## Manual steps - -1. Rewrite settings consumers that copied grants/pairs/`resolvePlacementList` logic as a map over - `useChannelSettingsSections(entity)`; delete app-side sheet copies in favor of - `~/modules/entities/channel-settings-sheet`. -2. In each channel module, replace `tools: channelSettingsTools({...})` with declarations spreading - `generalToolBase` / `detailsToolBase` / `tabsToolBase` / `dangerToolBase(channelType, resource)`; - move the `ToolCard` shells the factory added (general: `unsaved`, id `update-`; - details: id `update--details`; danger: `DeleteToolCard`) into the module's - lazy-loaded settings-tools components. -3. Remove `.map((tool) => ({ ...tool, order: tool.order ?? 50 }))` around `getTools` results; - replace `getChannelSettingsTools(channelType)` with `useChannelSettingsSections(entity)` (render) - or `getSlotDescriptors(slot)` (descriptor-only). -4. Replace inlined jsonb merge fragments in channel update queries with `mergeJsonbShallow` from - `#/db/utils/jsonb-merge`; drop writes to `SlotToolsConfig.settings` and any `requires`/`visibleTo` - keys in `placement-config.ts` overrides. -5. Apps that relied on ungated general forms: `general: { requires: undefined }` is not supported; - set `requires` to a grant every intended viewer holds, or declare a custom general tool without - spreading the base. - -## Verify - -```sh -pnpm check -pnpm test -``` diff --git a/cella/migrations/20260811T0905-ts-import-extensions/README.md b/cella/migrations/20260811T0905-ts-import-extensions/README.md deleted file mode 100644 index e09eef2de..000000000 --- a/cella/migrations/20260811T0905-ts-import-extensions/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Explicit .ts import extensions in the Vite config-load graph - -## What & why - -Vite 8's planned-default `configLoader: 'native'` needs fully-specified ESM imports, and -`frontend/vite.config.ts` loads the whole `shared/` graph at config time. All relative imports in -`shared/` and `frontend/vite/` now carry `.ts` extensions, directory-index imports name the index -file (`'../shared/index.ts'`, `'./src/permissions/index.ts'`), `__dirname` became -`import.meta.dirname` in `frontend/vite.config.ts` and the `frontend/vite/` tests, -`allowImportingTsExtensions` moved to the root `tsconfig.json`, and a `biome.jsonc` override -enforces `correctness/useImportExtensions` over `shared/**`, `frontend/vite/**` and -`frontend/vite.config.ts`. - -## Blast radius - -Not sync-breaking: no wire change, no `clientCacheVersion` bump, no DB. Forks that customized -`shared/` or `frontend/vite/` see conflicts (~90 template files; take upstream, re-run the fixer) -and app-owned files there fail lint until the codemod runs; untouched directories merge clean. - -## Run - -```sh -pnpm biome lint --only=correctness/useImportExtensions --write --unsafe shared frontend/vite frontend/vite.config.ts # apply -pnpm lint:fix # normalize -``` - -## Manual steps - -1. If you customized `frontend/vite.config.ts`, replace remaining `__dirname` with - `import.meta.dirname` and keep the `'../shared/index.ts'` import fully specified. -2. If an app tsconfig does not extend the root `tsconfig.json` but type-checks `shared/` source, - add `"allowImportingTsExtensions": true` (requires `noEmit`). -3. Fix directory-index imports the fixer flags but cannot resolve. - -## Verify - -```sh -pnpm ts -cd frontend && pnpm exec vite --configLoader native # must load under the future default (Node >= 22.18) -pnpm check -``` diff --git a/cella/migrations/20260812T1724-deploy-vocabulary/README.md b/cella/migrations/20260812T1724-deploy-vocabulary/README.md deleted file mode 100644 index 36adcf3e5..000000000 --- a/cella/migrations/20260812T1724-deploy-vocabulary/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Deploy vocabulary: start-first/stop-first, pathPrefix, storeOutputs - -## What & why - -The 2026-08 generation roll adopts Compose/Traefik deploy vocabulary in one batch: -`replacementStrategy` values `'lb-overlap'` to `'start-first'` and `'exclusive'` to `'stop-first'`; -service field `lbPathBegin` to `pathPrefix` (Scaleway `path_begin` stays inside the LB resource -layer); pinned genId fingerprint key `runMigrate` to `runRelease`; flat `db*` stack outputs -(`dbConnectionStringAdmin`, `dbCaCertificate`, ...) to `storeOutputs..` (the -db-exposure/seed CLI reads the primary store's entry). - -## Blast radius - -Infra-only: no wire shape, no DB, no sync break, no clientCacheVersion bump. Fork files: -`infra/config/services.config.ts` plus anything fork-local reading the retired `db*` outputs by -name. The fingerprint rename RE-ROLLS EVERY GENERATION on the first deploy after adoption (full -blue/green replacement, no downtime by design). - -## Run - -No script: manual. - -```sh -sed -i '' "s/'lb-overlap'/'start-first'/g; s/'exclusive'/'stop-first'/g; s/lbPathBegin/pathPrefix/g" infra/config/services.config.ts -``` - -## Manual steps - -1. Grep fork-local scripts for the retired stack outputs (`dbConnectionString`, `dbCaCertificate`, - `dbInstanceId`, `dbHost`, `dbName` as OUTPUTS; `naming.dbName` is unrelated) and switch them to - `pulumi stack output storeOutputs --json` and `..`. -2. Regenerate the compose model: `pnpm --filter infra compose:generate`. - -## Verify - -```sh -pnpm --filter infra exec vitest run -pnpm --filter infra ts -pnpm check -# first deploy after merge rolls all generations (intended): watch one full cutover complete -``` diff --git a/cella/migrations/20260816T0704-dev-port-offsets/README.md b/cella/migrations/20260816T0704-dev-port-offsets/README.md deleted file mode 100644 index aeef10b94..000000000 --- a/cella/migrations/20260816T0704-dev-port-offsets/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Dev service ports become one config knob (`devPorts`) - -## What & why - -Service listen ports were hardcoded in five places (`backend/src/env.ts` PORT, `cdc/src/env.ts` -API_WS_URL + CDC_HEALTH_PORT, `yjs/src/env.ts` YJS_PORT, `mcp/src/mcp-worker.ts`, the Vite proxy -targets in `frontend/vite.config.ts`), so parallel forks collided on :4000 and the first backend -up answered every fork's `/api` proxy. All now default from one `devPorts` block in -`shared/config/config.default.ts` (`api: 4000, cdcHealth: 4001, yjs: 4002, mcp: 4003`); -`PORT`-style env vars still override. - -## Blast radius - -Not sync-breaking, no wire shape, no DB, no clientCacheVersion bump; defaults unchanged, so forks -that never touched ports sync clean. Collision protection needs an offset override in -`config.development.ts`; a custom PORT in `.env` still wins (step 2). - -## Run - -No script: manual. - -## Manual steps - -1. After syncing, add a fork-unique offset to `config.development.ts` (a free decade, paired with - your frontend port): - - ```ts - frontendUrl: 'http://localhost:3020', // ...and the rest of the URL family - devPorts: { api: 4020, cdcHealth: 4021, yjs: 4022, mcp: 4023 }, - ``` - -2. Delete any `PORT=` line from `backend/.env`; a stale env value silently overrides your offset. - -## Verify - -```sh -# with two forks' stacks running concurrently -lsof -nP -iTCP:4000 -iTCP:4020 -sTCP:LISTEN # two listeners -curl -sD - -o /dev/null http://localhost:/api/me -H 'cookie: x=y' | grep -i set-cookie # -development-session-..., not another fork's -``` diff --git a/cella/migrations/20260817T1053-product-view-count-helpers/README.md b/cella/migrations/20260817T1053-product-view-count-helpers/README.md deleted file mode 100644 index c22095e49..000000000 --- a/cella/migrations/20260817T1053-product-view-count-helpers/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Product view-count helpers move to the entities module - -## What & why - -View-count plumbing is now shared next to `product_counters`: `findProductViewCount`, -`productViewCountSelect()` and `productViewCountJoin()` in -`backend/src/modules/entities/entities-queries.ts`, plus `productViewCountSchema` in -`backend/src/modules/entities/entities-schema.ts`. The attachment module's byte-identical copies -(`findAttachmentViewCount`, inline `coalesce(view_count, 0)` select, -`leftJoin(productCountersTable, ...)`, inline `z.number().int().min(0).optional()` field) are -deleted in favor of the helpers. - -## Blast radius - -Sync-breaking for apps importing `findAttachmentViewCount` from -`#/modules/attachment/attachment-queries` (gone; call `findProductViewCount(ctx, { productId })`). -No DB, wire or `clientCacheVersion` change. Apps pinning the attachment module keep working but -should hand-apply the rewrite. - -## Run - -No script: manual. - -## Manual steps - -1. Replace `findAttachmentViewCount(ctx, { entityId })` with - `findProductViewCount(ctx, { productId })` from `#/modules/entities/entities-queries`. -2. In product list queries, replace inline view-count selects/joins with - `viewCount: productViewCountSelect()` and - `.leftJoin(productCountersTable, productViewCountJoin(
    .id))`. -3. In product response schemas, replace inline view-count fields with `productViewCountSchema`. -4. Delete fork-local duplicates of these four pieces (e.g. `findItemViewCount`). - -## Verify - -```sh -pnpm check # product reads still return viewCount, no wire diff in sdk/gen -``` diff --git a/cella/migrations/20260817T1055-tools-config-channel-columns/README.md b/cella/migrations/20260817T1055-tools-config-channel-columns/README.md deleted file mode 100644 index c5aff4224..000000000 --- a/cella/migrations/20260817T1055-tools-config-channel-columns/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# toolsConfig moves into channelColumns() - -## What & why - -The `toolsConfig` jsonb column (sparse, `NOT NULL DEFAULT '{}'`) moves from a hand-declared column -on `organizations` into the shared `channelColumns()` factory -(`backend/src/db/utils/channel-columns.ts`); `mockChannelColumns` -(`backend/src/mocks/mock-entity-columns.ts`) mirrors it. Supersedes the hand-copy instruction in -migration `20260730T0858` step 5. - -## Blast radius - -Sync-breaking, DB-touching, additive: every fork channel table spreading `channelColumns()` gains -`tools_config` on the next `pnpm generate` (dormant at `'{}'` where unused; no backfill; wire field -stays optional). No `clientCacheVersion` bump. **Collision warning:** an app that hand-added -`toolsConfig` per the old step 5 MUST delete its local declaration (step 1); a leftover explicit -key after the `...channelColumns(...)` spread silently wins with no TypeScript error. - -## Run - -No script: manual. - -## Manual steps - -1. Delete any hand-declared `toolsConfig` column from channel table files (organizations plus any - app channel tables that copied it). -2. `pnpm generate`; expect one `ADD COLUMN tools_config jsonb NOT NULL DEFAULT '{}'` per channel - table that lacked it, no diff for tables that already had it. -3. `pnpm --filter backend migrate`. -4. Per channel that persists arrangement, thread the field through response/update schemas and - the update query (`organization-schema.ts` / `organization-queries.ts` are the reference). -5. App mocks that hand-roll channel rows instead of using `mockChannelColumns` add `toolsConfig: {}`. - -## Verify - -```sh -pnpm generate # no-op on a second run -pnpm check -pnpm test -``` diff --git a/cella/migrations/20260817T1447-remove-filter-tab-ids/README.md b/cella/migrations/20260817T1447-remove-filter-tab-ids/README.md deleted file mode 100644 index c4d0876de..000000000 --- a/cella/migrations/20260817T1447-remove-filter-tab-ids/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Remove the filterTabIds allow-list from nav tabs - -## What & why - -The `filterTabIds` prop is deleted from `PageTabNav` and `ResolveNavTabsOptions` -(`frontend/src/modules/common/page/tab-nav.tsx`): `guardNavTabs`/`useNavTabRedirect` never -honored it, so allow-list-hidden tabs stayed URL-reachable and could become the landing tab; the -declarative gates (`grants` + `navTab.requires`, `visibleTo` pairs) go through placement, which -the guards honor. - -## Blast radius - -Sync-breaking for apps passing `filterTabIds` to `PageTabNav` or `resolveNavTabs`: compile error -at sync. No DB or wire change, no `clientCacheVersion` bump. Apps that never used the prop are -unaffected. - -## Run - -No script: manual. - -## Manual steps - -1. Find call sites: `grep -rn "filterTabIds" frontend/src`. -2. Express each gate declaratively BEFORE syncing, or the hidden tabs reappear: permission gates - as `requires: ''` on the route's `staticData.navTab` plus the actor's `grants` passed to - `PageTabNav` (and the route's `guardNavTabs`); role gates on registry tabs as - `visibleTo: ['.']` plus `pairs`; hard removal by dropping the tab's route file or - registry declaration, or `placementOverrides` in the pinned `frontend/src/placement-config.ts`. -3. Delete the `filterTabIds` props from the call sites. - -## Verify - -```sh -pnpm check # then a previously hidden tab URL resolves (gate held) or forwards to the landing tab -``` diff --git a/cella/migrations/20260817T2052-comment-budget/README.md b/cella/migrations/20260817T2052-comment-budget/README.md deleted file mode 100644 index 4b83c395d..000000000 --- a/cella/migrations/20260817T2052-comment-budget/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Comment budget: delete comments that restate their own code - -## What & why - -`shared/scripts/check-frontend-style.ts` loses the `export-description` rule (a JSDoc on every -exported function and `const`); `cella/AGENTS.md` § Style & naming gains a **Comment budget**. -`trim-comment-budget.ts` deletes the docs the old rule bred under two rules: **name-restating** (at -least 60% of the doc's content words appear in the identifier) and **boilerplate** (a curated -template phrase of at most 8 words). Member docs are judged only when the identifier itself -carries the words. Report-only, off by default: **near-empty** (docs of three words or fewer on -members whose type is already named, exempting banners and docs stating a default or condition) -and **duplicate** (identical short docs across three or more files; hoisting candidates only). -Never touched: tool directives -(`biome-ignore`, `ts-expect-error`, `v8 ignore`, `@vite-ignore`, `#__PURE__`), JSDoc with an -`@tag`, `TODO`/`FIXME`/`HACK`, cella `fork:` markers, license headers, `.stories.` files, -generated trees (`sdk/gen`, `backend/drizzle`, `locales`, `*.gen.*`, `routeTree.gen`). - -## Blast radius - -Comments only: not sync-breaking, no `clientCacheVersion` bump, no lens, no DB. Skipping costs -comment-only conflicts in shared files on the next sync. Apps with a customized -`check-frontend-style.ts` must confirm `export-description` is gone before running the codemod, or -the gate keeps reporting violations it can no longer satisfy. - -## Run - -```sh -# report only, all rules -pnpm exec tsx cella/migrations/20260817T2052-comment-budget/trim-comment-budget.ts inventory \ - frontend/src backend/src shared --rules name,boilerplate,duplicate --verbose - -# apply the two safe rules -pnpm exec tsx cella/migrations/20260817T2052-comment-budget/trim-comment-budget.ts rewrite \ - frontend/src backend/src backend/tests backend/emails shared cdc/src yjs/src mcp/src \ - sdk/src bench/src infra packages frontend/storybook frontend/vite -``` - -Add your own product-module roots. Flags: `--name-ratio 0.6` (raise toward -0.8 to delete less), `--max-lines 2` (longer docs are never judged), `--dup-threshold 3`, `--rules`, -`--verbose`. - -## Manual steps - -1. Review the diff: a doc whose only information is a qualifier the identifier omits - (`soft`-delete, `client`-provided) is protected by a non-exhaustive word list; restore what you - needed. -2. `pnpm comments:placement` lists detached long comments; trim them by hand. -3. Run the report-only `duplicate` inventory and hoist each surviving copied note to its shared - abstraction, deleting the copies. - -## Verify - -```sh -pnpm lint:fix # the codemod leaves the removed line's blank space for Biome to close -pnpm frontend:style # export-description must be gone; component-declaration is unrelated -pnpm comments:check -pnpm check -``` diff --git a/cella/migrations/20260817T2052-comment-budget/trim-comment-budget.ts b/cella/migrations/20260817T2052-comment-budget/trim-comment-budget.ts deleted file mode 100644 index 825fccbf4..000000000 --- a/cella/migrations/20260817T2052-comment-budget/trim-comment-budget.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * Codemod: delete comments that restate the code they sit on. - * - * Four independent rules, each reported separately by `inventory` so an app can see what a - * `rewrite` would remove before running it: - * - * name-restating a declaration or member doc whose words are already in the identifier - * ("Mutation hook for creating a new attachment" on `useAttachmentCreateMutation`) - * boilerplate a doc matching a curated template phrase, carrying no local information - * ("Attachment query keys.", "Props for the X component.") - * near-empty a three-word-or-shorter doc on a member whose type is already named - * ("State tracking" on `_state: WebSocketState`). Members typed `unknown`, - * `any`, or a bare `string`/`number`/`boolean` are exempt: those types pin - * down nothing, so even a terse doc may be the member's only specification. - * Section banners and docs stating a default or condition are exempt too. - * OPT-IN: across this repo it found 11 candidates and about a third of those - * still carried a fact the member needed (a `key -> value` shape, a state - * qualifier). Read its inventory and apply by hand; the yield does not - * justify an unattended rewrite. - * duplicate an identical short doc repeated across `--dup-threshold` files or more, - * which is how a module cloned from a sibling inherits its comments. - * REPORT ONLY by default: it cannot tell a copied note from the same local - * pattern recurring (each module's `query.ts` really does exclude `include` - * from its own cache key), and deleting every copy loses the note. Read its - * inventory as a list of hoisting candidates, then move each one by hand. - * - * Judgment cases are out of scope on purpose: a comment that states a constraint the reader - * cannot see stays, and shortening a long one is a human edit. This only removes comments whose - * whole content is recoverable from the identifier next to them. - * - * Protected and never touched: tool directives (biome-ignore, ts-expect-error, v8 ignore, - * vite/webpack magic comments), any JSDoc carrying an `@tag`, TODO/FIXME/HACK, cella `fork:` - * sync markers, license headers, and generated trees. - * - * Usage (from the repo root): - * pnpm exec tsx cella/migrations//trim-comment-budget.ts inventory - * pnpm exec tsx cella/migrations//trim-comment-budget.ts inventory --verbose - * pnpm exec tsx cella/migrations//trim-comment-budget.ts rewrite - * pnpm exec tsx cella/migrations//trim-comment-budget.ts rewrite --rules name,boilerplate - */ - -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { extname, join } from 'node:path'; -import ts from 'typescript'; - -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']); - -/** Trees whose comments are generated, replayed, or translated; never rewritten by hand. */ -const EXCLUDED_PATHS = [ - 'node_modules/', - 'backend/drizzle/', - 'cella/migrations/', - 'locales/', - 'sdk/gen/', - 'dist/', - '.gen.', - 'routeTree.gen', - // Storybook renders the JSDoc above `meta` and above each story export as the description - // shown in the docs UI, so these comments are a published surface, not code commentary. - '.stories.', -]; - -/** A comment carrying any of these is load-bearing for a tool, a reader, or the sync CLI. */ -const PROTECTED_PATTERNS = [ - /\bbiome-ignore\b/, - /\b(?:ts-expect-error|ts-ignore|ts-nocheck|ts-check)\b/, - /\beslint-(?:disable|enable)\b/, - /\boxlint-disable\b/, - /\bprettier-ignore\b/, - /\b(?:v8|c8|istanbul|node) ignore\b/, - /\bknip-ignore\b/, - /\bwebpackChunkName\b/, - /@vite-ignore/, - /@__PURE__|#__PURE__/, - / = { - props: 'prop', properties: 'prop', property: 'prop', component: 'component', components: 'component', - hooks: 'hook', queries: 'query', mutations: 'mutation', options: 'option', ids: 'id', keys: 'key', - creating: 'create', creates: 'create', created: 'create', deleting: 'delete', deletes: 'delete', - deleted: 'delete', updating: 'update', updates: 'update', updated: 'update', renders: 'render', - rendering: 'render', returns: 'return', returning: 'return', fetches: 'fetch', fetching: 'fetch', - fetched: 'fetch', builds: 'build', building: 'build', resolves: 'resolve', resolving: 'resolve', - handles: 'handle', handling: 'handle', validates: 'validate', validating: 'validate', -}; - -const stem = (word: string) => STEMS[word] ?? (word.length > 3 && word.endsWith('s') ? word.slice(0, -1) : word); - -const proseWords = (text: string) => - text - .toLowerCase() - .replace(/`[^`]*`/g, ' ') - .replace(/[^a-z0-9\s]/g, ' ') - .split(/\s+/) - .filter((word) => word && !STOPWORDS.has(word)) - .map(stem); - -/** - * Every word the doc actually contains, including backticked symbols. `proseWords` drops those - * so an identifier mentioned in prose cannot inflate the name-ratio, but the boilerplate guard - * needs the true length: "Helper for `x.config.ts`: typed identity preserving literal keys" is a - * template prefix on a real sentence, not a template phrase. - */ -const rawWordCount = (text: string) => - text - .toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') - .split(/\s+/) - .filter((word) => word && !STOPWORDS.has(word)).length; - -const identifierWords = (name: string) => - name - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .toLowerCase() - .split(/[\s_-]+/) - .filter((word) => word && !STOPWORDS.has(word)) - .map(stem); - -/** Comment body with the delimiters and per-line `*` gutter removed, one entry per prose line. */ -function proseLines(text: string): string[] { - return text - .split(/\r?\n/) - .map((line) => - line - .replace(/^\s*\/\/\/?\s?/, '') - .replace(/^\s*\/\*\*?\s?/, '') - .replace(/^\s*\*\/?\s?/, '') - .replace(/\s*\*\/$/, '') - .trim(), - ) - .filter(Boolean); -} - -function isProtected(text: string): boolean { - return PROTECTED_PATTERNS.some((pattern) => pattern.test(text)); -} - -interface Candidate { - file: string; - pos: number; - end: number; - line: number; - rule: 'name' | 'boilerplate' | 'near-empty' | 'duplicate'; - owner: string; - body: string; - /** Interface, type-literal, or class member: judged by the member rule, not the function one. */ - isMember: boolean; - /** Declared type as written, empty when inferred. A named type can carry the doc's meaning. */ - typeText: string; -} - -/** - * Whether the declared type pins the contract down on its own. `unknown`/`any` say nothing, and a - * bare `string`/`number`/`boolean` says almost nothing, so a doc on those is often the only - * specification the member has. A named type usually carries the meaning by itself. - */ -function typeIsNamed(typeText: string): boolean { - const text = typeText.replace(/\s+/g, ' ').trim(); - if (!text) return false; - if (/^(?:unknown|any|object|\{\s*\})$/.test(text)) return false; - return !/^(?:string|number|boolean|Date)(?:\s*\[\])?(?:\s*\|\s*(?:null|undefined))*$/.test(text); -} - -/** - * A short noun phrase with no verb and no closing period is a section banner grouping members in - * a long interface ("Dimensions props", "Authentication", "Grid and data Props"), not a doc on the - * member it happens to sit above. Deleting one silently reflows the reader's map of the interface, - * so leave banners for a human who can see the whole shape. - */ -const BANNER_PATTERN = /^[\w-]+(?:[\s&]+[\w-]+)*$/; - -/** - * A default or a conditional contract survives at any length: those are exactly the facts a name - * and type cannot carry. "Defaults to window." is three words and is the only place that fact - * appears in the file. - */ -const KEEPS_MEANING_PATTERN = /\bdefaults?\b|\bwhen\b|\bunless\b|\bkeyed by\b|\boverride\b|\bfrom\b|→|->/i; - -/** Declarations whose leading doc this codemod is willing to judge against the identifier. */ -function declaredName(node: ts.Node): string | null { - if (ts.isVariableStatement(node)) { - const declaration = node.declarationList.declarations[0]; - return declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : null; - } - if ( - ts.isFunctionDeclaration(node) || - ts.isClassDeclaration(node) || - ts.isInterfaceDeclaration(node) || - ts.isTypeAliasDeclaration(node) || - ts.isEnumDeclaration(node) || - ts.isMethodDeclaration(node) || - ts.isMethodSignature(node) || - ts.isPropertySignature(node) || - ts.isPropertyDeclaration(node) || - ts.isPropertyAssignment(node) || - ts.isEnumMember(node) - ) { - const name = node.name; - return name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) ? name.text : null; - } - return null; -} - -function collectFiles(root: string, out: string[]) { - if (EXCLUDED_PATHS.some((part) => root.includes(part))) return; - let stat: ReturnType; - try { - stat = statSync(root); - } catch { - return; - } - if (stat.isFile()) { - if (SOURCE_EXTENSIONS.has(extname(root))) out.push(root); - return; - } - if (!stat.isDirectory()) return; - for (const entry of readdirSync(root)) { - if (entry === 'node_modules' || entry.startsWith('.')) continue; - collectFiles(join(root, entry), out); - } -} - -/** Every leading comment attached to a named declaration, with its owner resolved. */ -function candidatesIn(file: string, source: string, maxProseLines: number): Candidate[] { - const kind = file.endsWith('.tsx') || file.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); - const found: Candidate[] = []; - const seen = new Set(); - - const visit = (node: ts.Node) => { - const owner = declaredName(node); - if (owner) { - for (const range of ts.getLeadingCommentRanges(source, node.pos) ?? []) { - if (seen.has(range.pos)) continue; - seen.add(range.pos); - const text = source.slice(range.pos, range.end); - if (isProtected(text)) continue; - const lines = proseLines(text); - if (!lines.length || lines.length > maxProseLines) continue; - const isMember = ts.isPropertySignature(node) || ts.isPropertyDeclaration(node); - found.push({ - file, - pos: range.pos, - end: range.end, - line: source.slice(0, range.pos).split('\n').length, - rule: 'name', - owner, - body: lines.join(' '), - isMember, - typeText: isMember && node.type ? node.type.getText(sourceFile) : '', - }); - } - } - node.forEachChild(visit); - }; - sourceFile.forEachChild(visit); - return found; -} - -/** Cut the comment plus the whitespace line it occupied, keeping the declaration's indentation. */ -function removeRanges(source: string, ranges: { pos: number; end: number }[]): string { - let result = source; - for (const range of [...ranges].sort((a, b) => b.pos - a.pos)) { - let start = range.pos; - while (start > 0 && (result[start - 1] === ' ' || result[start - 1] === '\t')) start -= 1; - let stop = range.end; - if (result[stop] === '\r') stop += 1; - if (result[stop] === '\n') stop += 1; - result = result.slice(0, start) + result.slice(stop); - } - return result; -} - -function main() { - const argv = process.argv.slice(2); - const mode = argv[0]; - if (mode !== 'inventory' && mode !== 'rewrite') { - console.error('Usage: trim-comment-budget.ts [--rules name,boilerplate,duplicate]'); - console.error(' [--dup-threshold N] [--name-ratio 0.6] [--max-lines 2] [--verbose]'); - process.exit(1); - } - - const flag = (name: string, fallback: string) => { - const index = argv.indexOf(`--${name}`); - return index === -1 ? fallback : (argv[index + 1] ?? fallback); - }; - const verbose = argv.includes('--verbose'); - const enabled = new Set(flag('rules', 'name,boilerplate').split(',')); - const dupThreshold = Number(flag('dup-threshold', '3')); - const nameRatio = Number(flag('name-ratio', '0.6')); - const maxProseLines = Number(flag('max-lines', '2')); - - const roots = argv.slice(1).filter((arg, index, list) => { - if (arg.startsWith('--')) return false; - return !list[index - 1]?.startsWith('--') || list[index - 1] === '--verbose'; - }); - if (!roots.length) { - console.error('No roots given.'); - process.exit(1); - } - - const files: string[] = []; - for (const root of roots) collectFiles(root, files); - - // Pass 1: gather every judgeable comment so the duplicate rule can count across files. - const all: Candidate[] = []; - const sources = new Map(); - for (const file of files) { - const source = readFileSync(file, 'utf8'); - sources.set(file, source); - all.push(...candidatesIn(file, source, maxProseLines)); - } - - const filesPerBody = new Map>(); - for (const candidate of all) { - if (!filesPerBody.has(candidate.body)) filesPerBody.set(candidate.body, new Set()); - filesPerBody.get(candidate.body)?.add(candidate.file); - } - - // Pass 2: classify. First matching rule wins, so each comment is counted once. - const doomed: Candidate[] = []; - for (const candidate of all) { - const words = proseWords(candidate.body); - if (!words.length) continue; - - const owned = new Set(identifierWords(candidate.owner)); - const qualified = words.some((word) => QUALIFIER_WORDS.has(word) && !owned.has(word)); - - if (enabled.has('name') && !qualified) { - const covered = words.filter((word) => owned.has(word)).length; - if (covered / words.length >= nameRatio) { - doomed.push({ ...candidate, rule: 'name' }); - continue; - } - } - if ( - enabled.has('boilerplate') && - !qualified && - rawWordCount(candidate.body) <= BOILERPLATE_MAX_WORDS && - BOILERPLATE_PATTERNS.some((pattern) => pattern.test(candidate.body)) - ) { - doomed.push({ ...candidate, rule: 'boilerplate' }); - continue; - } - if ( - enabled.has('near-empty') && - candidate.isMember && - rawWordCount(candidate.body) <= NEAR_EMPTY_MAX_WORDS && - typeIsNamed(candidate.typeText) && - !BANNER_PATTERN.test(candidate.body) && - !KEEPS_MEANING_PATTERN.test(candidate.body) - ) { - doomed.push({ ...candidate, rule: 'near-empty' }); - continue; - } - if (enabled.has('duplicate') && (filesPerBody.get(candidate.body)?.size ?? 0) >= dupThreshold) { - doomed.push({ ...candidate, rule: 'duplicate' }); - } - } - - const byFile = new Map(); - for (const candidate of doomed) { - if (!byFile.has(candidate.file)) byFile.set(candidate.file, []); - byFile.get(candidate.file)?.push(candidate); - } - - if (mode === 'rewrite') { - for (const [file, ranges] of byFile) { - const source = sources.get(file); - if (source) writeFileSync(file, removeRanges(source, ranges)); - } - } - - const counts = { name: 0, boilerplate: 0, 'near-empty': 0, duplicate: 0 }; - for (const candidate of doomed) counts[candidate.rule] += 1; - const verb = mode === 'rewrite' ? 'Removed' : 'Would remove'; - console.info(`${verb} ${doomed.length} comment(s) in ${byFile.size} file(s), of ${files.length} scanned.`); - console.info(` name-restating ${counts.name}`); - console.info(` boilerplate ${counts.boilerplate}`); - console.info(` near-empty ${counts['near-empty']}`); - console.info(` duplicate ${counts.duplicate}`); - - if (verbose) { - for (const candidate of doomed.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)) { - console.info(` ${candidate.file}:${candidate.line} [${candidate.rule}/${candidate.owner}] ${candidate.body.slice(0, 100)}`); - } - } - if (mode === 'inventory') console.info('\nRun with `rewrite` to apply, then `pnpm lint:fix` and `pnpm check`.'); -} - -main(); diff --git a/cella/migrations/20260821T1532-app-table-classifications-and-jobs/README.md b/cella/migrations/20260821T1532-app-table-classifications-and-jobs/README.md deleted file mode 100644 index 8076fd6a9..000000000 --- a/cella/migrations/20260821T1532-app-table-classifications-and-jobs/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# App table classifications and scheduled jobs move out of cella-owned files - -## What & why - -1. **Table classifications**: pinned `backend/src/tables.ts` exports `appPartitionConfigs` - (pg_partman entries: Drizzle `table` plus `partitionColumn`/`interval`/`retention`), - `appFullCrudTables` and `appReadOnlyTables` (grant lists outside RLS); `10-partman.migration.ts`, - `10-rls.migration.ts`, `99-verify.migration.ts` and `backend/tests/partman-parity.test.ts` merge - them after cella's entries. `PartitionConfig` moves into `tables.ts`. -2. **Scheduled jobs**: `defineBackendModule({ jobs: [{ name, start }] })` in - `backend/src/lib/module.ts` (or `registerBackendJob` outside a module); `start` returns the stop - handle; `main.api.ts` starts jobs under the migration-owner guard and stops them on shutdown. - First registrant: `scheduleDbMaintenance` (`backend/src/lib/db-maintenance.ts`). - -## Blast radius - -Sync-breaking for apps that appended tables to `partitionConfigs`, `fullCrudTables`, -`readOnlyTables`, the parity test map, or scheduled a job in `main.api.ts`: after sync those -tables lose grants and partitioning and the job stops starting. Untouched apps are -unaffected. `tables.ts` is pinned: copy the new type and exports by hand. No `clientCacheVersion` -bump, no schema change. - -## Run - -No script: manual. - -## Manual steps - -1. In `backend/src/tables.ts`, add the `PartitionConfig` type and the three exports from cella's - version, then move each app entry: - - `backend/scripts/migrations/10-partman.migration.ts`: `{ name: 'x', partitionColumn, interval, - retention }` becomes `{ table: xTable, partitionColumn, interval, retention }` in - `appPartitionConfigs`. - - `backend/scripts/migrations/10-rls.migration.ts`: `fullCrudTables` names go to - `appFullCrudTables`, `readOnlyTables` names to `appReadOnlyTables`. - - `backend/tests/partman-parity.test.ts`: delete the app table import and map entry (the map - extends itself from `appPartitionConfigs`). -2. Per job in `backend/src/main.api.ts`: add `jobs: [{ name: '', start: () => schedule() }]` - to the owning module's `defineBackendModule`, then delete the import, the `stop` variable, - the guarded call and the cleanup line. -3. Remove the matching `// fork:` markers from the four cella files. - -## Verify - -```sh -pnpm --filter backend test -- tests/partman-parity.test.ts # passes and lists the app table -pnpm --filter backend migrate # logs "verify: ... passed" -pnpm check -# boot the API with RUN_MIGRATIONS_ON_BOOT=true: "[startup] scheduled jobs:" must name every job -``` diff --git a/cella/migrations/20260828T1711-formlabel-help-popover/README.md b/cella/migrations/20260828T1711-formlabel-help-popover/README.md deleted file mode 100644 index 14c71d1f2..000000000 --- a/cella/migrations/20260828T1711-formlabel-help-popover/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# FormDescription removed: field help moves into a FormLabel popover - -## What & why - -`frontend/src/modules/ui/field.tsx` no longer exports `FormDescription` (cella #1105, from -projectcampus). `FormLabel` gains a `help` prop rendered as a question-mark popover next to the -label, replacing the always-reserved description row and its collapse toggle. `FieldDescription` -stays exported as a plain paragraph (collapse behavior gone). `form-fields/input.tsx` is the -reference consumer. - -## Blast radius - -Sync-breaking for fork-local files importing `FormDescription` from `~/modules/ui/field`: -`pnpm check` reports each one. No wire, DB or `clientCacheVersion` change, no lens; apps that never -used it are unaffected. Same-PR visual changes (`soft-*-strong` buttons, filled `bg-primary` unseen -badges, `focus-effect` textareas) need no action. - -## Run - -No script: manual. - -```sh -grep -rln "FormDescription" frontend/src -``` - -## Manual steps - -1. `X ... Y` becomes - `X` (`help` takes any `ReactNode`; - `{description && ...}` collapses to `help={description}`, a - nullish `help` renders no popover). -2. Drop `FormDescription` from the `~/modules/ui/field` import. -3. A call site with no matching `FormLabel` (a bare description paragraph) switches to - `FieldDescription`. - -## Verify - -```sh -pnpm check -grep -rn "FormDescription" frontend/src # must come back empty -``` diff --git a/cella/migrations/20260828T2030-elevated-grants-root-roles/README.md b/cella/migrations/20260828T2030-elevated-grants-root-roles/README.md deleted file mode 100644 index 7ddaccdf0..000000000 --- a/cella/migrations/20260828T2030-elevated-grants-root-roles/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Per-channel elevatedGrants and explicit rootRoles - -## What & why - -The global `elevatedRoles` list is gone from `shared/config/permissions-config.ts` and the `shared` -exports: each hierarchy channel declares `elevated` (roles whose product grants cover its subtree), -compiled into `hierarchy.elevatedGrants` (`${channelType}:${role}` keys) for the engine, the -collection-scope SQL compiler and view derivation. Auto-created root memberships take -their role from the source channel's explicit `rootRoles` map: `resolveParentMembershipRole` split -into `resolveAssociatedMembershipRole` (associated memberships, least-privileged fallback, -`carryRole` unchanged) and `resolveRootMembershipRole`, which throws without a map. - -## Blast radius - -Sync-breaking for every fork: `pnpm check` fails on `elevatedRoles` imports, -`resolveParentMembershipRole` calls and `elevatedRoles` options passed to the engine or view -derivation. With no `elevated` declarations **every product grant at a non-home ancestor level -becomes home-scoped** (previously subtree-scoped): a multi-level fork skipping step 1 loses subtree -reads. Invites auto-creating root membership rows throw without a complete `rootRoles` map. No DB -or wire change; `clientCacheVersion` untouched. - -## Run - -No script: manual. - -## Manual steps - -1. In `shared/config/hierarchy-config.ts`, add `elevated: [...]` per channel for the roles whose - grants must cover its subtree; to reproduce the old `elevatedRoles: ['x']` exactly, declare `x` - elevated on every channel that has it (`elevateAcross` in `shared/src/testing/elevate.ts` shows - the equivalence). Cella declares `elevated: roles.all` on `organization`. -2. Remove the `elevatedRoles` export from `shared/config/permissions-config.ts`. -3. On every non-root channel whose invites auto-create root membership rows (menuStructure - submenus and associated types), declare a complete `rootRoles` map (every channel role to a - root-channel role); build-time validation rejects partial maps. -4. Rename fork-local `resolveParentMembershipRole` calls to `resolveAssociatedMembershipRole`; - root-row call sites use `resolveRootMembershipRole`. -5. Fork-local tests passing `elevatedRoles: [...]` to `getAllDecisions`, - `resolveCollectionReadFilter` or `deriveGrantBoundaryViews` pass - `elevatedGrants: elevateAcross(hierarchy, [...])` (or an explicit key set) instead. - -## Verify - -```sh -pnpm check -pnpm test:core -grep -rn "elevatedRoles\|resolveParentMembershipRole" --include="*.ts" . # outside node_modules, must be empty -# after deploy: an org view answering `opaque` instead of `ok` on a member's catchup/sync means a missing `elevated` declaration -``` diff --git a/cella/migrations/20260828T2157-notifications-module/README.md b/cella/migrations/20260828T2157-notifications-module/README.md deleted file mode 100644 index 7b1b03c4f..000000000 --- a/cella/migrations/20260828T2157-notifications-module/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Notifications module: inbox, mentions and email digest - -## What & why - -New `notifications` module (backend `modules/notification/`, frontend `modules/notification/`): -per-recipient inbox for mentions and addressed activity, per-user email preferences, -category-scoped unsubscribe, daily/weekly digest as a module job. Product modules opt in by -declaring a `notifications` source on `defineBackendModule` (`mentionable`, `loadRows`, -`writeMentions`, `resolveRecipients`, `resolveContextId`, `loadPreview`, `loadContextNames`, -`resolveEmailLink`; see `backend/src/lib/module.ts`); with no source the module is dormant. Phase 1 -of `WEB_PUSH_BADGE_PLAN.md`. - -## Blast radius - -Not sync-breaking (dormant), but every app acts once: `pnpm generate` (`backend/drizzle` is -app-owned) for the new tables -`notifications` (partitioned weekly, 90-day retention like `seen_by`) and -`notification_preferences`, verifying the partitions exist in the DB (do not trust exit codes), -and the bell (`BellIcon`, `NotificationsSheet`, `UnreadNavBadge`) in the pinned `nav-config.tsx`, -which does not sync. No new env beyond `UNSUBSCRIBE_SECRET`; the SDK gains `/notifications` -routes. Pre-cella forks (projectcampus): steps 3 to 6. - -## Run - -No script: manual. - -## Manual steps - -1. Sync, `pnpm generate`, migrate, verify the `notifications` partitions exist. -2. Add the bell to your pinned `nav-config.tsx` (copy cella's entry). -3. Declare a source on each relevant product module: `notifications: true` takes the defaults - derived from the product table (see `ModuleNotifications` in `backend/src/lib/module.ts`); - `{ resolveRecipients, resolveContextId }` carries your thread/assignee model. -4. Dispatch from the module's create and update ops, inside the write transaction: - `dispatchMutation(txCtx, '.created', { after: rows })` and - `dispatchMutation(txCtx, '.updated', { before: [entity], after: [updated] })` - (`serverOrigin: true` from Yjs materialization). Mention derivation listens on the mutation bus, - so a source without these calls derives nothing; the attachment ops are the template example. -5. Pre-cella forks: delete your copy, move recipient logic into step 3's declarations, rename - stored `item_id` usage to `context_id` (a fresh `pnpm generate` covers pre-production databases). -6. Mentions need a `mentions` column on the product table and a composer emitting - `data-mention-id` spans (cella's mention element already does). - -## Verify - -```sh -pnpm check -pnpm test:core -# dev session: mention flow end to end with a mentionable source; without one, the bell shows an empty inbox and the digest job logs an empty run -``` diff --git a/cella/migrations/20260831T1533-web-push/README.md b/cella/migrations/20260831T1533-web-push/README.md deleted file mode 100644 index 2ae1b7ba7..000000000 --- a/cella/migrations/20260831T1533-web-push/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Web Push for notifications; Periodic Background Sync retired - -## What & why - -Phases 3 to 5 of `WEB_PUSH_BADGE_PLAN.md`. New `push` module (backend `modules/push/`) stores -per-device Web Push subscriptions and sends one ids-only payload per fresh notification row to -offline subscribers. The notifications settings card gains a per-device toggle. The Periodic -Background Sync badge path is deleted (never fired on Safari/iOS). - -## Blast radius - -Not sync-breaking: double-gated on config `has.push` (default false) AND `VAPID_*` env keys. Every -app inherits: table `push_subscriptions` (run `pnpm generate`), `/push` routes in the SDK, -service-worker `push`/`notificationclick`/`pushsubscriptionchange` handlers, and removal of the -`periodicsync` handler plus `registerPeriodicBadgeSync()` (closed-app badge updates now arrive only -via push). Custom `lib/sw.ts` or `seen-tracker.tsx` edits: merge by hand. - -## Run - -No script: manual. - -## Manual steps - -1. Sync, run `pnpm generate`, apply migrations (`push_subscriptions` plus its grants). -2. To enable: `npx web-push generate-vapid-keys`, set `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` (and - optionally `VAPID_SUBJECT`, a mailto: or URL, default the frontend URL) in the deployment's - secret manager, flip `has.push` to true in `shared/config/config.default.ts`. -3. Verify on real devices before announcing (desktop Chrome and an installed iOS PWA 16.4+ over - HTTPS): subscribe via the settings toggle, trigger a notification from another account, confirm - the toast and app badge, and that revoking permission stops delivery. -4. Apps with their own `sw.ts` edits: port the three new handlers, drop the `periodicsync` block - and `updateBadge()`. - -## Verify - -```sh -pnpm check -pnpm test:core -grep -rn "periodicsync\|registerPeriodicBadgeSync" frontend/src # must come back empty -# backend/src/modules/push/push-sender.test.ts covers pruning, backoff, online subtraction; delivery needs step 3 -``` diff --git a/cella/migrations/20260902T0906-generic-channel-path-resolver/README.md b/cella/migrations/20260902T0906-generic-channel-path-resolver/README.md deleted file mode 100644 index 90b98e14a..000000000 --- a/cella/migrations/20260902T0906-generic-channel-path-resolver/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Generic channel-path resolver replaces the register-channel-paths seam - -## What & why - -`resolveChannelPath` in `frontend/src/query/realtime/view-declaration.ts` now resolves a cached -channel row's root-first `path` itself: it iterates `hierarchy.channelTypes` minus the root and -reads `path` off the row via `findInCache`. Removed: `registerChannelPathResolver`, the pinned -`frontend/src/query/realtime/register-channel-paths.ts` (side-effect-imported from -`frontend/src/list-queries-config.tsx`), and its pinned entry in `cella/cella.config.ts`. - -## Blast radius - -Sync-breaking for apps that owned a `register-channel-paths.ts`: both files are pinned so the -merge is clean, but `registerChannelPathResolver` then resolves to a missing export and -`pnpm check` fails until the steps below. No DB or wire change; `clientCacheVersion` untouched. A -never-customized single-channel app needs only steps 1 and 3. - -## Run - -No script: manual. - -## Manual steps - -1. `git rm frontend/src/query/realtime/register-channel-paths.ts`. -2. Remove `import '~/query/realtime/register-channel-paths';` from - `frontend/src/list-queries-config.tsx`. -3. Remove `'frontend/src/query/realtime/register-channel-paths.ts'` from `pinned` in - `cella/cella.config.ts`. -4. Only if the app's resolver did more than read `path` off cached channel rows: move that logic - into `resolveChannelPath` in `view-declaration.ts` and carry the diff locally. No known app does. - -## Verify - -```sh -pnpm check -pnpm test:core -grep -rn "register-channel-paths\|registerChannelPathResolver" --include="*.ts" --include="*.tsx" . # outside node_modules, must be empty -# nested channels: a member of only a sub-organization channel must send a catchup view prefixed by that channel's path -``` diff --git a/cella/migrations/20260902T0939-role-vocabulary-from-hierarchy/README.md b/cella/migrations/20260902T0939-role-vocabulary-from-hierarchy/README.md deleted file mode 100644 index b36d7c305..000000000 --- a/cella/migrations/20260902T0939-role-vocabulary-from-hierarchy/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Role vocabulary derived from the hierarchy - -## What & why - -cella wrote `member` as a literal in tests, fixtures, stories, email previews, the `memberships` -and `inactive_memberships` column defaults and the invite default role, so apps with another -vocabulary (projectcampus: `admin | staff | guest`) carried `// fork: role vocabulary` in 17 -files. The hierarchy now exposes -`hierarchy.rootChannelType` (the parentless channel), `hierarchy.getLeastPrivilegedRole(channelType)` -(the last declared role, the floor) and `hierarchy.getMostPrivilegedRole(channelType)`; the column -default and invite default follow the app's registry. - -## Blast radius - -Not sync-breaking: every changed file resolves to the same value for an app with a `member` floor; -apps that replaced `'member'` locally get at most a trivial conflict (take upstream). No wire -change. If the registry floor differs from the DB default, `pnpm generate` emits a harmless -default-only migration. - -## Run - -No script: manual. - -## Manual steps - -1. Take upstream for every file with a `// fork: role vocabulary` marker and delete the marker; - `grep -rn "fork: role vocabulary" backend frontend shared` must come back empty. -2. Fork-local tests keep hardcoded app role names; prefer - `hierarchy.getLeastPrivilegedRole(hierarchy.rootChannelType)` when the test means "any member". -3. Replace fork code deriving the root itself - (`hierarchy.channelTypes.find((t) => hierarchy.getParent(t) === null)`) with - `hierarchy.rootChannelType`. - -## Verify - -```sh -pnpm generate -pnpm check -pnpm test:core -``` diff --git a/cella/migrations/20260902T0945-attachment-placement-seam-v2/README.md b/cella/migrations/20260902T0945-attachment-placement-seam-v2/README.md deleted file mode 100644 index 3d36cbc6f..000000000 --- a/cella/migrations/20260902T0945-attachment-placement-seam-v2/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Attachment placement seam v2 - -## What & why - -The pinned seam `backend/src/modules/attachment/helpers/attachment-placement.ts` now covers -everything apps homing attachments below the organization used to patch into cella-owned files. -New exports (apps replace the file): `attachmentHomeColumnKey` -(grant-scope column for list reads: `'organizationId'` default, `'projectId'` for a project-homed -app); `resolveAttachmentHomeScope(ctx, channelId)` (validates the `channelId` list/delta query -param as an app home channel; default accepts only the organization); -`seedAttachmentPlacements(db, organizations)` (where the seed homes its rows; `[]` skips seeding). -The default fill is hierarchy-derived (deepest home id required when that ancestor is strict, -optional when nullable, a second id rejected as ambiguous, the chain above the home read off the -resolved row); `publicAt` is accepted on create (client-sent, row-local; the upload path stamps the -home channel's value as default). - -Cella-owned: `attachmentListQuerySchema.channelId` (optional) replaces app params like `projectId`; -`channelRelationColumns` adds a lazy `references` per non-root ancestor and related channel through -the pinned `backend/src/db/channel-tables.ts` map (one lazy getter per channel type, -`satisfies Record`), `channelRelationIndexes(tableName, table, entityType)` -emits one index per such column (the root keeps its composite `(tenant_id, organization_id)` -foreign key); `appConfig.attachmentUploadTargets` (`['organization']`) lists channels with an -upload button and inline-media editors; `CreateAttachmentInput` accepts placement keys nullable and -omits null from the wire; `parseUploadedAttachments` and `useAttachmentsUploadDialog` take an -optional `placement`. - -## Blast radius - -Sync-breaking for apps that filled the seam: the pinned file must export the new members before -`pnpm check` passes. Hand-declared ancestor foreign keys or indexes on the attachments table must -go or `pnpm generate` emits duplicates; a sub-organization column without a foreign key gets one -(additive). Wire: `GET /attachments?projectId=` becomes `channelId`. No cache bump. - -## Run - -No script: manual. - -## Manual steps - -1. `shared/config/config.default.ts`: add `attachmentUploadTargets` (`['organization']` keeps - today's behavior; `[]` for apps fed only by host media blocks, then delete the fork edits in - `attachments-bar.tsx`, `attachments-table.tsx` and `update-organization-details-form.tsx` that - removed the upload affordance). -2. Create the pinned `backend/src/db/channel-tables.ts` from cella's, one lazy getter per channel - type (`project: () => projectsTable`, ...); add it to `pinned` in `cella/cella.config.ts`. - Product tables drop hand-declared ancestor foreign keys (same constraint names). -3. Take cella's `attachment-placement.ts`, keeping only what the derived defaults do not cover - (typically `seedAttachmentPlacements`). Apps that inherited `publicAt` server-side send it from - the client. -4. Take upstream for `attachment-schema.ts`, `get-attachments.ts`, `attachment-db.ts`, - `20-attachment.seed.ts`, `recalculate-sequence.test.ts`, `frontend/.../query.ts`, - `query-mutations.ts`, `parse-uploaded.ts`, `persist-attachments.ts`, - `use-attachments-upload-dialog.tsx`, `attachments-bar.tsx`, `attachments-table.tsx`, - `update-organization-details-form.tsx`; unpin any the app had pinned; remove hand-declared - ancestor `index(...)`/`foreignKey(...)` entries from `attachment-db.ts`. -5. Callers passing a home id to `parseUploadedAttachments` pass it as `placement` (`{ projectId }`). - -## Verify - -```sh -pnpm generate # at most one additive migration (ancestor foreign keys and indexes), never a drop -pnpm sdk -pnpm check -pnpm test:core -# then upload from a sub-organization channel's attachments table: the row carries that channel's id column -``` diff --git a/cella/migrations/20260902T0949-generic-app-adoptions/README.md b/cella/migrations/20260902T0949-generic-app-adoptions/README.md deleted file mode 100644 index 73b5d564c..000000000 --- a/cella/migrations/20260902T0949-generic-app-adoptions/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Generic app improvements adopted upstream - -## What & why - -Fork `// fork:` deltas on cella-owned files, now upstream: - -- `tenantReadAs(ctx, tenantId, fn)` in `backend/src/db/tenant-context.ts`: `tenantRead` with an - explicit tenant id. -- `resolveEmailLink` (notification source) also receives `tenantId`, `channelId`, `entityType`. -- `getCreatedChannelRoute(entityType, channel)` and `getNearestAncestorRoute(entityType, row)` in - `frontend/src/utils/channel-route.ts`. -- `recalculate-counters.ts` compares embedded host ids as text (`uuid[]` hosts work). -- Tab arrangement card headers: `c:resource_name` (`{{resource}} name`, new key in - `locales/en|nl/common.json`) and `c:tab`. -- `frontend/.../derive-description-props.ts` uses `shared/utils/derive-description-core`; - `DerivedDescriptionCounts` gains `attachments: string[]`. -- `shared/package.json` exports `./config/*`: app-owned `shared/config/` modules import as - `shared/config/`, not via the synced barrel. -- `appConfig.memberStatProductTypes` (`['attachment']`) drives member stats: `member-counts.ts` - resolves tables through `entityTables`, counts only published rows where the table has - `publishedAt` and stamps activity by publish time there; `members-columns.tsx` reads it; the - first type is the `lastPostedAt` sort key. -- `channelRouteConfig.notificationSearch(notification)`: search params a notification link opens - with; `getNotificationRoute` passes `entityType` and `subjectId`. - -## Blast radius - -Sync-breaking only through the new `memberStatProductTypes` key: `pnpm check` fails until it -exists. `package.json` never syncs, so the `./config/*` export is manual. Everything else is -additive; on conflict take upstream and re-apply what is app-specific. - -## Run - -No script: manual. - -## Manual steps - -1. `shared/config/config.default.ts`: add `memberStatProductTypes` (`['attachment']` keeps today's - behavior). -2. Add `"./config/*": "./config/*.ts"` to `exports` in `shared/package.json`, import app-owned - config modules as `shared/config/`, take upstream's `shared/index.ts` verbatim (raak: the - label vocabulary exports). -3. Take upstream for `tenant-context.ts`, `lib/module.ts`, `channel-route.ts`, - `recalculate-counters.ts`, `tabs-arrangement-card.tsx`, `derive-description-props.ts` (+ test), - `member-counts.ts`, `members-columns.tsx`, `notification-link.ts`; keep app-only icons in - `members-columns.tsx` under a fork marker. -4. Move per-entity-type notification link rules into the channel's `notificationSearch` in the - pinned `routes-config.tsx`. -5. Apps that must not send `attachmentCount` in derived description props strip it at the mutation - call site, not by narrowing the shared type. - -## Verify - -```sh -pnpm sdk -pnpm check -pnpm test:core -grep -rn "fork:" backend frontend shared # should list none of the files above -``` diff --git a/cella/migrations/20260902T0950-brand-and-app-locale-ignored/README.md b/cella/migrations/20260902T0950-brand-and-app-locale-ignored/README.md deleted file mode 100644 index f6aa641bf..000000000 --- a/cella/migrations/20260902T0950-brand-and-app-locale-ignored/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Brand assets and the app locale namespace are ignored, not pinned - -## What & why - -`favicon.ico`, `favicon.svg`, `thumbnail.png`, `logo.tsx`, `legal-config.ts`, `locales/en/app.json` -and `locales/nl/app.json` move from `pinned` to `ignored` in the template `cella/cella.config.ts` -(never synced, fork-owned): a pin still merges and silently drops upstream hunks on conflict. -Cella's four onboarding keys move from -`app.json` to `common.json`; `app.json` ships empty. `about.json` stays pinned. - -## Blast radius - -Not sync-breaking; fork files under the new `ignored` entries are left as they are. An app that -overrode the onboarding copy in its own `app.json` keeps winning (app merges over common); one that -never did shows cella's `common.json` copy, as before. - -## Run - -No script: manual. - -## Manual steps - -1. In `cella/cella.config.ts`, take upstream's `ignored` and `pinned` lists: remove `favicon.ico`, - `favicon.svg`, `thumbnail.png`, `logo.tsx`, `legal-config.ts`, `locales/en/app.json` and - `locales/nl/app.json` from `pinned` (now in the synced `ignored` block); keep the files. -2. Delete stale `ignored` entries `cella analyze` warns about ("ignored entry not found"). -3. Pinned files differing from upstream only by a comment (`cella analyze` lists them as protected - with a tiny diff): take upstream. - -## Verify - -```sh -pnpm cella analyze # "protected in fork" shrinks by the brand and locale entries; no "ignored entry not found" -pnpm check -``` diff --git a/cella/migrations/20260902T1355-members-config-and-hierarchy-test-seeds/README.md b/cella/migrations/20260902T1355-members-config-and-hierarchy-test-seeds/README.md deleted file mode 100644 index 0188d9b5c..000000000 --- a/cella/migrations/20260902T1355-members-config-and-hierarchy-test-seeds/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Members table config seam and hierarchy-aware test seeds - -## What & why - -**Members config seam.** `memberStatIcons` (icon per `appConfig.memberStatProductTypes` entry, -`BoxIcon` fallback) and `hiddenMemberCountColumns` (product and sub-channel `${type}Count` columns -hidden until toggled on) move out of -`frontend/src/modules/memberships/members-table/members-columns.tsx` into the new pinned -`frontend/src/members-config.ts`. - -**Hierarchy-aware test seeds.** Since `channelRelationColumns` (20260902T0906) adds ancestor -foreign keys, invented ancestor ids no longer insert: `buildInsertableProduct` -(`backend/src/mocks/product-mock-registry.ts`) inserts nullable ancestors as null unless overridden -(`hierarchy.getNullableAncestors`); `backend/tests/integration/cdc-event-bus.test.ts` (full mode) -seeds the ancestor chain through `buildTestEntityHierarchyPlan` and `seedEntityHierarchy` and -spreads `plan.channelIdColumns` into the insert; `yjs/src/tests/integration/permissions.test.ts` -seeds every `ancestorColumns` entry of nested channel rows. No-ops on cella's org-only hierarchy. - -## Blast radius - -Not sync-breaking, no `clientCacheVersion` bump, no DB change. Apps that never touched -`members-columns.tsx` or these tests only take upstream and add the pin. - -## Run - -No script: manual. - -## Manual steps - -1. Add `'frontend/src/members-config.ts'` to `pinned` in `cella/cella.config.ts` (next to - `placement-config.ts`). -2. If your `members-columns.tsx` drifted for icons or default-hidden columns, move those values - into `frontend/src/members-config.ts` (`memberStatIcons`, `hiddenMemberCountColumns`) and take - upstream for `members-columns.tsx`. -3. Take upstream for `backend/src/mocks/product-mock-registry.ts`, - `backend/tests/integration/cdc-event-bus.test.ts` and - `yjs/src/tests/integration/permissions.test.ts` if your copies carry the fork-marked version - (raak and projectcampus: same logic, comment hunk only). - -## Verify - -```sh -pnpm cella analyze # none of the four files drifted or diverged; members-config.ts shows as protected -pnpm check -pnpm test -``` diff --git a/cella/migrations/20260902T1536-seam-consolidation/README.md b/cella/migrations/20260902T1536-seam-consolidation/README.md deleted file mode 100644 index e4f90e6f6..000000000 --- a/cella/migrations/20260902T1536-seam-consolidation/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Seam consolidation: derived memberships, module routes, product tables, app schemas - -## What & why - -Five pinned files lose their pins; behavior and the wire contract are unchanged for a root-only -hierarchy. - -- `memberships-db.ts` and `inactive-memberships-db.ts` are template-owned: - `membershipChannelColumns()` and `membershipChannelIndexes()` - (`backend/src/db/utils/channel-relation-columns.ts`) derive one nullable, cascade-deleting - `Id` per non-root channel and one `
    __user_archived_idx` index each. -- `defineBackendModule` takes `routes: [{ path, app, phase? }]` (`phase`: `static` by default; - `absolute` for apps mounted at `/` with `/:tenantId/...` routes; `tenant` for - `/:tenantId/:organizationId/...`). `backend/src/routes.ts` is template-owned; the pinned - `backend/src/modules.ts` import list registers modules. -- `tables.ts` is template-owned: `entityTables` derives from the pinned lazy-getter maps - `channel-tables.ts` and the new `backend/src/db/product-tables.ts` (also home of - `appPartitionConfigs`, `appFullCrudTables` and `appReadOnlyTables`). Keep the maps separate: one - map importing both is a load-order cycle under drizzle-kit's per-file loading. -- Pinned `backend/src/schemas/app-schemas.ts` (`setupConfigSchema`, `appChannelCountFields`) - replaces `modules/organization/setup-config-schema.ts` and `schemas/app-channel-counts.ts`; - `organization-schema.ts` and `channel-included.ts` import from it. -- `user-profile-content.tsx` (unpinned) hosts a `user.profile` slot (render context: viewed user, - opening organization id, `isSheet`); cella's organizations grid is the `organizations` tool. -- Template pinned list adds `backend/src/modules.ts` and `product-tables.ts`; drops - `nav-config.tsx`, `onboarding-config.ts` (pin locally if customized) and - `user-profile-content.tsx`. - -## Blast radius - -Sync-breaking: four files are deleted or superseded and the pinned list changes; syncing without -these steps leaves stale fork copies beside the template's. No `clientCacheVersion` bump, no wire -change. DB: sub-root channels get one index-only migration on `memberships` -(`(_id, user_id, archived)`; an existing index with the derived name, raak's -`memberships_project_user_archived_idx`, is kept); root-only apps: none. - -## Run - -No script: manual. - -## Manual steps - -1. `git mv backend/src/modules/organization/setup-config-schema.ts backend/src/schemas/app-schemas.ts`; - append your `appChannelCountFields` from `backend/src/schemas/app-channel-counts.ts` (or the - empty template one); delete `app-channel-counts.ts`; take upstream for `organization-schema.ts` - and `schemas/channel-included.ts`. -2. Delete your `membershipChannelColumns` and per-channel membership indexes; take upstream for - `memberships-db.ts` and `inactive-memberships-db.ts`; `pnpm generate` should add only indexes. -3. Per app module, add `routes` to `defineBackendModule` with the mounts your `routes.ts` had: - static paths (`/public/tasks`, `/t`) need no phase; apps mounted at `/` (workspace, project, - course, course section, item and material links) take `phase: 'absolute'`; - `/:tenantId/:organizationId/...` mounts take `phase: 'tenant'`. Keep the app modules listed in - `backend/src/modules.ts`; take upstream for `routes.ts`. -4. Create `backend/src/db/product-tables.ts` from the template with a `productTables` getter per - app product (`task: () => tasksTable`, ...); move your `appPartitionConfigs`, - `appFullCrudTables` and `appReadOnlyTables` entries into it; keep `channel-tables.ts`; take - upstream for `tables.ts`. -5. If you rewrote `frontend/src/modules/user/user-profile-content.tsx`, move that UI into an - app-owned module as a `user.profile` tool (`render: ({ user, isSheet }) => ...`), hide cella's - grid with `'user.profile': { organizations: { hidden: true } }` in `placement-config.ts` when - replacing it, then take upstream. Same for `home-page.tsx`: app home surfaces belong in - `home.sections` tools. -6. In `cella/cella.config.ts` `pinned`: remove `backend/src/tables.ts`, `backend/src/routes.ts`, - `backend/src/modules/memberships/memberships-db.ts`, - `backend/src/modules/organization/setup-config-schema.ts` and - `frontend/src/modules/user/user-profile-content.tsx`; add `backend/src/db/product-tables.ts` - and `backend/src/schemas/app-schemas.ts`; drop `frontend/src/nav-config.tsx` and - `frontend/src/modules/home/onboarding/onboarding-config.ts` unless you customized them. - -## Verify - -```sh -pnpm generate # index-only migration on memberships, or nothing for a root-only app -pnpm check -pnpm test -pnpm cella analyze # no diverged files among the above, no "pinned entry not found" for the removed pins -# route parity: sorted `method path` lists of baseApp.routes before and after must be identical -``` diff --git a/cella/migrations/20260903T0640-notifications-contract/README.md b/cella/migrations/20260903T0640-notifications-contract/README.md deleted file mode 100644 index c596fd0ef..000000000 --- a/cella/migrations/20260903T0640-notifications-contract/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Notifications contract: table-derived sources, attachment mentions, /n deep link - -## What & why - -A notification source is derived from the product table: `notifications: true` on -`defineBackendModule` gives live-row loading (`deletedAt`, `publishedAt`), `mentions` writing when -the table has `mentionableColumns`, previews and digest names from `name` and `description`, and -`deriveFrom: 'both'` when the module registers a `yjsMaterializer`; apps override only -`resolveRecipients` and `resolveContextId`. Email and push links are self-describing (`/n?...`, -`buildNotificationLink`, frontend `notification-link.ts`), so `resolveEmailLink` is gone. -Attachments are the template consumer: `attachments.mentions`, `description` on the update -contract, `updateAttachmentOp` as materializer, a collaborative description editor in a sheet -(`useDescriptionUpdate` is the shared persistence policy). `appNotificationTypes` in the pinned -`app-schemas.ts` extends the type enum and labels; `shared/utils/blocknote-server-schema.ts` is -the one server schema; `pnpm style` reads `shared/config/vocabulary-allowlist.ts`. - -The inbox card reads like the pre-cella app's: `getNotifications` items carry `actor`, `channelName` -and `subjectTitle`, and `c:notification.` sentences interpolate them. `channelRouteConfig` -entries declare `notificationSearch` so a link opens the subject (cella: `attachmentDialogId`); a -channel without it lands on its default tab. `pnpm seed` gains a notifications seed built on -`mockSeedNotification` (`notification-mocks.ts`). - -## Blast radius - -Sync-breaking for apps with a `notifications` source (`loadRows` and `resolveEmailLink` change -shape), a `*DescriptionUpdate` hook, or a customized `app-schemas.ts`. DB: `attachments.mentions`. -Wire: `Attachment.mentions`, `description` update op, push payload `url` (additive, no -`clientCacheVersion` bump). Locale keys added: `email.digest_line.*`, `email.unsubscribe_mentions`. - -## Run - -No script: manual. - -## Manual steps - -1. Delete per-product `*-notifications.ts` files; declare `notifications: true` on the module, or - `{ resolveRecipients, resolveContextId }` for thread and assignee models. Replace the - `mentions` column line in each product table with `...mentionableColumns`. Remove - `resolveEmailLink` and any `onMutation` handler that re-dispatched server-origin writes. -2. Append the template's `appNotificationTypes` export to your pinned `app-schemas.ts`; list your - types (e.g. `['assigned']`) and add `notification.` plus `email.digest_line.` to - `app.json`. -3. Replace `use--description-update.ts` hooks with `useDescriptionUpdate` from - `modules/common/blocknote`, or compose its halves (`patchCollaborativeDescription` with your - derived fields, `persistStandaloneDescription` behind a debounce) when the editor needs more. -4. Take upstream for the attachment module (backend and frontend), `pnpm generate` for - `attachments.mentions`, and for `frontend/src/lib/sw.ts` (push clicks open the subject). -5. Create `shared/config/vocabulary-allowlist.ts` from the template and move app-side exceptions - into it; take upstream for `check-app-vocabulary.ts`. -6. Create backend `ServerBlockNoteEditor` instances with `serverBlockNoteSchema` from - `shared/utils/blocknote-server-schema` and delete mention-flattening workarounds; take - upstream for `yjs/src/lib/blocknote-seed.ts`. - -7. Give every channel in `channelRouteConfig` a `notificationSearch` mapping your subject types to - the search param that opens them (projectcampus: item and comment ids on the feed); without it - a notification lands on the channel's default tab, which is why item links opened the - organization's courses tab. The target route's `validateSearch` schema must declare that param - (cella: `attachmentDialogId` in `attachmentsRouteSearchParamsSchema`); the router strips - undeclared search params, so the link opens the route with nothing selected and no error. - Rewrite your `notification.` keys as sentences with `actor`, - `subject` and `channel`, and add `someone` to `app.json` if you override it. -8. Port your notifications seed onto `mockSeedNotification` (projectcampus's `55-notifications.seed.ts`). - -## Verify - -```sh -pnpm generate -pnpm check -pnpm test:core -pnpm style -``` diff --git a/cella/migrations/20260904T0746-organization-spine/README.md b/cella/migrations/20260904T0746-organization-spine/README.md deleted file mode 100644 index 0de14f2fb..000000000 --- a/cella/migrations/20260904T0746-organization-spine/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Organization is the spine, not a configurable root - -## What & why - -The hierarchy no longer models a swappable root channel: `organization` is declared with -`organization({ roles, elevated })`, the only parentless entity, and `channel()` requires a parent. -Removed: `hierarchy.rootChannelType`, `RootChannelType`, `rootRoles`/`getRootRole` (now -`organizationRoles`/`getOrganizationRole`), `resolveRootMembershipRole` (now -`resolveOrganizationMembershipRole`), `rootChannelId` on `buildTestEntityHierarchyPlan` (now -`organizationId`). Organization-bound tables call `organizationForeignKey(table)`. `getValidProduct` -requires tenant + organization scope and compares both; `getValidChannel` compares what the context -set. Reverses step 3 of `20260902T0939-role-vocabulary-from-hierarchy`. - -## Blast radius - -Sync-breaking for every app: `pnpm check` fails on the removed symbols and on -`.channel('organization', { parent: null, … })`. No DB or wire change; foreign-key names are -unchanged, so `pnpm generate` is a no-op. A product route calling `getValidProduct` without both -`tenantGuard` and `orgGuard` now fails with a 500 on first call. - -## Run - -No script: manual. Every replacement is a literal, listed below. - -## Manual steps - -1. `shared/config/hierarchy-config.ts`: `.channel('organization', { parent: null, roles, elevated })` becomes `.organization({ roles, elevated })`; `rootRoles:` on sub-organization channels becomes `organizationRoles:`. -2. Replace `hierarchy.rootChannelType` with `'organization'`, `RootChannelType` with `'organization'`, `EntityIdColumnKey` with `'organizationId'`, and `appConfig.entityIdColumnKeys[hierarchy.rootChannelType]` with `'organizationId'`. -3. Replace root-detection idioms with the literal: `channelTypes.find((t) => getParent(t) === null)`, `getOrderedAncestors(x).at(-1)`, `[...ancestors].reverse()[0]`, `getParent(a) === null`. -4. Rename `getRootRole` to `getOrganizationRole` and `resolveRootMembershipRole` to `resolveOrganizationMembershipRole`. -5. Tests calling `buildTestEntityHierarchyPlan({ rootChannelId })` pass `organizationId` instead. -6. App tables with a hand-written `(tenantId, organizationId)` foreign key to `organizations` use `organizationForeignKey(table)` from `#/db/utils/organization-foreign-key`; drop the `foreignKey` and `organizationsTable` imports if unused. -7. Where comparing an ancestor to `'organization'` narrows a single-channel union to `never`, compare `(x as string)`; multi-channel apps never hit this. -8. Every product route that reaches `getValidProduct` carries `tenantGuard` + `orgGuard`. - -## Verify - -```sh -grep -rnE "rootChannelType|RootChannelType|getRootRole|rootRoles|resolveRootMembershipRole|rootChannelId" --include="*.ts" --include="*.tsx" backend frontend shared cdc yjs # must be empty -pnpm generate # must not emit a migration -pnpm check -pnpm test:core -``` diff --git a/cella/migrations/20260904T0846-rls-defense-in-depth/README.md b/cella/migrations/20260904T0846-rls-defense-in-depth/README.md deleted file mode 100644 index 6c898d7c3..000000000 --- a/cella/migrations/20260904T0846-rls-defense-in-depth/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# RLS defense in depth: explicit scope, lazy admin credential, verified catalog - -## What & why - -Application authorization no longer leans on RLS. `requestScopeWhere(ctx, table)` -(`backend/src/db/utils/request-scope.ts`) adds the guarded tenant + organization predicate to every -organization-bound product query; `splitByPermission` rejects unknown and out-of-scope ids. The -admin pool is lazy: `migrationDb`, `unsafeInternalAdminDb` and `seedDb` are replaced by -`getAdminDb(purpose)` and `getSeedDb()`, and `DATABASE_ADMIN_URL` is optional for the API. The RLS -and verify migrations refuse to run without `runtime_role` and `admin_role`; the verifier asserts -owner, policies, grants and `BYPASSRLS`. The Yjs sweep runs per tenant and `deleteStaleDoc` takes -the row. - -## Blast radius - -Sync-breaking for apps that import `seedDb`, `migrationDb` or `unsafeInternalAdminDb`, and for -apps whose test setup migrates before creating the roles (the migration now aborts). The combined -side-effect migration re-emits (`pnpm generate`), so the next deploy re-applies every block and -verifies the catalog: a database migrated without roles fails that deploy instead of running -degraded. No wire change. - -## Run - -No script: manual. - -## Manual steps - -1. Replace `seedDb` with `getSeedDb()` (call it once per module: `const db = getSeedDb();`), `migrationDb` with `getAdminDb('migrations')` and `unsafeInternalAdminDb` with `getAdminDb('')`; drop `if (!migrationDb)` guards, the getter throws. Call `getAdminDb` inside the function that uses it: `unsafeInternalAdminDb` was `undefined` until used, whereas the getter throws (or opens the pool) when it runs, so a module-scope `const db = getAdminDb(...)` crashes the process at import. -2. App product queries (lists, counts, updates, soft-deletes, bulk predicates) add `requestScopeWhere(ctx, table)` next to their id predicate; `findAttachmentsByIds`-style helpers take `UserContext`. -3. Any test that truncates, seeds RLS tables, inserts `system_roles` or calls `recalculateCounters` uses `getAdminDb('test setup')` / `getSeedDb()`, never the runtime connection. -4. Test global setup creates `runtime_role` and `admin_role` before `migrate()`; stop re-applying triggers, ownership or grants after it. Reset a test volume migrated without roles: `pnpm docker:test:reset`. -5. `pnpm generate` and commit the new `*_side_effects` folder. -6. Fork Yjs code calling `deleteStaleDoc(entityType, entityId)` passes the stale row (`{ entityType, entityId, tenantId }`). -7. Add `test:runtime` (backend) and `test:core:runtime` (root) scripts and the CI step from cella's `ci.yml` so the suite also runs as `runtime_role`. - -## Verify - -```sh -grep -rn "seedDb\|migrationDb\|unsafeInternalAdminDb" --include="*.ts" backend cdc yjs # only getSeedDb/getAdminDb call sites -pnpm generate -pnpm check -pnpm test:core -pnpm test:core:runtime -``` diff --git a/cella/migrations/20260904T0947-rls-owner-bypass/README.md b/cella/migrations/20260904T0947-rls-owner-bypass/README.md deleted file mode 100644 index ce4c3b04b..000000000 --- a/cella/migrations/20260904T0947-rls-owner-bypass/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# RLS owner bypass replaces BYPASSRLS: NO FORCE RLS, effective CDC role probe, smoke warnings - -## What & why - -The 0.10.0 smoke step failed on `cdc=unhealthy(role_missing_bypassrls)`: Scaleway's admin user has -REPLICATION but never BYPASSRLS, and only a superuser can grant it. `10-rls` now emits -`ENABLE ROW LEVEL SECURITY` + `NO FORCE ROW LEVEL SECURITY`, so `admin_role` bypasses as the table -owner on every provider; `99-verify` asserts enabled-not-forced. The CDC probe reports `rlsBypass` and -`rlsBlockedTables` (health reason `rls_bypass_missing`). Smoke results are `ok|warn|fail`; `infra -status` gains `live.components` on the shared `infra/lib/health-components.ts`. - -## Blast radius - -Touches the database (the combined side-effect migration re-emits and un-forces RLS on the next -deploy) and the cdc→backend health push field (`roleBypassRls` → `rlsBypass`). Not sync-breaking for -apps that never customized RLS, the CDC worker, smoke, or the status registry. - -## Run - -No script: manual. - -## Manual steps - -1. `pnpm generate` and commit the new `*_side_effects` folder (RLS block now enables and un-forces; verify asserts `rls-enabled-not-forced:
    `). -2. Test global setup: create `admin_role` without `BYPASSRLS` and `ALTER ROLE admin_role NOBYPASSRLS` on an existing volume; the degraded-volume guard checks `relrowsecurity AND NOT relforcerowsecurity`. -3. Fork tests or health mappings reading `roleBypassRls` or the reason `role_missing_bypassrls` switch to `rlsBypass` / `rls_bypass_missing`; catalog assertions on `relforcerowsecurity = true` flip to `false`. -4. Fork smoke callers reading `SmokeResult.ok` read `status` (`'ok' | 'warn' | 'fail'`); `unhealthyComponents`, `formatComponentIssues` and `componentSeverity` import from `infra/lib/health-components.ts`. -5. A fork status registry test harness adds `components` to its facts map (see `registry.test.ts`). - -## Verify - -```sh -pnpm generate -pnpm --filter cdc test -pnpm --filter infra test -pnpm test:core -pnpm test:core:runtime -pnpm check -``` diff --git a/cella/migrations/20260904T1349-table-bar-sticky-cleanup/README.md b/cella/migrations/20260904T1349-table-bar-sticky-cleanup/README.md deleted file mode 100644 index 29bbc5112..000000000 --- a/cella/migrations/20260904T1349-table-bar-sticky-cleanup/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Table filter bars drop the dead sticky wiring - -## What & why - -`TableBarContainer` (`frontend/src/modules/common/data-table/table-bar-container.tsx`) no longer -wraps the filter row in `StickyBox`. The sticky path was unreachable: `enableSticky` defaulted to -false and no bar ever set it, so `offsetTop`, `enableSticky`, the `focusView` subscription and the -`group/sticky` classes were inert. The container is now a plain flex row plus the search-vars -scroll reset. `EntityGridBar` lost its `isSheet` prop, which only fed that `offsetTop`. - -## Blast radius - -Not sync-breaking for the shared bars (they arrive migrated). App-owned bars that pass `offsetTop` -to `TableBarContainer`, or `isSheet` to `EntityGridBar`, fail `pnpm check` with an unknown-prop -error until step 1 and 2. `StickyBox` itself is untouched; tab navs, the docs operations page and -any app use of it keep working. No DB or wire change. - -## Run - -No script: manual. - -## Manual steps - -1. In every app-owned `*-bar.tsx` remove `offsetTop={...}` (and `enableSticky`) from ``. -2. In every app-owned `*-grid.tsx` remove `isSheet={...}` from ``. -3. Only if an app-owned bar relied on the table bar rendering `data-sticky` or the `group/sticky` name: it never did in practice, but move that styling onto a direct `StickyBox` if it must stay. - -## Verify - -```sh -grep -rn "TableBarContainer" frontend/src | grep -n "offsetTop\|enableSticky" # must be empty -grep -rn -A12 " updateDesc({ id: task.id, ops, summary, summaryLength }))`, computing `deriveDescriptionProps` once before the branch; keep `triggerTaskGlow` after the collaborative patch. -5. projectcampus `use-item-description-update.ts`: mirror `use-material-description-update.ts`, which already composes the two halves (`name` via `titleFromBlocks` in the collaborative extra, the 900 ms solo debounce around `persistStandaloneDescription`). - -## Verify - -```sh -pnpm --filter backend seed # against an empty database: seeded rows open in the editor without a write on load -pnpm check -``` diff --git a/cella/migrations/20260909T0740-yjs-update-log/README.md b/cella/migrations/20260909T0740-yjs-update-log/README.md deleted file mode 100644 index 473fbb70e..000000000 --- a/cella/migrations/20260909T0740-yjs-update-log/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Yjs relay: append-only update log, per-socket ordering, compaction - -## What & why - -A burst of keystrokes right after opening a collaborative editor lost its first update: the relay -merged update bytes in memory across `await`s with no serialization, so a later frame overwrote -`pendingState`. The relay now appends every frame to a new `yjs_updates` table before broadcasting, -applies each socket's frames in order through a serial queue, and compacts the log into -`yjs_documents.state` under a per-document lock (`yjs/src/sync/compaction.ts`). `last_edited_by` -moved to the log rows. The relay also pulls what a client holds (server Step1), and -`yjs-resync.ts` reconnects a client stuck on parked structs. - -## Blast radius - -Database change: a new RLS table and a dropped column, applied by the next release's migrations. -Not sync-breaking for clients: the wire protocol and `/yjs/materialize` are unchanged. Only apps -that customized `yjs/src/sync/*` or `yjs/src/data/storage.ts`, or list RLS tables in their own -tests, have work. `YJS_SAVE_DEBOUNCE_MS` is now `YJS_COMPACT_DEBOUNCE_MS`; `YJS_MATERIALIZE_RETRY_MS` -is gone (the durable log makes the retry timers redundant). - -## Run - -No script: manual. - -## Manual steps - -1. `pnpm generate` and commit the new drizzle folder plus the `*_side_effects` folder: `yjs_updates` joins the RLS table set through `classifyRlsTables()` in `10-rls.migration.ts`. -2. A fork test that lists RLS tables by name (cella's `schema-verification.test.ts` does) adds `yjs_updates`. -3. Fork code importing `loadState`, `saveState`, `createDoc`, `deleteState` or `deleteStaleDoc` from `yjs/src/data/storage.ts` moves to `loadBase`, `ensureDoc`, `appendUpdate`, `readLog`, `compactState`, `deleteDoc`; `materializeState` is replaced by `compactDocument`. -4. The `yjs-worker` package gains `y-websocket` as a dev dependency for the end-to-end relay test; `pnpm install` after the sync. -5. Reset a development database or let migrations run: the old `last_edited_by` column is dropped and open session rows are re-seeded on the next connect. - -## Verify - -```sh -pnpm generate -pnpm --filter yjs-worker test -TEST_MODE=full pnpm exec vitest run --project=yjs --project=backend tests/integration/schema-verification -pnpm check -``` diff --git a/cella/migrations/20260911T0759-registry-owned-iam-principals/README.md b/cella/migrations/20260911T0759-registry-owned-iam-principals/README.md deleted file mode 100644 index 72f77ca58..000000000 --- a/cella/migrations/20260911T0759-registry-owned-iam-principals/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# VM IAM principals and policies follow the service registry - -## What & why - -`infra/lib/services.ts` gains `placeServices`, `principalServices` and `principalSecretScopeSlugs`; `resources/vm-iam.ts`, the deploy's `vm_assert_json` rows and bootstrap principal creation derive from the full registry, so toggling `appConfig.services..enabled` never touches bootstrap-owned IAM. "Apply infra change" creates missing `vm-`/`boot` applications itself. Dormant principals (registry services outside the deployed set) must hold zero API keys: the deploy asserts it and `mint-generation-keys` purges them. Trigger: enabling yjs under `singleVM` stalled the 0.10.2 deploy on a stale host condition (cella #1156). - -## Blast radius - -Infra only, not sync-breaking, no `clientCacheVersion` bump, no database change. Every bootstrapped stack needs one privileged run. `singleVM` stacks: the host condition gains each non-deployed registry folder (cella: `/-/mcp/`). Split-VM stacks: a new application and policy per registry service that was not deployed (raak: `vm-yjs`, `vm-mcp`). Until that run, the next CI deploy fails at `requirePrincipalId` (split-VM) or "Verify VM IAM grants" (`singleVM`). - -## Run - -No script: manual. - -## Manual steps - -1. Sync, so `infra/` carries this change. -2. Per stack: `pnpm infra` → Stack setup → **Apply infra change**, with a fresh bootstrap key and `SCW_STATE_ACCESS_KEY` / `SCW_STATE_SECRET_KEY` set to a key of the `--ci-deploy` application. Expect the `pulumi up` diff to show `~rules` on the VM policies, plus new `vm-` policies on split-VM stacks. -3. Revoke the bootstrap key and the temporary CI key. -4. Deploy as usual; "Verify VM IAM grants" reports each dormant principal with no key. - -## Verify - -```sh -pnpm --filter infra exec tsx tasks/print-deploy-env.ts production -pnpm --filter infra exec vitest run -pnpm check -``` diff --git a/cella/migrations/20260917T1850-sign-out-lifecycle/README.md b/cella/migrations/20260917T1850-sign-out-lifecycle/README.md deleted file mode 100644 index 55e8c2a90..000000000 --- a/cella/migrations/20260917T1850-sign-out-lifecycle/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Sign-out lifecycle: session-bound streams and cross-tab teardown - -## What & why - -The sign-out route flushes seen batches and drops the push subscription before the session ends, and -`teardownUserState` clears the app badge. `LocalUserDatabase` handles `versionchange` for a delete by closing for -good and running `deletedElsewhereListeners`, so other tabs sign out with the deleting one. Backend: `ctx.var.sessionId`, `AppStreamSubscriber.sessionId`, and `authEvents` -(`session.deleted`) let the entities listeners close a deleted session's SSE stream with the `unauthorized` error. - -## Blast radius - -Not sync-breaking, no `clientCacheVersion` bump, no database change. Apps building `AppStreamSubscriber` objects -(own streams, test fakes) need the new field. Apps that never touched the stream subscriber are unaffected after -the sync. - -## Run - -No script: manual. - -## Manual steps - -1. Add `sessionId` to every `AppStreamSubscriber` literal (handlers and test fakes); the app stream handler reads - it from `ctx.var.sessionId`. -2. If the app deletes sessions outside `signOut` and `deleteMySessions`, emit - `authEvents.emit('session.deleted', { userId, sessionIds })` after the delete. - -## Verify - -```sh -pnpm --filter backend exec vitest run src/modules/entities/stream -pnpm --filter frontend exec vitest run src/query/tests/local-user-db.test.ts -pnpm check -``` diff --git a/cella/migrations/20260917T2101-email-verification-token-removed/README.md b/cella/migrations/20260917T2101-email-verification-token-removed/README.md deleted file mode 100644 index 571c396fc..000000000 --- a/cella/migrations/20260917T2101-email-verification-token-removed/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# The email-verification token type is removed - -## What & why - -`sendVerificationEmail` had no caller since password sign-up went: magic links and the OAuth -verification round trip prove address ownership themselves. Removed with it: `handleEmailVerification`, -the `email-verification` email template, its `invokeToken` branch, the `email-verification` entry in -`tokenTypes`, and the `email-verification_*` and `email.email_verification.*` locale keys. -`deleteVerificationTokens` became `deleteOAuthVerificationTokens`. The OAuth mail subject moved to -`email.oauth_verification.subject`. - -## Blast radius - -Not sync-breaking, no database change, no cache bump. `tokens.type` is an unconstrained varchar. -Affected only if an app calls the removed helpers, sends the removed template, or translated the -removed locale keys. The `/auth/email-verification/$reason` page stays; the OAuth flows use it. - -## Run - -No script: manual. - -## Manual steps - -1. In the app's `shared/config/config.default.ts`, remove `'email-verification'` from `tokenTypes`. -2. Replace any call to `deleteVerificationTokens(userId, 'oauth-verification', id)` with `deleteOAuthVerificationTokens(ctx, { userId, identityId })` from `#/modules/auth/auth-queries`. -3. In app locale files, delete `email-verification_expired`, `email-verification_not_found` (and their `.text` keys) and the `email.email_verification.*` keys; set `email.oauth_verification.subject` if the app translated the old subject. -4. Delete any app test mock of `#/modules/auth/general/helpers/send-verification-email`. - -## Verify - -```sh -grep -rn "email-verification'\|sendVerificationEmail\|deleteVerificationTokens" backend/src shared/config -pnpm sdk -pnpm check -``` diff --git a/cella/migrations/20260918T0711-email-ledger-proof-stamps/README.md b/cella/migrations/20260918T0711-email-ledger-proof-stamps/README.md deleted file mode 100644 index 03b70881c..000000000 --- a/cella/migrations/20260918T0711-email-ledger-proof-stamps/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# The emails table records inbox proofs - -## What & why - -`emails` is the ledger of inboxes proven to belong to an account. Two new columns, `lastVerifiedBy` -(`'magic'` or the OAuth provider whose verification mail was clicked) and `lastVerifiedAt`, are -stamped on every proof; `verified` and `verifiedAt` only on the first. The click on an OAuth -connect's verification mail now adds a differing provider address to the ledger (`addProvenEmail`), -so a magic link for it signs in to the same account and invitations to it bind directly. -`markEmailVerified` takes `by`. The write-only `emails.tokenId` column is dropped: its one reader went with -the email-verification path. - -## Blast radius - -Not sync-breaking, no cache bump. Adds two nullable columns to `emails` and drops `token_id`: apps run -`pnpm generate`. -An app that calls `markEmailVerified` passes `by`. Apps with their own address writes should route -them through `addProvenEmail` so the stamps stay truthful. Nothing is deleted as a side effect. - -## Run - -No script: manual. - -## Manual steps - -1. `pnpm generate` for the two `emails` columns; keep the generated backfill (`last_verified_by = 'magic'` for rows already verified) if the app has verified rows. -2. Add `by: 'magic' | ` to any app call of `markEmailVerified` or `requireEmailVerified`. -3. Remove `tokenId` from any app insert or update on `emails`. - -## Verify - -```sh -pnpm generate -pnpm --filter backend test -- tests/sign-in tests/invitations -pnpm check -``` diff --git a/cella/migrations/20260918T0831-identities-table/README.md b/cella/migrations/20260918T0831-identities-table/README.md deleted file mode 100644 index f5004446f..000000000 --- a/cella/migrations/20260918T0831-identities-table/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# oauth_accounts becomes identities, keyed on the provider subject - -## What & why - -`oauth_accounts` is renamed to `identities` (`backend/src/modules/auth/identities-db.ts`), the one -table for every external identity of a user: social OAuth now, SSO federations and LTI launches -later. Identity is `(provider, providerUserId, issuer)`; the asserted `email` becomes a nullable -display snapshot, out of the unique key and out of the callback lookup. New columns `kind`, `issuer`, -`connectionId`, `data`, `lastUsedAt`. `tokens.oauthAccountId` is `tokens.identityId`. -`deleteOAuthVerificationTokens` takes `identityId`. - -## Blast radius - -Not sync-breaking, no cache bump, database change: apps run `pnpm generate` and answer the rename -prompts (or pass the hints below). An app that only read the provider list from `oauth_accounts` -changes one import. A provider changing a user's address no longer produces a second row. - -## Run - -No script: manual. The schema migration needs rename hints so the data survives. The last two hints belong to -`20260918T0711-email-ledger-proof-stamps`; include them when both notes are applied in one `pnpm generate`, since -the dropped `emails.token_id` otherwise reads as a possible rename: - -```sh -cd backend && pnpm tsx node_modules/drizzle-kit/bin.cjs generate --config drizzle.config.ts --hints '[ - {"type":"rename","kind":"table","from":["public","oauth_accounts"],"to":["public","identities"]}, - {"type":"rename","kind":"column","from":["public","tokens","oauth_account_id"],"to":["public","tokens","identity_id"]}, - {"type":"rename","kind":"foreign key","from":["public","tokens","tokens_oauth_account_id_oauth_accounts_id_fkey"],"to":["public","tokens","tokens_identity_id_identities_id_fkey"]}, - {"type":"rename","kind":"foreign key","from":["public","identities","oauth_accounts_user_id_users_id_fkey"],"to":["public","identities","identities_user_id_users_id_fkey"]}, - {"type":"rename","kind":"index","from":["public","identities","oauth_accounts_user_id_idx"],"to":["public","identities","identities_user_id_idx"]}, - {"type":"create","kind":"index","entity":["public","identities","identities_provider_subject_idx"]}, - {"type":"create","kind":"column","entity":["public","emails","last_verified_by"]}, - {"type":"create","kind":"column","entity":["public","emails","last_verified_at"]}]' -cd .. && pnpm generate -``` - -## Manual steps - -1. Replace imports of `#/modules/auth/oauth/oauth-accounts-db` (`oauthAccountsTable`, `OAuthAccountModel`) with `#/modules/auth/identities-db` (`identitiesTable`, `IdentityModel`). -2. Rename `oauthAccountId` to `identityId` on token inserts and on `deleteOAuthVerificationTokens` calls. -3. Replace `oauth_accounts` in any app table list (test truncation helpers, grant lists). -4. Any app lookup of an identity by address changes to `(provider, providerUserId)`; `email` may be null. - -## Verify - -```sh -grep -rn "oauth_accounts\|oauthAccount" backend/src backend/tests backend/scripts -pnpm --filter backend test -- tests/sign-in -pnpm check -``` diff --git a/cella/migrations/20260918T1245-invitee-onboarding/README.md b/cella/migrations/20260918T1245-invitee-onboarding/README.md deleted file mode 100644 index 98bf631d9..000000000 --- a/cella/migrations/20260918T1245-invitee-onboarding/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Onboarding picks its steps from the user's invitations - -## What & why - -`getOnboardingSteps(ctx)` in `frontend/src/modules/home/onboarding/onboarding-config.ts` now takes -`{ hasOrganizations, hasInvitations }` and each step carries a `when`. An invited user gets a new -`invitations` step (`invitations-step.tsx`) plus `profile`, and no longer the create-organization -steps. The `/welcome` route loads organizations and invitations first, and `footer.tsx` reads -`currentStep` from the stepper. The completed screen links into the first organization, or offers -`menuSectionsSchema.organization.createAction` when there is none. - -## Blast radius - -Frontend only, not sync-breaking, no DB or cache change. An app that never edited -`frontend/src/modules/home/onboarding/` is unaffected. An app with its own steps gets merge -conflicts there and a type error on `getOnboardingSteps()` without arguments. - -## Run - -No script: manual. - -## Manual steps - -1. Own steps in `onboarding-config.ts`: keep them, give each a `when` (`() => true` to always show). -2. Own branches in `steps.tsx`: keep them next to the new `id === 'invitations'` branch. -3. Any call to `getOnboardingSteps()`: pass the context, or read `currentStep` from `useStepper()`. -4. Apps without organization creation: drop `createAction` in `frontend/src/menu-config.tsx` to hide the completed screen's create button. - -## Verify - -```sh -pnpm check -pnpm --filter frontend exec vitest run src/modules/home/onboarding -``` diff --git a/cella/migrations/20260921T1433-devices-table/README.md b/cella/migrations/20260921T1433-devices-table/README.md deleted file mode 100644 index 5bf56ef09..000000000 --- a/cella/migrations/20260921T1433-devices-table/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Sign-ins enroll the browser in a devices table - -## What & why - -New table `devices` (`backend/src/modules/auth/devices-db.ts`, `devicesTable`): one row per user and -browser, keyed on `(userId, deviceIdHash)`. `createSession` enrolls the browser on every sign-in; a -first insert on an account that signed in before sends the `new-sign-in` account security email. A daily -`prune-devices` job drops rows unseen for 400 days. `mfa` sessions now get a device id, the same-browser -replace and the `maxSessionsPerUser` cap, like regular ones. The `device-id` cookie is SameSite Lax. - -## Blast radius - -Database change, not sync-breaking, no cache bump: apps run `pnpm generate`. Until the table exists sign-in -keeps working and logs `Failed to enroll device on sign-in`. An app with its own `devices` table has a -name clash. Users with MFA on are now capped at `maxSessionsPerUser` sessions. - -## Run - -No script: manual. - -```sh -pnpm generate -``` - -## Manual steps - -1. Run `pnpm generate`: it emits the `devices` table migration and a side-effects migration granting `runtime_role` CRUD on it. -2. An app that lists tables by hand (test truncation helpers, grant lists) adds `devices`; truncating `users` with CASCADE already clears it. -3. An app with translated backend emails adds `email.account_security.new-sign-in.title` and `.text` to its `locales//backend.json`; without them the mail falls back to English. - -## Verify - -```sh -pnpm --filter backend test -- tests/sign-in tests/emails -pnpm check -``` - -After migrating a real database, check the grant there rather than trusting the exit code: - -```sql -select has_table_privilege('runtime_role', 'public.devices', 'INSERT'); -``` diff --git a/cella/migrations/20260921T1436-identity-issuer-slug/README.md b/cella/migrations/20260921T1436-identity-issuer-slug/README.md deleted file mode 100644 index d8aa748b1..000000000 --- a/cella/migrations/20260921T1436-identity-issuer-slug/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# identities are keyed on (kind, issuer, subject); provider becomes the issuer slug - -## What & why - -`identities.provider` and the nullable `identities.issuer` merge into one not-null `issuer`, always a slug, -namespaced by `kind`: a supported OAuth provider (`github`, `google`, `microsoft`) for `oauth`, an issuer-registry -entry for `sso` and `lti` later. The issuer URL stays in config (`providers.ts`), never in the row. -`providerUserId` is `subject`. The unique index is `identities_kind_issuer_subject_idx` on -`(kind, issuer, subject)`, replacing the expression index `identities_provider_subject_idx`. Lookups of a social -identity are scoped to `kind = 'oauth'`, so an identity of another kind can never match a social sign-in. - -## Blast radius - -Not sync-breaking, no cache bump, no API change (`enabledOAuth` is unchanged), database change. The generated -migration needs care so rows survive; which path depends on whether the app already applied -`20260918T0831-identities-table`. - -## Run - -No script: manual. - -**App already on `identities`** (has `identities-db.ts` with a `provider` column): drizzle-kit reads the diff as -"`provider` dropped, `issuer` set not null", because an `issuer` column already exists. Generate, then hand-add one -line so the slug is copied before the drop: - -```sh -cd backend && pnpm tsx node_modules/drizzle-kit/bin.cjs generate --config drizzle.config.ts --name identity_issuer_slug --hints '[ - {"type":"rename","kind":"column","from":["public","identities","provider_user_id"],"to":["public","identities","subject"]}, - {"type":"create","kind":"index","entity":["public","identities","identities_kind_issuer_subject_idx"]}]' -cd .. && pnpm generate -``` - -In the generated `migration.sql`, directly above `ALTER TABLE "identities" DROP COLUMN "provider";`, add: - -```sql -UPDATE "identities" SET "issuer" = "provider";--> statement-breakpoint -``` - -Without it the migration fails on `SET NOT NULL` for any app with identity rows (it rolls back, nothing is lost). - -**App still on `oauth_accounts`** (applies this note together with `20260918T0831-identities-table`): use these hints -instead of the ones in that note. The result is a plain rename, no hand edit: - -```sh -cd backend && pnpm tsx node_modules/drizzle-kit/bin.cjs generate --config drizzle.config.ts --hints '[ - {"type":"rename","kind":"table","from":["public","oauth_accounts"],"to":["public","identities"]}, - {"type":"rename","kind":"column","from":["public","identities","provider"],"to":["public","identities","issuer"]}, - {"type":"rename","kind":"column","from":["public","identities","provider_user_id"],"to":["public","identities","subject"]}, - {"type":"create","kind":"column","entity":["public","identities","kind"]}, - {"type":"create","kind":"column","entity":["public","identities","connection_id"]}, - {"type":"create","kind":"column","entity":["public","identities","data"]}, - {"type":"create","kind":"column","entity":["public","identities","last_used_at"]}, - {"type":"rename","kind":"column","from":["public","tokens","oauth_account_id"],"to":["public","tokens","identity_id"]}, - {"type":"rename","kind":"foreign key","from":["public","tokens","tokens_oauth_account_id_oauth_accounts_id_fkey"],"to":["public","tokens","tokens_identity_id_identities_id_fkey"]}, - {"type":"rename","kind":"foreign key","from":["public","identities","oauth_accounts_user_id_users_id_fkey"],"to":["public","identities","identities_user_id_users_id_fkey"]}, - {"type":"rename","kind":"index","from":["public","identities","oauth_accounts_user_id_idx"],"to":["public","identities","identities_user_id_idx"]}, - {"type":"create","kind":"index","entity":["public","identities","identities_kind_issuer_subject_idx"]}, - {"type":"create","kind":"column","entity":["public","emails","last_verified_by"]}, - {"type":"create","kind":"column","entity":["public","emails","last_verified_at"]}]' -cd .. && pnpm generate -``` - -## Manual steps - -1. Rename `provider` to `issuer` and `providerUserId` to `subject` wherever app code reads or writes `identitiesTable`. -2. Any app lookup of a social identity becomes `(kind = 'oauth', issuer, subject)`; keep the `kind` condition. -3. `identity.issuer` is a plain string. Where a typed `EnabledOAuthProvider` is needed, pass the provider already in - scope (as `processCallbackResult` does) or narrow against `appConfig.enabledOAuthProviders`. - -## Verify - -```sh -grep -rn "providerUserId\|identitiesTable.provider\|identity.provider" backend/src backend/tests backend/scripts -pnpm --filter backend test -- tests/sign-in -pnpm check -``` - -After migrating a database that had identity rows: `SELECT kind, issuer, subject FROM identities;` shows the old -provider slug in `issuer` for every row. diff --git a/cella/migrations/20260921T1629-node-26/README.md b/cella/migrations/20260921T1629-node-26/README.md deleted file mode 100644 index d7dcf0894..000000000 --- a/cella/migrations/20260921T1629-node-26/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Node.js 26 is the required runtime - -## What & why - -Every `engines.node` moves from `24.x` to `26.x`. CI (`node-version`, `NODE_VERSION`), `Dockerfile` -and `infra/boot/Dockerfile` (`node:26-*`), the `boot:build` target (`node26`) and `@types/node` -follow. `frontend/vitest.setup.ts` deletes Node's global `localStorage` in node-env tests: Node 25+ -defines it as `undefined` without `--localstorage-file`, so zustand `persist` crashes on write where -it used to disable itself. - -## Blast radius - -Every app, on every machine: local development, CI and images all need Node 26. Not sync-breaking, -no DB or cache change. Synced files arrive migrated; only app-owned workflows, Dockerfiles, Node -version pins and vitest setup files need the manual steps. - -## Run - -No script: manual. - -## Manual steps - -1. Install Node 26 locally (`volta install node@26`, `nvm install 26`, or the installer), then `pnpm install`. -2. App-owned workflows under `.github/workflows/`: set `node-version` / `NODE_VERSION` to `26`. -3. App-owned Dockerfiles or version pins (`.nvmrc`, `volta` key, hosting settings): move `node:24-*` and `24` to `26`. - Node 25+ no longer ships corepack, so `RUN corepack enable` fails with exit code 127 on `node:26-*`. Install - pnpm from npm at the `packageManager` pin instead, as the base stage of the synced `Dockerfile` does. -4. App-owned packages: set `engines.node` to `26.x` and `@types/node` to the version the synced packages pin. -5. App-owned vitest setup files that run node-env tests against a store persisting to `localStorage`: copy the guard from `frontend/vitest.setup.ts`. - -## Verify - -```sh -node -v # v26.x -git grep -n -E "node-version: 24|NODE_VERSION: '24'|node:24-|\"node\": \"24" -- . ':!pnpm-lock.yaml' # expect no output -git grep -n corepack -- '*Dockerfile*' # expect no output: images install pnpm from npm, not via corepack -pnpm check -pnpm test:core -``` diff --git a/cella/migrations/20260921T1945-devices-table-slim/README.md b/cella/migrations/20260921T1945-devices-table-slim/README.md deleted file mode 100644 index b888bb018..000000000 --- a/cella/migrations/20260921T1945-devices-table-slim/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# devices keeps only what is read: the key and three timestamps - -## What & why - -`devices` drops `lastStrategy`, `deviceName`, `deviceType`, `deviceOs`, `browser` and `ipCountry`. Every sign-in -wrote them and nothing read them: the new sign-in notice builds its text from the request, and the sessions list -reads the same facts from the session row, which is the snapshot of that sign-in. The table is now -`(userId, deviceIdHash, firstSeenAt, lastSeenAt, notifiedAt)`. `enrollDevice(userId, deviceId)` loses its -`context` and `strategy` parameters. - -## Blast radius - -Database change, not sync-breaking, no cache bump, no API change: apps run `pnpm generate`. An app that applies -this together with `20260921T1433-devices-table` gets one migration that creates the table in its final shape. - -## Run - -No script: manual. - -```sh -pnpm generate -``` - -The schema migration is six `DROP COLUMN` statements (or none, when the table is created in the same run). No -rename prompts. - -## Manual steps - -1. App code calling `enrollDevice` drops the last two arguments. -2. App code reading a dropped column reads it from the session row (`sessionsTable`) of that sign-in. - -## Verify - -```sh -grep -rn "lastStrategy\|enrollDevice(" backend/src backend/tests -pnpm --filter backend test -- tests/sign-in -pnpm check -``` diff --git a/cella/migrations/20260922T1210-principals/README.md b/cella/migrations/20260922T1210-principals/README.md deleted file mode 100644 index c581d42fb..000000000 --- a/cella/migrations/20260922T1210-principals/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# principals: one actor table behind every provenance column - -## What & why - -New `principals (id, kind)` table; `users.id` is now a foreign key to it and `createdBy` / `updatedBy` / -`deletedBy` in `productColumns` and `channelColumns` reference `principals` instead of `users`. Users are -inserted through `insertUsers()` (`backend/src/modules/user/helpers/insert-users.ts`), which writes the -principal row first. `AuthContext` is renamed **`UserContext`** (a signed-in user), and `ActorContext` (`#/core/context`) -is its new supertype, carrying one `actor` `{ kind, id, bindings }` variable that `accessFrom` / `actorFrom` read. Error bodies carry `requestId`. Prepares service accounts (AUTH_SUBSTRATE_PLAN -Phase A). - -## Blast radius - -Database change, not sync-breaking, no cache bump, API adds one optional error field. Every app is affected: -product tables inherit the re-pointed foreign keys, and any `db.insert(usersTable)` outside `insertUsers` -fails on the new constraint until rewritten. Apps that never customized auth need only the steps below. - -## Run - -No script: manual. - -```sh -cd backend && pnpm tsx node_modules/drizzle-kit/bin.cjs generate --config drizzle.config.ts --hints '[ - {"type":"create","kind":"foreign key","entity":["public","","_created_by_principals_id_fkey"]}, - {"type":"create","kind":"foreign key","entity":["public","","_updated_by_principals_id_fkey"]}, - {"type":"create","kind":"foreign key","entity":["public","","_deleted_by_principals_id_fkey"]}, - {"type":"create","kind":"foreign key","entity":["public","organizations","organizations_created_by_principals_id_fkey"]}, - {"type":"create","kind":"foreign key","entity":["public","organizations","organizations_updated_by_principals_id_fkey"]}]' -cd .. && pnpm generate -``` - -One `create` hint per provenance column of every product and channel table (drizzle-kit lists the exact -constraint names it needs when run without hints). - -## Manual steps - -0. Rename the type everywhere: `git ls-files '*.ts' '*.tsx' '*.md' ':!cella/' ':!*CHANGELOG.md' | xargs perl -pi -e 's/\bAuthContext\b/UserContext/g'`. -1. In the generated `migration.sql`, insert the backfill directly after `CREATE TABLE "principals"` and before - any `ADD CONSTRAINT`: `INSERT INTO "principals" ("id", "kind", "created_at") SELECT "id", 'user', "created_at" FROM "users";` -2. Replace every `db.insert(usersTable)` in app code, seeds and tests with `insertUsers(db, records, { onConflictDoNothing })`. -3. Add `principals` to test `TRUNCATE` lists that include `users`. -4. Add `'principals'` to `fullCrudTables` in `backend/scripts/migrations/10-rls.migration.ts` if the app pins that file. -5. App operations that only need the actor's id: change `UserContext` to `ActorContext` and `ctx.var.user.id` to - `ctx.var.actor.id`, `ctx.var.memberships` to `ctx.var.actor.bindings`. Operations reading `user.name` / `email` stay on `UserContext`. -6. `withAuditUserLite` is gone; use `withAuditUser(ctx, entity)`. - -## Verify - -```sh -grep -rn "insert(usersTable)" backend/src backend/scripts backend/tests -grep -rn "withAuditUserLite" backend/src -pnpm generate -pnpm sdk -pnpm check -``` diff --git a/cella/migrations/20260922T1600-service-accounts/README.md b/cella/migrations/20260922T1600-service-accounts/README.md deleted file mode 100644 index 338ec6fa3..000000000 --- a/cella/migrations/20260922T1600-service-accounts/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# service accounts and API keys: the first machine principal - -## What & why - -Two new tables, `service_accounts` (a machine principal: tenant-scoped, role bindings in `bindings`, disabled never -deleted) and `api_keys` (opaque keys, hash only, `scopes` mask). `serviceGuard` authenticates -`Authorization: Bearer _sk_live_…` (or `x-api-key`) as that account; `actorGuard` accepts a session or a key on -routes whose operations take `ActorContext`. Scopes are derived from the policy matrix (`accessScopes` next to -`policyMatrix`, `:read|write`) and applied as a mask in `checkAccess*` and collection reads. The points -limiter keys on `(tenantId, principalId)`; CSRF is skipped for requests carrying an API key. AUTH_SUBSTRATE_PLAN Phase B. - -## Blast radius - -Database change (two tables, tenant `restrictions` default gains `serviceAccount` and `apiKey` quotas), not -sync-breaking, no cache bump. API adds the `service-accounts` module and an `apiKey` security scheme. Apps whose -`permissions-config.ts` destructures `configurePermissions(...)` gain an `accessScopes` export for free. Apps with their own -route files choose per route whether to accept keys (`actorGuard`) or stay session-only (`userGuard`). - -## Run - -No script: manual. - -```sh -pnpm generate -pnpm sdk -``` - -## Manual steps - -0. Rename the guards everywhere: `git ls-files '*.ts' '*.tsx' '*.md' ':!cella/' ':!*CHANGELOG.md' | xargs perl -pi -e 's/\bauthGuard\b/userGuard/g'` (`authGuard` is now `userGuard`, the session-only guard; `serviceGuard` takes API keys; `actorGuard` takes either). -1. Add `'service_accounts'` and `'api_keys'` to `fullCrudTables` in `backend/scripts/migrations/10-rls.migration.ts` if the app pins that file (they are auth tables, not RLS tables). -2. Add `api_keys, service_accounts` to test `TRUNCATE` lists that include `users`. -3. Routes a machine may call: switch `xGuard: [userGuard, ...]` to `[actorGuard, ...]` on routes whose operations are typed `ActorContext`. Never on a route whose operation reads `ctx.var.user`. -4. Hand-built contexts in tests (`{ var: { memberships } }`) also need `actor: { kind: 'user', id, bindings: memberships, scopes: null }`; guards read `actor.bindings`, and a user's binding must carry `userId` to count as a membership row. -5. `Actor` is a union (`UserActor | ServiceActor`); code that surfaces a grant as a membership row narrows with `isMembershipRow` (`memberships/helpers/select.ts`). `ActorContext` no longer promises `organization`; operations behind `orgGuard` that read it take `OrgContext`. The shared SQL actor type is now `PredicateActor` (was `Actor`). -6. Id brands (`backend/src/db/utils/ids.ts`): columns referencing `users.id` are `$type()`, columns referencing `principals.id` are `$type()`, `service_accounts.id` is `ServiceAccountId`. Plain strings still flow in everywhere; what fails to compile is a service account's id (or `actor.id`, the union) written into a user-only column. App tables with a `userId` / `createdBy` column that references `users.id` get the same `.$type()`; product and channel tables inherit it from the column helpers. `backend/tsconfig.json` turns on `noImplicitOverride` and `noImplicitReturns` (shared is checked through it; the frontend is not there yet). -7. App quotas: `defaultRestrictions.quotas` may set `serviceAccount` and `apiKey` (0 = unlimited; template defaults 20 and 100). -8. Rate limiters keyed on `'userId'` keep working for sessions; use `'principalId'` for limits that must also cover keys. - -## Review round (2026-09-22) - -- `service_accounts.updatedBy` and `api_keys.revokedBy` record who disabled or revoked (one regenerated migration, `20260922193615_auth_substrate`, carries every table of this plan). `*-queries.ts` never throws and takes `(ctx, opts)`; the admin check and the - 404 live in `helpers/managed-service-account.ts`; one operation per file. Quotas count active accounts and live - keys only. Keys and their account are cached by hash (`middlewares/guard/api-key-cache.ts`) for a minute; - revoke, roll and disable invalidate. A service account with no binding is refused at `tenantGuard`. -- Every guard declares the OpenAPI `security` it accepts (`security:` in its `xMiddleware` options); an app guard - does the same and `createXRoute` emits it. `Access.scopes` is required: a hand-built access states `scopes: null`. -- `assertTenantQuota` throws `entityType` for entity keys and `meta.resource` for principal keys; apps that read - `restrict_by_app` errors see both shapes. - -## Verify - -```sh -grep -rn "xGuard: \[userGuard" backend/src/modules//*-routes.ts -pnpm generate -pnpm sdk -pnpm check -``` - -## Naming round (2026-09-22) - -- Names follow what other systems call these things: the keys table is `api_keys` (`ApiKey`, `apiKeysTable`, - `issueApiKey`, routes `…/service-accounts/{id}/keys`); `credential` stays the WebAuthn word; prose names the proof (API key, access token, session). The OAuth scope - vocabulary is `AccessScope` / `accessScopes` (`accessScopes.all/required/allows/parse`), qualified because `scope` - was already the engine's read-scope family; the wire word `scope` is unchanged. A service account's role bindings - are `bindings` (`RoleBinding`, `actor.bindings`, `ActorBinding`); `grants` stays the engine's word. The quota key is - `apiKey`. -- Dropped as not yet read by anything: `lastUsedAt` on accounts and keys (and the stamping in the guard), - `description` on both, the key `type` column (the key format still carries `sk` / `pk`). Add them back with the - screen that shows them. -- The nine migrations this plan produced during review are squashed into `20260922193615_auth_substrate` (+ its - `side_effects`); apps that already applied an earlier folder reset their database. diff --git a/cella/migrations/20260922T1900-oauth-server/README.md b/cella/migrations/20260922T1900-oauth-server/README.md deleted file mode 100644 index 16f0ac0c6..000000000 --- a/cella/migrations/20260922T1900-oauth-server/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# OAuth authorization server: keystore, consent, connected apps - -## What & why - -cella issues its own OAuth 2.1 tokens (AUTH_SUBSTRATE_PLAN Phase D). `node-oidc-provider` runs as its own process -(`MODE=oauth`, port `devPorts.oauth`) on the same public origin under `/oauth/*`: Postgres adapter over -`oidc_payloads`, signing keys in `signing_keys` (RS256, private JWK encrypted with `data-encryption.ts`, `current` + -`next` published), grant types `authorization_code` + `refresh_token` + `client_credentials`, client auth `none` -(Client ID Metadata Documents, for MCP clients) and `client_secret_basic` (`oauth_clients` rows, and every active service -account with its secret keys as client secrets). Every token names an RFC 8707 resource (`/t/` or -`///mcp`), so it never crosses tenants. Consent is a cella page (`/oauth/consent`) that reads the -session; `GET/DELETE /me/connected-apps` list and revoke grants. `serviceGuard` and `actorGuard` accept the JWT as a -bearer and resolve the consenting user (masked by the token scopes) or the service account behind `client_credentials`. - -## Blast radius - -Database change (three tables, `service_accounts.oauth_client_id`, tenant `restrictions` default gains -`allowConsentedClients`), not sync-breaking, no cache bump. New config keys `oauthUrl`, `services.oauth`, -`devPorts.oauth`, env `OAUTH_URL`, `MODE=oauth`. Infra registry gains the `oauth` service (reuses the backend image, -co-hosted under singleVM, path route `/oauth`), so per-stack `Apply` creates its principal before the next deploy. -Two new `me` routes, one public frontend route, one account-settings tool. - -## Run - -No script: manual. - -```sh -pnpm install -pnpm generate -pnpm sdk -pnpm generate:routes -pnpm infra:compose -``` - -## Manual steps - -1. Config: add `oauthUrl` to every `shared/config/config..ts` (same origin as the API: `/oauth`), - `services.oauth: { enabled: boolean }` and `devPorts.oauth` to `config.default.ts`; the vite dev proxy forwards - `/oauth` to that port. Set `services.oauth.enabled: true` where MCP clients or registered apps must connect. -2. RLS: add `oauth_clients`, `signing_keys`, `oidc_payloads` to the grant list in `backend/scripts/migrations/10-rls.migration.ts` - if the app pins that file (auth tables, no tenant policy). -3. Tests: add `oidc_payloads` cleanup where suites truncate auth tables; the AS test starts the provider in-process - (`createOauthListener`), no separate process. -4. Infra tests that enumerate the registry (`services.test.ts`, `naming.test.ts`, `print-deploy-env.test.ts`, - `setup-service-apps.test.ts`, `synth.test.ts`) list `oauth` after `mcp`. -5. Locale keys: `connected_apps*`, `oauth_consent*`, `oauth_refusal.*`, `scope_read` / `scope_write` (with `{{resource}}` from `c:_other`), two errors. Apps with - extra entity scopes need only their `_other` plural; unknown scopes render raw. -6. Operations that must be callable with a token keep `actorGuard`; a user token sets `user` and `memberships` like - a session would, so `UserContext` operations also work behind `serviceGuard`. - -## Review round (2026-09-22) - -- The process starts: `oauth/` is a workspace package (`pnpm dev` runs it like `mcp/`), the entry is - `backend/src/modules/oauth-server/worker/oauth-worker-entry.ts`, and `main.api.ts` folds it under `singleVM` on - `devPorts.oauth` (the mcp fold passes its port the same way). Add `oauth` to `pnpm-workspace.yaml` and the biome - includes. -- The consent page lives at `/auth/consent` (`/oauth/*` is proxied to the authorization server); the interaction - redirect points there. The page reads the interaction routes through `frontend/src/lib/oauth-interaction.ts`. -- `oidc_payloads.account_id` (indexed) carries the consenting user; `oidc-payloads-sweep.ts` deletes expired and - consumed rows hourly as a job of `oauth-server-module.ts`; a partial unique index keeps one `current` and one - `next` signing key (both in the regenerated `20260922193615_auth_substrate`). -- The API face publishes `GET //.well-known/oauth-protected-resource` and `serviceGuard` names it in its - 401 challenge. Health `?depth=full` probes the store and the signing key. `rotateSigningKeys` is gone until a - rotation route exists. Test cleanup truncates `oidc_payloads` and `oauth_clients` with the auth tables. - -## Verify - -```sh -pnpm check -pnpm --filter backend exec vitest run tests/oauth-server.test.ts -pnpm --filter infra test -curl -s http://localhost:3000/oauth/.well-known/oauth-authorization-server | jq .issuer -``` - -## Naming round (2026-09-22) - -- The clients table is `oauth_clients` (`oauthClientsTable`, `OauthClientModel`) and the installation column - `service_accounts.oauth_client_id`, qualified like `oidc_payloads` because `client` means the query client - everywhere else. Dropped: `client_uri` / `policy_uri` (the consent screen renders name and logo) and - `oidc_payloads.user_code` (device authorization is off; the adapter answers `findByUserCode` with nothing). diff --git a/cella/migrations/20260922T1930-mcp-substrate/README.md b/cella/migrations/20260922T1930-mcp-substrate/README.md deleted file mode 100644 index dc28cf260..000000000 --- a/cella/migrations/20260922T1930-mcp-substrate/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# MCP on the substrate: tokens only, tools from routes - -## What & why - -The MCP endpoint (`POST ///mcp`) now sits behind `tokenGuard`: only access tokens from the -authorization server, never sessions or API keys (MCP spec 2026-07-28, AUTH_SUBSTRATE_PLAN Phase E). A tokenless call -answers `401` with `WWW-Authenticate: Bearer resource_metadata="…/mcp/.well-known/oauth-protected-resource"`; that -public route publishes the RFC 9728 document (resource id, `authorization_servers`, `scopes_supported`). Tools are no -longer a hand-written registry: a route opts in with `'x-tool': { enabled, description, approvalRequired, category, -entity, execute }` on `createXRoute`, which registers it (`backend/src/core/mcp-tool-registry.ts`: `registerMcpTool`, `getMcpTools`, `McpTool` with `entity` + `action`). The input schema -derives from the route's `request` (params minus `tenantId` / `organizationId`, query, body); a body's sync -transaction (`stx`) is left out of what the model sees and rebuilt server-side before the route's own schema -validates the call. `execute(ctx, { params, query, body })` is the handler's one line, typed from the route. `tools/list` returns every tool with `annotations` and the -scope it needs (`_meta.scope`); `tools/call` outside the token's scopes answers `403` with -`WWW-Authenticate: Bearer error="insufficient_scope", scope=":write"` (step-up), inside them it runs the -operation in-process as the consenting user or the service account. The attachment module ships the showcase -(list, get with `descriptionText`, create, update, delete). OpenAPI gains an `oauth2` security scheme whose scopes are -the derived vocabulary; guards emit it per operation. - -## Blast radius - -No database change. Route change: `handleMcp` refuses sessions (was `userGuard`). Config: `services.mcp` enabled in -development and test. `@tanstack/ai` dropped from the backend. `buildTools` and `ExecutableTool` are gone; apps that registered tools -there move them onto the routes. - -## Run - -No script: manual. - -```sh -pnpm install -pnpm sdk -``` - -## Manual steps - -1. Config: `services: { mcp: { enabled: true } }` in `config.development.ts` and `config.test.ts` (and wherever MCP - clients must connect); `oauth` must be enabled on the same modes. -2. Tools: for each route an MCP client may call, add `'x-tool': { enabled, description, approvalRequired, category, - entity, execute }` to the route; `execute: (ctx, { params, query, body }) => xOp(ctx, ...)` mirrors the handler. - Query values arrive as strings (the route's own schema reads them); a `stx` in the body is rebuilt server-side, so - pass `{ serverOrigin: true }` to update operations. Scope derives from the method (`get` = `:read`, else - `:write`). -3. Apps that extended `buildTools()` (removed) move each tool onto its route; `@tanstack/ai` types are no longer - imported by the template. -4. Tests: `backend/tests/oauth-helpers.ts` starts the AS in-process and runs client_credentials or the authorization - code flow with consent through the interaction routes; reuse it for app tool tests. -5. Provenance written by a service account hydrates to `null` in `createdBy` / `updatedBy` on the wire (audit-user - join is user-only); rows carry the principal id. A service badge in the UI is a follow-up. - -## Verify - -```sh -pnpm check -pnpm --filter backend exec vitest run tests/mcp.test.ts src/modules/mcp/mcp-server.test.ts -curl -s http://localhost:3000/mcp///mcp/.well-known/oauth-protected-resource | jq -curl -si -X POST http://localhost:3000/mcp///mcp -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | grep -i www-authenticate -``` diff --git a/cella/migrations/20260922T2010-app-voice/README.md b/cella/migrations/20260922T2010-app-voice/README.md deleted file mode 100644 index 20befcbfc..000000000 --- a/cella/migrations/20260922T2010-app-voice/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# app voice: no product name in identifiers and wire strings - -## What & why - -Two template leaks shipped the template's name to an app's own users: the domain verification TXT record -`_cella-verification.` and the HKDF salt `cella:data-encryption` behind `encryptData`. The record is now -`_-verification.` (backend lookup, route description, and the value the domain tile shows), -the salt is the neutral constant `data-encryption`. `pnpm vocabulary:check` gains a second rule that rejects -`cella_*`, `Cella*`, `_cella-*` identifiers and `cellajs.com` literals in `backend/src`, `shared/src` and -`frontend/src` (tests, config, docs and marketing excluded); `cella/AGENTS.md` records when `cella` may appear in -code (template-vs-app contrast only). - -## Blast radius - -**Re-keys every stored ciphertext.** Values encrypted before this change (TOTP secrets in `totps`, and any column -an app encrypts with `encryptData`) no longer decrypt: `decryptData` throws on the auth tag. Not sync-breaking, no -cache bump. Domains already marked verified stay verified; a domain verified after the sync needs the new record -name, which the domain tile shows. - -## Run - -No script: manual. - -```sh -pnpm sdk -pnpm vocabulary:check -``` - -## Manual steps - -1. Before deploying: decide what to do with existing encrypted rows. With no real users (the template's state), - `DELETE FROM totps;` and let people enrol again. With users, keep the old salt in the app (`// fork:` marker on - `HKDF_SALT`) or run a re-encryption pass before switching. -2. Tell tenants with a pending domain verification to add the `_-verification` record instead. -3. Run `pnpm vocabulary:check`; the new rule may flag app identifiers that carry the template name (rename or - allowlist them in `shared/config/vocabulary-allowlist.ts`). - -## Verify - -```sh -pnpm vocabulary:check -pnpm --filter backend exec vitest run src/utils/data-encryption.test.ts -``` diff --git a/cella/migrations/20260923T0803-auth-renames-3/README.md b/cella/migrations/20260923T0803-auth-renames-3/README.md deleted file mode 100644 index 402a26cd9..000000000 --- a/cella/migrations/20260923T0803-auth-renames-3/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Auth substrate renames, round 3 - -## What & why - -Three renames from the auth substrate naming rounds. `MissingScopeError` / `missing_scope` (the engine's error when an -ancestor channel id is absent, HTTP 400) become `MissingAncestorError` / `missing_ancestor`, so they no longer share a -word with the OAuth face's RFC 6750 `insufficient_scope` (403). Tenant restriction `allowConsentedClients` becomes -`allowUnregisteredClients` (it gates consent to OAuth clients with no registration), refusal `clients_not_allowed` -becomes `unregistered_clients_not_allowed`. Env `MCP_API_URL` becomes `MCP_URL`, matching `mcpUrl` and the other -public URL variables. Plan: `.todos/AUTH_RENAMES_PLAN.md` in cella. - -## Blast radius - -Sync-breaking for app code that catches `MissingScopeError`, reads `allowConsentedClients` or sets `MCP_API_URL` in a -deploy env. Database: one migration rewrites the `tenants.restrictions` default and the key in stored rows. Wire: the -Tenant response changes, so `clientCacheVersion` bumps (`v9-tenant-restrictions`); keep the bump after sync. Apps that -never touched these areas need only the codemod and the regeneration below. - -## Run - -```sh -pnpm exec tsx cella/migrations/20260923T0803-auth-renames-3/auth-renames-3.ts inventory backend/src shared/src frontend/src yjs/src locales infra/config # report only -pnpm exec tsx cella/migrations/20260923T0803-auth-renames-3/auth-renames-3.ts rewrite backend/src shared/src frontend/src yjs/src locales infra/config # apply -``` - -## Manual steps - -1. `shared/src/permissions/missing-scope-error.ts` is now `missing-ancestor-error.ts`; the sync renames the template file, the codemod rewrites any app import of it. -2. `backend/drizzle` is app-owned (the default sync config ignores it), so the template migration `20260923075927_unregistered_clients` does not arrive: run `pnpm generate` for the new `tenants.restrictions` default, then append the backfill from the template's `migration.sql` to yours: `UPDATE "tenants" SET "restrictions" = (("restrictions"::jsonb - 'allowConsentedClients') || jsonb_build_object('allowUnregisteredClients', COALESCE(("restrictions"->>'allowConsentedClients')::boolean, true)))::json WHERE ("restrictions"::jsonb) ? 'allowConsentedClients';`. If your app added its own keys to `tenants.restrictions`, that statement keeps them: it only removes `allowConsentedClients` and adds `allowUnregisteredClients`. -3. A deploy environment or `.env` that set `MCP_API_URL` by hand: rename it; the registry binds `MCP_URL` to the worker's own URL, so most apps set nothing. -4. Locale copy: `oauth_refusal.unregistered_clients_not_allowed` in your `app.json` overrides, if any; the template text now says an admin has not installed the app. -4. Keep `clientCacheVersion: 'v9-tenant-restrictions'` (or a later value of your own); the synced bump is what clears cached Tenant rows on every client. - -## Verify - -```sh -pnpm sdk -pnpm --filter infra compose:generate -pnpm check -``` diff --git a/cella/migrations/20260923T0803-auth-renames-3/auth-renames-3.ts b/cella/migrations/20260923T0803-auth-renames-3/auth-renames-3.ts deleted file mode 100644 index 032bbcc53..000000000 --- a/cella/migrations/20260923T0803-auth-renames-3/auth-renames-3.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Codemod: the third naming pass over the auth substrate (AUTH_RENAMES_PLAN.md). - * - * Whole-identifier renames, word-boundary matched: the engine's missing-ancestor error and its code, - * the tenant restriction that gates consent to unregistered OAuth clients and its refusal code, and the - * MCP public URL variable. Scans .ts/.tsx (source), .json (locales, config) and .yml (deploy env) files; - * generated output (`gen/`, `*.gen.*`, `drizzle/`) is skipped and regenerates from the renamed source. - * - * Usage (from the repo root): - * pnpm exec tsx cella/migrations//auth-renames-3.ts inventory - * pnpm exec tsx cella/migrations//auth-renames-3.ts rewrite - */ - -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { extname, join } from 'node:path' - -/** Whole-identifier renames (old -> new), word-boundary matched. */ -const RENAMES: Record = { - // The engine raises it when an ancestor channel id is absent; nothing to do with RFC 6750 insufficient_scope. - MissingScopeError: 'MissingAncestorError', - missing_scope: 'missing_ancestor', - 'missing-scope-error': 'missing-ancestor-error', - // The restriction gates consent to clients with no oauth_clients row (Client ID Metadata Document clients). - allowConsentedClients: 'allowUnregisteredClients', - clients_not_allowed: 'unregistered_clients_not_allowed', - // The only public URL variable that carried _API_; the config key was already mcpUrl. - MCP_API_URL: 'MCP_URL', -} - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.turbo', 'gen', 'drizzle']) -const EXTS = new Set(['.ts', '.tsx', '.json', '.yml', '.yaml']) - -/** Escape a string for use inside a RegExp. */ -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -/** One alternation regex, longest keys first, so nested identifiers match whole. */ -function buildRegex(keys: string[]): RegExp { - const alts = [...keys].sort((a, b) => b.length - a.length).map(escapeRegExp) - return new RegExp(`\\b(${alts.join('|')})\\b`, 'g') -} - -/** Recursively collect source files under a root, skipping generated and vendored dirs. */ -function collect(root: string, out: string[]): void { - let entries: ReturnType - try { - entries = readdirSync(root, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - const full = join(root, entry.name) - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) collect(full, out) - } else if (EXTS.has(extname(entry.name)) && !/\.gen\.[a-z]+$/.test(entry.name) && !full.includes('/gen/')) { - out.push(full) - } - } -} - -function main(): void { - const [mode, ...rest] = process.argv.slice(2) - if (mode !== 'inventory' && mode !== 'rewrite') { - console.error('Usage: ') - process.exit(1) - } - const roots = rest.filter((a) => !a.startsWith('--')) - if (roots.length === 0) { - console.error('Pass at least one root directory (e.g. backend/src shared/src frontend/src yjs/src locales infra/config).') - process.exit(1) - } - - const idRegex = buildRegex(Object.keys(RENAMES)) - const files: string[] = [] - for (const root of roots) { - if (statSync(root).isDirectory()) collect(root, files) - else files.push(root) - } - - const counts: Record = {} - let changedFiles = 0 - for (const file of files) { - const before = readFileSync(file, 'utf8') - const after = before.replace(idRegex, (m) => { - counts[m] = (counts[m] ?? 0) + 1 - return RENAMES[m] - }) - if (after !== before) { - changedFiles += 1 - if (mode === 'rewrite') writeFileSync(file, after) - else console.info(` ${file}`) - } - } - - const verb = mode === 'rewrite' ? 'Rewrote' : 'Would rewrite' - console.info(`${verb} ${changedFiles} file(s) across ${files.length} scanned.`) - for (const [name, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) { - console.info(` ${name} -> ${RENAMES[name]} (${n})`) - } - if (mode === 'inventory') console.info('\nRun with `rewrite` to apply, then follow the README manual steps.') -} - -main() diff --git a/cella/migrations/20260923T0835-auth-resource-activities/README.md b/cella/migrations/20260923T0835-auth-resource-activities/README.md deleted file mode 100644 index c2b9210f5..000000000 --- a/cella/migrations/20260923T0835-auth-resource-activities/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# service accounts, API keys and OAuth clients are activity resources; one secret-column registry - -## What & why - -`resourceTypes` gains `service_account`, `api_key` and `oauth_client`, and `resourceTables` maps them, so minting or -revoking a key, changing an account and registering a client land in `activities`. User-owned rows stay out: -notifications cover those. Secret columns move to one registry, `backend/src/db/secret-columns.ts` -(`secretColumns`, `secretLookingColumns`, `secretColumnPattern`): `createSelectSchema` now omits them itself, -`lib/redact-keys.ts` feeds log redaction (workers included, `createWorkerLog` takes the paths), and -`compactRowData(tableMeta, rowData)` strips them in the CDC worker. - -## Blast radius - -Database change (publication + REPLICA IDENTITY on three tables), not sync-breaking, no cache bump. Any app table -with a column ending in hash, secret, jwk, token or password fails the new CDC test until it is listed in -`secretColumns` or `secretLookingColumns`. A hand-written `.omit({ secret: true })` on such a column becomes a type -error, since the wrapper already omitted it. - -## Run - -No script: manual. - -```sh -pnpm generate -``` - -The side-effect migration adds the three tables to `cdc_pub`; no schema prompts. - -## Manual steps - -1. App code calling `compactRowData(rowData)` passes the `tableMeta` first. -2. App tables with a secret column: add it to `secretColumns` in `backend/src/db/secret-columns.ts`; drop the hand - `.omit()` for it in response schemas. -3. App code importing `redactedFields` from `#/lib/pino` imports it from `#/lib/redact-keys`; an app worker calling - `createWorkerLog(suffix, env)` passes `redactedFields` as the third argument. - -## Verify - -```sh -pnpm --filter cdc-worker test -- secret-columns -pnpm sdk -pnpm check -``` diff --git a/cella/migrations/20260923T0850-geoip-bucket-source/README.md b/cella/migrations/20260923T0850-geoip-bucket-source/README.md deleted file mode 100644 index ceb44bcd6..000000000 --- a/cella/migrations/20260923T0850-geoip-bucket-source/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# GeoIP databases come from the public bucket, not the image - -## What & why - -The Docker `geoip` stage and `backend/scripts/download-geoip.ts` are gone. `backend/src/lib/geoip.ts` now downloads -`dbip-country-lite.mmdb.gz` and `dbip-asn-lite.mmdb.gz` from `GEOIP_SOURCE_URL` (default: the `geoip/` prefix of -`appConfig.s3.publicCDNUrl`) at boot and daily, and substitutes `GEOIP_DEV_SAMPLE_IP` for loopback in development. -The new sign-in notice omits its location line when no country is known (`{{- location}}` in the locale text). -Data refreshes with `pnpm infra` → Refresh GeoIP data, the deploy pipeline (35-day gate) and a monthly workflow. - -## Blast radius - -Not sync-breaking, no cache bump, no database change. Production shows no country until the first deploy after the -sync (the pipeline fills the prefix) or a manual refresh. Apps that never touched GeoIP, the Dockerfile, the -account-security email or `print-deploy-env` are otherwise unaffected. - -## Run - -No script: manual. - -## Manual steps - -1. Once after the sync, publish the data for production: `pnpm infra` → Stack setup → Refresh GeoIP data (or wait for - the next deploy). -2. An app with its own `new-sign-in.text` translation replaces `Location: {{country}} (approximate)
    ` - with `{{- location}}` and adds the `email.account_security.location` key. -3. An app that customized the Dockerfile drops its `geoip` stage and the `COPY --from=geoip` line, and keeps - `backend/geoip` writable by the `app` user. -4. An app with a custom deploy env consumer adds `public_bucket` to what it expects from `print-deploy-env`. - -## Verify - -```sh -grep -rn "download-geoip\|--from=geoip\|geoip:download" backend Dockerfile .github -pnpm --filter infra test -- tasks/geoip-refresh tasks/print-deploy-env -pnpm --filter backend test -- src/lib/geoip tests/emails tests/sign-in -pnpm check -``` diff --git a/cella/migrations/20260923T0902-principal-to-actor/README.md b/cella/migrations/20260923T0902-principal-to-actor/README.md deleted file mode 100644 index f46d5a400..000000000 --- a/cella/migrations/20260923T0902-principal-to-actor/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Principal becomes actor - -## What & why - -The stored identity behind a request had two names: `principals` (the supertable, the id brand, the api_keys column, -the token claim) and `actor` (the guard output, `ActorContext`, `actorGuard`, the engine input). The template now uses -one word for the row and the request value, as it does for `user` and `organization`. `principalsTable` is -`actorsTable` in `backend/src/modules/actors/`, `PrincipalId` is `ActorId`, `api_keys.principal_id` is `actor_id`, the -access-token claim `principal_kind` is `actor_kind`, the rate-limit identifier `principalId` is `actorId`. The engine's -access field follows: `Access`, `PredicateActor`, `EngineAccess`, `ConditionActor` and `PermissionCheckOptions` carry -`actorId` where they carried `userId`, since the value was always any actor id. - -## Blast radius - -Sync-breaking for app code that imports from `#/modules/principals/`, reads `principalId` on an API key, or names the -`principals` table in a truncate list. Database: one migration renames the table, column, index and constraints. -Wire: the ApiKey shape carries `actorId`; nothing on the client reads it, so no `clientCacheVersion` bump. Access -tokens issued before the deploy fail verification once, then refresh. - -## Run - -```sh -pnpm exec tsx cella/migrations/20260923T0902-principal-to-actor/principal-to-actor.ts inventory backend/src backend/tests backend/scripts shared/src frontend/src yjs/src cdc/src # report only -pnpm exec tsx cella/migrations/20260923T0902-principal-to-actor/principal-to-actor.ts rewrite backend/src backend/tests backend/scripts shared/src frontend/src yjs/src cdc/src # apply -``` - -## Manual steps - -1. `backend/src/modules/principals/` is now `backend/src/modules/actors/` (`actors-db.ts`, `helpers/insert-actors.ts`); the sync moves the template files, the codemod rewrites imports. -2. `backend/drizzle` is app-owned (the default sync config ignores it), so the template migration `20260923100637_principal_to_actor` does not arrive: run `pnpm generate`, answer "rename" (never create + delete) for the `principals` -> `actors` table and the `principal_id` -> `actor_id` column, then add the `ALTER TABLE ... RENAME CONSTRAINT`/`ALTER INDEX ... RENAME` lines from the template's `migration.sql` to yours (drizzle keeps the `principals` names in the snapshot). The side-effects migration regenerates in the same run. If your app added tables with provenance columns, their foreign keys still point at the renamed table (Postgres keeps the reference); add matching `RENAME CONSTRAINT` lines for their `*_principals_id_fkey` names if you want them to match. -3. Any app table that references `principalsTable` directly, or any test truncate list that names `principals`, is covered by the codemod; check tables declared under a different root. -4. The codemod also rewrites the word in comments and descriptions; read the diff for "an actor" versus "a actor" and for doubled phrases such as "machine actor: the actor". -5. The engine field `userId` -> `actorId` is not in the codemod: `userId` also names the membership column. Rename it by hand wherever your app builds an `Access`, `PredicateActor`, `EngineAccess` or `ConditionActor` literal, or passes `userId` in `getAllDecisions` options; `pnpm check` lists every site as an excess-property error. Stream subscribers (`SubscriberAccess`) keep `userId`: they are users. -6. Infra code keeps `principal` for Scaleway IAM principals (`infra/lib/scaleway/principals.ts`); the codemod roots exclude `infra/` on purpose. - -## Verify - -```sh -pnpm sdk -pnpm check -pnpm test:core -``` diff --git a/cella/migrations/20260923T0902-principal-to-actor/principal-to-actor.ts b/cella/migrations/20260923T0902-principal-to-actor/principal-to-actor.ts deleted file mode 100644 index b431a554f..000000000 --- a/cella/migrations/20260923T0902-principal-to-actor/principal-to-actor.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Codemod: one word for who acts. `principal` becomes `actor` everywhere the app names the identity - * behind a request: the supertable and its module, the id brand, the api_keys column and index, the - * access-token claim, the rate-limit identifier, the insert helpers and the prose around them. The - * request-time `Actor`, `ActorContext` and `actorGuard` already used the word; the stored row now does too. - * - * Whole-identifier renames, word-boundary matched, over .ts/.tsx/.json/.yml files; generated output - * (`gen/`, `*.gen.*`, `drizzle/`) is skipped and regenerates from the renamed source. Import paths of the - * renamed module (`#/modules/principals/…`) are covered by the plural and file-stem entries. - * - * Usage (from the repo root): - * pnpm exec tsx cella/migrations//principal-to-actor.ts inventory - * pnpm exec tsx cella/migrations//principal-to-actor.ts rewrite - */ - -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { extname, join } from 'node:path' - -/** Whole-identifier renames (old -> new), word-boundary matched. */ -const RENAMES: Record = { - // Table, kinds and id brand. - principalsTable: 'actorsTable', - principalKinds: 'actorKinds', - PrincipalKind: 'ActorKind', - PrincipalId: 'ActorId', - // The api_keys column, its index and the query option named after it. - principalId: 'actorId', - principalIds: 'actorIds', - principal_id: 'actor_id', - api_keys_principal_id_idx: 'api_keys_actor_id_idx', - // The access-token claim beside `sub`. - principal_kind: 'actor_kind', - // Insert helpers and the query that lists a service account's keys. - insertPrincipals: 'insertActors', - deleteDanglingPrincipals: 'deleteDanglingActors', - findApiKeysByPrincipal: 'findApiKeysByActor', - // Tenant quota keys for service accounts and API keys: these count machines, not actors in general. - principalQuotaKeys: 'machineQuotaKeys', - // Module folder and file stems (import paths). - 'insert-principals': 'insert-actors', - 'principals-db': 'actors-db', - // The plain word, in table-name strings, local variables and prose. - principals: 'actors', - principal: 'actor', - Principals: 'Actors', - Principal: 'Actor', -} - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.turbo', 'gen', 'drizzle']) -const EXTS = new Set(['.ts', '.tsx', '.json', '.yml', '.yaml']) - -/** Escape a string for use inside a RegExp. */ -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -/** One alternation regex, longest keys first, so nested identifiers match whole. */ -function buildRegex(keys: string[]): RegExp { - const alts = [...keys].sort((a, b) => b.length - a.length).map(escapeRegExp) - return new RegExp(`\\b(${alts.join('|')})\\b`, 'g') -} - -/** Recursively collect source files under a root, skipping generated and vendored dirs. */ -function collect(root: string, out: string[]): void { - let entries: ReturnType - try { - entries = readdirSync(root, { withFileTypes: true }) - } catch { - return - } - for (const entry of entries) { - const full = join(root, entry.name) - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) collect(full, out) - } else if (EXTS.has(extname(entry.name)) && !/\.gen\.[a-z]+$/.test(entry.name) && !full.includes('/gen/')) { - out.push(full) - } - } -} - -function main(): void { - const [mode, ...rest] = process.argv.slice(2) - if (mode !== 'inventory' && mode !== 'rewrite') { - console.error('Usage: ') - process.exit(1) - } - const roots = rest.filter((a) => !a.startsWith('--')) - if (roots.length === 0) { - console.error('Pass at least one root directory (e.g. backend/src backend/tests backend/scripts shared/src frontend/src).') - process.exit(1) - } - - const idRegex = buildRegex(Object.keys(RENAMES)) - const files: string[] = [] - for (const root of roots) { - if (statSync(root).isDirectory()) collect(root, files) - else files.push(root) - } - - const counts: Record = {} - let changedFiles = 0 - for (const file of files) { - const before = readFileSync(file, 'utf8') - const after = before.replace(idRegex, (m) => { - counts[m] = (counts[m] ?? 0) + 1 - return RENAMES[m] - }) - if (after !== before) { - changedFiles += 1 - if (mode === 'rewrite') writeFileSync(file, after) - else console.info(` ${file}`) - } - } - - const verb = mode === 'rewrite' ? 'Rewrote' : 'Would rewrite' - console.info(`${verb} ${changedFiles} file(s) across ${files.length} scanned.`) - for (const [name, n] of Object.entries(counts).sort((a, b) => b[1] - a[1])) { - console.info(` ${name} -> ${RENAMES[name]} (${n})`) - } - if (mode === 'inventory') console.info('\nRun with `rewrite` to apply, then follow the README manual steps.') -} - -main() diff --git a/cella/migrations/20260923T1200-partition-maintenance-pg-cron/README.md b/cella/migrations/20260923T1200-partition-maintenance-pg-cron/README.md deleted file mode 100644 index 5fc9b8112..000000000 --- a/cella/migrations/20260923T1200-partition-maintenance-pg-cron/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Partition retention moves from pg_partman to maintain_partitions() and pg_cron - -## What & why - -pg_partman is gone: Scaleway Managed PostgreSQL does not offer it, so production never -partitioned anything, and its `run_maintenance()` ran from an in-process timer that production -never started. `10-partitions.migration.ts` now converts the same tables to native range -partitions and installs `maintain_partitions()`, which pg_cron calls nightly (scheduled by -`backend/scripts/db/schedule-partition-maintenance.ts` from `migrate.ts` and boot). The dev -image `backend/db/Dockerfile` ships pg_cron instead of pg_partman. `sessions`, `tokens` and -`unsubscribe_tokens` leave partitioning: plain `id` primary keys, retention by DELETE in the -same procedure (migration `20260923092234_tidy_bruce_banner` flattens partitioned copies). - -## Blast radius - -Touches the database and the dev image. Sync-breaking for every app: `pnpm dev` with -`RUN_MIGRATIONS_ON_BOOT=true` fails until the db image is rebuilt with pg_cron, and a Scaleway -stack needs the new `admin-cron-privilege` on `rdb` applied before the next migrate. Code that -targeted sessions, tokens or unsubscribe tokens by `(id, expiresAt)` or `(id, createdAt)` now -targets `id`. Apps that only listed `appPartitionConfigs` need no code change. - -## Run - -No script: manual. - -## Manual steps - -1. `pnpm docker:test:reset`, then rebuild both db images: `docker compose -f backend/compose.yaml --profile test build`. -2. `backend/drizzle` is app-owned (the default sync config ignores it): `pnpm generate` writes the primary-key change for `sessions`, `tokens` and `unsubscribe_tokens` and regenerates the side-effects migration (`partition_setup` replaces `partman_setup`). A database that an earlier migration partitioned needs the flatten block first: copy the `DO $$ ... $$` statement from the template's `20260923092234_tidy_bruce_banner/migration.sql` to the top of your generated migration. -3. If you imported `#/lib/db-maintenance` or ran `scripts/db-maintenance.ts`, delete those references; the job now lives in pg_cron. -4. Simplify any `and(eq(sessionsTable.id, …), eq(sessionsTable.expiresAt, …))` (same for tokens and unsubscribe tokens) to the `id` predicate; `tokens.id` is now a plain primary key other tables may reference. -5. Apps on Scaleway: CLI **Apply infra change** so the admin user gets `all` on the `rdb` database, before the next release migrates. -6. Any extra compose file that runs `backend/db` (devcontainers) adds `-c shared_preload_libraries=pg_cron -c cron.database_name=postgres`. - -## Verify - -```sh -pnpm --filter backend test -- tests/partition-parity.test.ts -pnpm --filter backend migrate # logs "verify: ... passed"; then in psql: SELECT * FROM cron.job; -pnpm check -``` diff --git a/cella/migrations/20260923T2319-session-revocation/README.md b/cella/migrations/20260923T2319-session-revocation/README.md deleted file mode 100644 index 841c279ac..000000000 --- a/cella/migrations/20260923T2319-session-revocation/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Sessions are revoked, not deleted - -## What & why - -A session ends the way an API key does: `sessions` gains `revokedAt`, `revokedBy` (actor, null for the -server's own housekeeping) and `revocationReason` (`sign_out`, `other_session`, `mfa_enabled`, -`session_cap`, `replaced`), and the row stays until the nightly sweep. `revokeSessions` in -`auth-queries.ts` replaces `deleteSession` and `deleteSessionsByIds`; `validateSession` answers 401 -`session_revoked`. The route `deleteMySessions` is `revokeMySessions` and returns the revoked rows; the -auth event `session.deleted` is `session.revoked`. The sessions list shows revoked and expired sessions -of the last 30 days; UI copy says revoke, not terminate. - -## Blast radius - -Sync-breaking for apps that call the renamed queries, route, SDK operation or auth event, or that carry -the removed locale keys (`terminate`, `terminate_all`, `success.session_terminated`, -`success.sessions_terminated`). No `clientCacheVersion` bump: the session wire shape only gains nullable -fields. Three nullable columns on `sessions`: apps run `pnpm generate`. - -## Run - -No script: manual. - -## Manual steps - -1. `pnpm generate` for the three `sessions` columns, then `pnpm sdk`. -2. Replace `deleteSession` / `deleteSessionsByIds` calls with `revokeSessions(ctx, { filters, reason, revokedBy })`; never `db.delete(sessionsTable)` outside the sweep. -3. Rename `deleteMySessions` to `revokeMySessions` in SDK calls; read the revoked rows from `data` and mark them in the query cache, they are not gone. -4. Rename `authEvents` listeners and emitters from `session.deleted` to `session.revoked`. -5. Add `revokedAt: null, revokedBy: null, revocationReason: null` to app session mocks; add `isNull(sessionsTable.revokedAt)` to app queries that mean "live session". -6. Replace the removed locale keys with `c:revoke`, `c:revoke_all` and `success.revoke_resource`; add the `revocation_reason.*`, `revoked`, `expired` and `session_history` keys to app languages. -7. Passkeys and TOTP say delete everywhere: security mail types `passkey-removed` / `totp-removed` are `passkey-deleted` / `totp-deleted` (with their `backend.json` keys); locale keys `unlink`, `unlink_mfa_last`, `success.passkey_unlinked`, `success.totp_removed`, `passkey_unlink_failed` and `totp_remove_failed` are replaced by `delete`, `delete_mfa_last`, `success.delete_resource`, `passkey_delete_failed` and `totp_delete_failed`. -8. `emails.lastVerifiedBy` is `lastVerifiedVia` (migration `email_last_verified_via`, a rename hint keeps the data) and `markEmailVerified`, `requireEmailVerified` and `addProvenEmail` take `via` in place of `by`: every other `*By` column names an actor, this one names a proof method. - -## Verify - -```sh -pnpm generate -pnpm sdk -pnpm --filter backend exec vitest run tests/sign-in/sign-out.test.ts src/modules/entities/stream -pnpm check -``` diff --git a/cella/migrations/20260923T2343-infra-operator-keys/README.md b/cella/migrations/20260923T2343-infra-operator-keys/README.md deleted file mode 100644 index cdacd346f..000000000 --- a/cella/migrations/20260923T2343-infra-operator-keys/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Operator API keys and privileged runs in the infra CLI - -## What & why - -Keys are named after their Scaleway bearer. Your own key as organization Owner is the **Owner API key** -(`SCW_OWNER_ACCESS_KEY` / `SCW_OWNER_SECRET_KEY`, usually a `keychain:` or `op:` reference): every -privileged action (Apply, Teardown, Reset database, DB exposure, seeding, Manage runtime secrets, the -admin key fetch) validates it first and, when it is a durable key, mints a 30-minute key for the run. -The **admin application key** lives in `infra/.env.` as `SCW_ADMIN_ACCESS_KEY` / -`SCW_ADMIN_SECRET_KEY`; the file's old `SCW_ACCESS_KEY` / `SCW_SECRET_KEY` pair, `SCW_STATE_*` and -`SCW_BOOTSTRAP_*` are read for one release with rename warnings. Apply previews and confirms once, -verifies live grants and privileges afterwards, and holds a renewed stack lease. A read-only preflight -names owed Applies in the deploy and on the release PR (`infra-preflight` job). - -## Blast radius - -Every app's operators and its release PR. Not sync-breaking, no DB or cache change. Synced code and -workflows arrive migrated; only each operator machine's `infra/.env.` needs the manual steps. -GitHub Environment secrets keep their names: `SCW_ACCESS_KEY` / `SCW_SECRET_KEY` are what the Scaleway -provider reads. - -## Run - -No script: manual. - -## Manual steps - -1. On each operator machine: put your Owner API key in `infra/.env.` as `SCW_OWNER_ACCESS_KEY` / `SCW_OWNER_SECRET_KEY` (a `keychain:` or `op:` reference), or be ready to paste one. -2. `pnpm infra` → Manage keys & secrets → Fetch admin application key: writes `SCW_ADMIN_*` and removes the superseded `SCW_ACCESS_KEY` / `SCW_SECRET_KEY` / `SCW_STATE_*` lines. Remove a `SCW_BOOTSTRAP_*` pair yourself. -3. Optional: Manage keys & secrets → Store passphrase in keychain. -4. The `infra-preflight` CI job needs the production Environment's secrets on release PRs, exactly as the deploy job does; add `infra-preflight` to the branch ruleset's required checks once it has run green. - -## Verify - -```sh -pnpm infra # prints "Admin application key: … (admin application)" and no rename warning before the menu -pnpm --filter infra test -pnpm check -``` diff --git a/cella/migrations/20260926T0700-jobs-pg-boss/README.md b/cella/migrations/20260926T0700-jobs-pg-boss/README.md deleted file mode 100644 index 4d0ff74e8..000000000 --- a/cella/migrations/20260926T0700-jobs-pg-boss/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# Scheduled jobs run on pg-boss through the jobs worker - -## What & why - -`BackendJob` is now `{ name, cron, run }`: a pg-boss cron schedule (UTC) on a singleton queue, -run by the new `jobs` service (`MODE=jobs`, `devPorts.jobs`, `jobs/` dev package, `coHosted` -under `singleVM`). `defineBackendModule` also takes `queues: [{ name, handler, ... }]`. The -in-process timers under `RUN_MIGRATIONS_ON_BOOT` are gone, which is why sweeps never ran in -production. The migrate companion installs the store (`installJobsSchema`) and grants -`runtime_role`; the MCP worker no longer starts pg-boss and no longer needs `DATABASE_ADMIN_URL`. -Pool defaults drop to 20 (API), 10 (cdc), 10 (yjs). - -## Blast radius - -Sync-breaking for apps that declared a job with `start`, or edited `main.api.ts` to start one: -after sync the job does not compile. Every app gains a registry service, so production needs one -operator **Apply infra change** before the next deploy (new IAM principal under split-VM, host -scope under `singleVM`). No `clientCacheVersion` bump; the database gains the `pgboss` schema on -the next migrate. - -## Run - -No script: manual. - -## Manual steps - -1. Per job: replace `{ name, start: () => scheduleX() }` with `{ name, cron: '', run: () => x() }` and delete the interval scheduler; a throw fails the run and the next period retries. -2. Add `jobs: { enabled: true }` to `services` and `jobs: 4006` to `devPorts` (4005 is `devPorts.internal`, the backend's internal listener) in `shared/config/config.default.ts` if your config is pinned; add `jobs` to `pnpm-workspace.yaml` `packages` if that file is app-owned. -3. `pnpm generate` (the combined side-effect migration gains the `jobs_grants` block), then `pnpm --filter infra compose:generate`. -4. Operator: `pnpm infra` → Apply infra change, then deploy; `/health` reports a `jobs` component. - -## Verify - -```sh -pnpm --filter backend exec vitest run src/lib/jobs.test.ts # declarations valid -pnpm dev # the jobs package logs "scheduling ..." -pnpm jobs # queues, schedules, failures -pnpm --filter infra test -pnpm check -``` diff --git a/cella/migrations/20260927T0704-access-hardening/README.md b/cella/migrations/20260927T0704-access-hardening/README.md deleted file mode 100644 index af37146f7..000000000 --- a/cella/migrations/20260927T0704-access-hardening/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Access hardening: sessions, tokens, second factors, the authorization server, data access, realtime and infra - -## What & why - -Session cookies carry a random token the database stores hashed; every session ending goes through `endSessions`; one -token module issues and spends every token. Second factors are single-use, account-security actions need a step-up, -and one grant policy governs the authorization server, its revocations reaching every process. Cross-scope reads -return narrow shapes and uniform refusals, media follows one reference grammar, the Yjs relay takes per-entity -Ed25519 tokens on an internal listener, secrets are scoped, and logs are redacted. - -## Blast radius - -Sync-breaking for every app: apps bump `clientCacheVersion` and `cookieVersion`, everyone signs in again, OAuth -clients consent again, and blocks with external image URLs stop rendering. One schema migration, one side-effect set, -new env vars and a privileged infra Apply. - -## Run - -No script: manual. - -## Manual steps - -**Database, env, config and infra** - -1. `pnpm --filter backend generate` emits one schema migration (`passkey_challenges`, `totps.last_used_step`, the unique `passkeys.credential_id`, `sessions.impersonator_session_id`/`stepped_up_at`/`stepped_up_via`, `tokens.pending_sign_up`/`session_id`, partial indexes on both new session references, `api_keys.expires_at` with a time zone, `yjs_documents.generation`, the `unsubscribe_tokens` drop) and one side-effect set (`membership_rules`, grants, a verify check of the last-admin trigger); commit both. drizzle-kit asks whether `passkey_challenges` renames the dropped `unsubscribe_tokens`; off a terminal it exits 2, so answer with `pnpm --filter backend generate -- --hints '[{"type":"create","kind":"table","entity":["public","passkey_challenges"]}]'`. An app that drops the `10-membership-rules` producer also empties `membershipRuleTriggers`. -2. Replace `YJS_SECRET` in every env with `YJS_TOKEN_PRIVATE_KEY`, `YJS_TOKEN_PUBLIC_KEY` and `YJS_RELAY_SECRET` (`pnpm --filter backend yjs:public-key` derives the public key); set `INTERNAL_PORT` where the default does not fit. Mode-bound secrets (`CDC_SECRET`, `PII_HASH_SECRET`, `UNSUBSCRIBE_SECRET`, `ADMIN_EMAIL`, the Yjs keys) are optional in the env type: read them through `modeSecret()`, declare their minimum length with `secretString(name)`, and list app ones in `env-mode-secrets.ts` (`ADMIN_EMAIL` is read by the migrate process alone). The cdc worker's `API_WS_URL` becomes `BACKEND_INTERNAL_URL`, the internal listener's base URL, as for the yjs worker. -3. Rotate any `COOKIE_SECRET` entry or `UNSUBSCRIBE_SECRET` shorter than 16 characters before deploying; rotating `UNSUBSCRIBE_SECRET` voids the unsubscribe links already sent. -4. The scheduled jobs (the digest, device prune and OAuth sweep) run on the jobs worker: `20260926T0700-jobs-pg-boss` prescribes the conversion and the service, port and config entries. -5. In the app's own `shared/config`: add `devPorts.internal` and `mediaAssetOrigin: ''`, and bump `clientCacheVersion` and `cookieVersion` (the template's bumps do not sync). -6. In `transloadit-config.ts`, set `publicBucket: true` on avatar and cover, `false` on attachment, and add the `newsletter` template and id; in the Transloadit workspace, turn on "Require a correct Signature". -7. Infra: rename `internalRoute` to `internalPort`, drop `mcp` from the CDC, Yjs signing key, relay and admin-email entries of `runtime-secrets.config.ts`, then run the privileged `Apply` and check the preview moves secret paths and replaces nothing. Deploy right after it: the Apply deletes `yjs-secret`, which the running release still requires, so an old VM that reboots in between cannot boot until the deploy replaces it. An app whose deployment serves users keeps `yjsSecret` and the old consumer lists for one release (`cella/DEPLOYMENT.md`, Changing infrastructure). -8. Infra code: import `db-exposure-acl` from `infra/lib` (it exports `parseAclInput(raw, allowWide = false)` and `AclParse` alone; the ACL takes IPv4 only, an IPv6 entry is refused), pass the plan path to `parseBootPlanJson`, and throw from tasks. - -**Sessions and sign-in** - -9. Replace `getParsedSessionCookie`, `validateSession` and `ctx.var.sessionToken` with `resolveSession`, `readSession` and `ctx.var.session`; a reader that may find no session calls `findSession(ctx)` (null on a refusal alone, a failed read throws), and a custom sign-out reads through `readOwnSession`. -10. Replace `revokeSessions` with `endSessions`; listeners of `session.revoked` handle its `reason` and `'all'`. -11. Every process with guard caches calls `listenForAuthInvalidation()` on a session-mode connection and reports it as the critical `authInvalidation` component of `/health?depth=full`. -12. Custom stream clients reconnect on `session_replaced` and `access_changed`; the 401 types that sign a client out are `sessionLostTypes` in `shared/utils/session-lost` (an app's own session reader adds its types there); app-registered `AppStreamSubscriber`s carry `systemAccessAllowed`, and callers of `closeAppStream` use `closeAppStreams`. -13. An app's own sign-out UI ends the session through `endSession({ wipe })` (`frontend/src/modules/auth/end-session.ts`), which flushes seen marks and drops the push subscription first. -14. Every route in an app's own auth modules declares `'x-strategy'`. -15. Tests build cookies with `authCookie(name, content)` (`createTestSession` already signs); scripts sign with `sealAuthCookie` or use `pnpm --filter backend session:mint `; a test that opens a magic link as the browser that asked sends `authCookie('magic-requested', tokenId)`. -16. Expect 403 `impersonation_forbidden` from `revokeMySessions` during an impersonation. - -**Tokens and sign-up** - -17. Move direct `tokensTable` reads and writes into `backend/src/modules/auth/tokens/`; replace `getValidToken` and `getValidSingleUseToken` with `invokeToken`, `readBoundToken` or `spendCookieToken`. `findBoundToken` and `invitationTokensSubquery` are gone (`readBoundToken`); `spendCookieToken(ctx, type, { db: tx })` leaves the cookie for the caller to delete once the transaction committed; `rememberLinkRequest`, `requestedHere` and `forgetLinkRequest` in `tokens/token-lifecycle.ts` replace the magic and step-up marker helpers, and a used link clears its marker. -18. Give every app token type a `tokenPolicies` entry with `replaces` (and `unboundOpener` for links), a `linkHandlers` entry per link type, and add `oauth-connect` and `step-up` to `tokenTypes`. -19. Issue cookie tokens that serve one session with `sessionId`; a custom sign-out spends `magic` and calls `dropHeldMagicLink`; the `oauth-connect` pin dies with its session. -20. Route app sign-up checks through `maySignUp(ctx, { email })`; an unverified OAuth result has no `invite` reason. `handleCreateUser(ctx, { newUser, via })` writes the email row verified and claims the invitations itself (`emailVerified` and `inactiveMembershipId` are gone); no app path creates an account without inbox proof, since the unproven-account reaper is gone. -21. `findInvitationToken` takes `{ id } | { inactiveMembershipId }`; callers of `resendInvitationWithToken` send `{ tokenId }`. - -**Second factors and step-up** - -22. Replace `validateTOTP`, `verifyTOTPWithGracePeriod` and `validatePasskey` with `verifyTotp` and `verifyPasskeyAssertion`; drop `email` from passkey challenge and verification bodies. -23. Read `check-email` as `{ recognized }` and treat `POST /requests` as 204. -24. Add `stepUpGuard` to app-owned account-security routes and to routes that mint API keys or other lasting secrets, and `noImpersonationGuard` (403 `impersonation_forbidden`) to routes an impersonation may never call; and wrap their frontend calls in `withStepUp`; call `startOAuthConnect` before an app's own connect UI. PUT /me/mfa takes a step-up and no proof in its body. - -**Authorization server** - -25. Import `invalidateOauthClientCache` from `oauth-server/client-cache`; replace `refusalFor` with `grantRefusal` for users and `apiKeyRefusal` for a service's API key, and `tokenUserCache` with the token grant cache. An authorization request naming an unknown or foreign resource redirects to the client with `error=invalid_target`. -26. Revoke grants through `revokeGrant`; replace `invalidateApiKeyCacheByAccount` with `invalidateCache.serviceAccount(tx, account)` (the row's `id`, `tenantId`, `oauthClientId`; it covers an installed app's tokens, `invalidateCache.installation` is gone); every `invalidateCache.*` takes the writing database or transaction first and is awaited last in that transaction; app listeners read the `{ serviceAccount: { id, tenantId, clientId } }` message through `parseAuthInvalidation`; pass the `VerifiedAccessToken` to the token-verdict cache functions. Read a client's kind through `clientKindOf(client)` (`oauth-server/adapter`); a Client ID Metadata Document that sets `client_kind` is refused with `invalid_client_metadata`. `deleteConsentWithTokens` runs on the caller's transaction. -27. Show `target` from the consent details on an app-owned consent page (frontend `ConsentDetails`). - -**Data access** - -28. Attachment keys start with `/` (a custom upload path keeps that first segment, or adapts `isOrganizationKey`); the server stamps the bucket, so drop `publicBucket` and `bucketName` from `getUploadToken` and `createAttachments` calls and from the Uppy meta; rows outside the prefix no longer presign. `uploadStorage(templateId)` (`shared/utils/upload-visibility`) replaces `isPublicUploadTemplate`, `appBucketFor` and `attachmentBucket`: an app whose template stores elsewhere sets that template's `publicBucket` in `transloadit-config.ts` and every reader follows. -29. Pass the entity's `organizationId` to `assertBlockMediaUrls` and `sanitizeBlockMediaUrls`; `validateBlockMediaUrls` takes `(json, ctx)`. `json/trusted-media-domains.json` and `trustedMediaDomains` are gone: stored blocks with external image URLs render nothing, and a save that contains one answers 400. -30. App creates run `checkIdempotency(ctx, table, mutationId)` before inserting (`20260930T0855-store-selectors-and-seams`). -31. Rename `loadActiveTenant` to `loadTenant`; treat an unknown tenant as 403 and an unreadable product or channel as 404 (`cella/PERMISSIONS.md`, Refusals). `updateMembership`, `toggleMfa`, `revokeMySessions`, `updateOrganization` and `updateUser` require their body, so a request without one answers 400: a body that is not required skips validation when the request has no Content-Type. App routes that take a body set `required: true` on it; `backend/tests/security/request-bodies.test.ts` fails on one that does not. -32. Frontend row affordances pass the row's home to `resolveCan(permission, createdBy, actorId, home)`: a non-elevated role's grant covers rows homed at its own channel only, as on the server. -33. Replace `findMembershipAwareRows` with `findInvitationAccounts` and `findInvitationsToAddresses` (which also returns `userId`); pass `canResend` to the pending table's `useColumns`. Invitation mails go through `sendInvitationMails` (`memberships/helpers/invitation-mail.ts`): an invited account reads its mail in its own language, a new address in the organization's default, a system invitation in the app's. `personalViewKeys` is exported from `memberships-schema.ts`. -34. Replace the SDK `Membership` type with `UpdateMembershipResponse`, merge or guard these responses before an own-membership cache (as `frontend/src/modules/memberships/query-mutations.ts` does), and pass other users' memberships from app routes through `membershipAsSeenBy`. -35. Test cleanups that delete memberships before their organizations run in one transaction (the last-admin trigger). - -**Realtime** - -36. Pass `organizationId` to `CollaborativeBlockNote`; drop `YjsTokenFetcher` and `collaborativeProduct`; an app's own `PersistQueryClientProvider` passes `shouldPersistQuery` (`~/query/persister`) as `shouldDehydrateQuery`. Relay code calls `broadcastToCollab(session, message)` with the session `joinCollab` returns. -37. App materializers refuse with a 403 or 404 `AppError` and forward the new `materialized` option to `dispatchMutation`, the mutation payload's name for the relay's own write (it was `serverOrigin`); `serverOrigin` on an update op now only picks the server clock, so an MCP tool's write retires the document like a client edit. Custom Yjs clients treat close code 1011 as transient and 4400 as final, and handle the relay's generation message (`yjs/README.md`). A description written outside the relay calls `retireYjsDocuments` in the same transaction. Relay code that writes the log passes the session's generation to `appendUpdate` and `compactState`, which write nothing and return false once that generation's row is gone; an app's own retire deletes the document row before its log rows. -38. App pools pass the parsed `DEBUG` flag to `createPgConnection(url, { debug })`, which decides the query logger; `dbConfig` is gone. - -**Telemetry, email and limits** - -39. Import `scrubUrl` from `shared/utils/scrub-url` and add app token routes to `secretPathTemplates` or `sensitiveQueryKeys`; pass `redactPaths` to every `createLogger`; log queries on `pgDetail` find nothing any more (use `pgCode` and `pgConstraint`). -40. Email templates: keep markup in the translation string, spread `plainText` on text outputs, name params with `param('')`, and declare HTML params in `htmlParams`; replace module-load `t()` calls in schemas with `translatedError(key)`. -41. Newsletter links use `buildUnsubscribeLink(userId, 'newsletter')`; `/me/unsubscribe` and `unsubscribe_tokens` are gone. Pass `since` to `buildDigestForUser` and `findUndigestedNotifications`, and the recipient's language to `renderSectionsHtml`; apps with extra languages translate the new locale keys; the instant mention-mail pass takes the oldest rows first and stamps every row it takes, mailed or skipped for good, so such a row leaves the digest too. -42. Replace imports of `StaticDocumentBody`, `BlockNoteMinimalHtml` and `sanitizeUrl`. -43. Fake limiter stores in app tests are a `RateLimiterMemory` per prefix (`rate-limiter/tests/memory-stores.ts`); an app that passed `onBlock`, imported `slowOptions` or read a limiter's `points` uses `reserveTiers`/`settleTiers` (`rate-limiter/tiers.ts`) and `handler.buckets`. - -**Removed** - -44. Replace `deleteUser` with `deleteAccounts` (`user/helpers/delete-accounts`); `findUserByEmail` loses `verifiedOnly` (every stored address is proven); `withinTimeout` returns the failure or `undefined`; test fixtures lose their `verified` argument and `verifyUserEmail`; `getHealthResponse` with the exported `healthApp`, and `StaleDocRow` with `DocScope`; drop `findExistingRequest`, `getEntityByTransaction`, `findActivityRefByMutationId` and `verifyEmail`. An app that still throws `request_email_is_user`, `request_exists`, `token_not_found` or `sync_unavailable` adds the key to its own locale. - -## Verify - -```sh -pnpm --filter backend generate -pnpm vitest run --project=backend backend/tests/security -pnpm check -``` diff --git a/cella/migrations/20260930T0855-store-selectors-and-seams/README.md b/cella/migrations/20260930T0855-store-selectors-and-seams/README.md deleted file mode 100644 index 8a0cef3f3..000000000 --- a/cella/migrations/20260930T0855-store-selectors-and-seams/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Store selector gate, per-channel role types, idempotency and block-field seams, app bundle externals - -## What & why - -The overlay providers read the whole UI store, so every open and close re-rendered each reader: -`pnpm style` now fails a store hook called without a selector. Seams a product module could skip -are template-owned: `checkIdempotency(ctx, table, stxId)` builds its own predicate, -`evolutionContract.product` takes `blockFields`, the role getters are typed per channel -(`OrganizationRole`), and the three `tsup.config.ts` read app externals from the pinned -`backend/src/bundle-config.ts`. PR CI typechecks and builds the service bundles. - -## Blast radius - -Sync-breaking for every app: `pnpm style` fails on selector-less store calls, and a -`checkIdempotency` call with a closure does not compile. No `clientCacheVersion` bump, no database -change. - -## Run - -No script: manual. - -## Manual steps - -1. Frontend: `node shared/scripts/check-frontend-style.ts` lists every selector-less store call; read one value per call (`useUIStore((state) => state.lockUI)`), or `useShallow` from `zustand/react/shallow` for a multi-pick. -2. Product creates: replace `checkIdempotency(stxId, () => findByStxMutationId(...))` with `checkIdempotency(ctx, Table, stxId)`, delete the module's `findByStxMutationId` query, and run `withAuditUsers` on the result. -3. Product contracts: declare the block-document fields as `blockFields: ['description']` on `evolutionContract.product(...)`, call `Contract.assertBlockFields(item, organizationId)` per item in the create loop once the home is resolved and on the update ops, and drop the direct `assertBlockMediaUrls` calls in product ops. -4. Bundles: `backend/src/bundle-config.ts` arrives pinned; move app entries from the tsup `KEEP_ON_DISK` and `external` lists into `appKeepOnDisk` (plain package names) and take the template's three `tsup.config.ts`. A package listed there must be in the service's `dependencies`, since the runtime image installs with `--prod`. -5. Roles: `hierarchy.getRoles`, `getMostPrivilegedRole` and `getLeastPrivilegedRole` return the named channel's roles; drop casts of the organization fixtures to the API's role type and use `OrganizationRole` from `shared` where a type is needed. -6. Tests: a suite that asserts a member's attachment rights calls `assumeMemberAttachmentPolicy({ ... })` (`backend/tests/security/helpers.ts`) at its top; `mark-seen.test.ts` skips where `attachment` is not seen-tracked. -7. CI: `ci.yml` gains `build-services` (every PR: `pnpm ts` and the three bundles) and `docker-images` (release PR: the three targets); add `build-services` to the branch ruleset's required checks. -8. `pnpm generate -- --hints ''` now reaches drizzle-kit: no action. - -## Verify - -```sh -pnpm style -pnpm --filter backend --filter cdc-worker --filter yjs-worker build -pnpm check -``` diff --git a/cella/migrations/20260930T1510-base-ui-toast/README.md b/cella/migrations/20260930T1510-base-ui-toast/README.md deleted file mode 100644 index d7fb55a68..000000000 --- a/cella/migrations/20260930T1510-base-ui-toast/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Replace sonner with Base UI Toast - -## What & why - -`sonner` is removed: toasts render through Base UI Toast in `frontend/src/modules/ui/toast.tsx`, ported from the -shadcn base toast, so the app runs on one primitive library. `toaster.(message, options)` keeps its -shape, but `options` are Base UI's (`description`, `actionProps`, `timeout`, `id`, `onClose`, `priority`). -`loading`, `message`, `promise`, `custom`, `dismiss`, `getHistory` and `getToasts` are gone; `close` stays. -`toast-store.ts` is gone because `toaster` holds toasts until the `Toaster` mounts. `ReloadPrompt` is a toast. - -## Blast radius - -Frontend only. Sync-breaking for an app that imports `sonner`, passes sonner-only options (`action`, `duration`, -`cancel`, `icon`), calls a removed method or uses `useToastStore`. Plain `toaster.(message)` calls are -unaffected. No `clientCacheVersion` bump, no database change. - -## Run - -No script: manual. - -## Manual steps - -1. Delete `frontend/src/modules/ui/sonner.tsx` and `frontend/src/modules/ui/stories/sonner.stories.tsx` if the sync kept them, remove `sonner` from `frontend/package.json`, then `pnpm install`. -2. `rg "from 'sonner'" frontend/src`: import `toaster` from `~/modules/common/toaster/toaster` and call `toaster.(...)` in place of `toast.(...)`. -3. `rg -A4 "toaster(\.[a-z]+)?\(" frontend/src | rg "action:|duration:|cancel:"`: `action: { label, onClick }` becomes `actionProps: { children, onClick }` (the action leaves the toast open; call `toaster.close(id)`), `duration` becomes `timeout` (`0` keeps the toast open). -4. `toaster.dismiss(id)` becomes `toaster.close(id)`. For `promise` or `loading`, show `toaster(message, { id })` and call again with the same `id` to update the open toast. -5. `useToastStore.getState().showToast(message, severity)` becomes `toaster[severity](message)`. - -## Verify - -```sh -rg "sonner|toast-store" frontend/src -pnpm check -``` diff --git a/cella/migrations/README.md b/cella/migrations/README.md deleted file mode 100644 index 956f08404..000000000 --- a/cella/migrations/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Migrations - -When an upstream cella change rewrites a pattern across the codebase (a codemod sweep, a schema -shift, a renamed contract), upstream code arrives already migrated but app-specific code still uses -the old pattern. This folder ships the tooling and instructions to replay each change on an app -after pulling it. - -## How it is structured - -- **One folder per migration**, named `-` (UTC, minute precision). The - timestamp is the stable id and sort key; a date alone collides under high merge activity. Each - folder holds a `README.md` (from [`_TEMPLATE.md`](./_TEMPLATE.md)) and whatever the sweep needs - (codemod script, data files, SQL). -- **[`manifest.json`](./manifest.json)**: the machine-readable index, one entry per folder with - `version` (the cella release it ships in), `kind`, sync-breaking flag, codemod path, scan roots, - and follow-up commands. Version lives here, never in the folder name, so a folder is never - renamed after apps have run it. -- **[`run.ts`](./run.ts)**: the planner. It diffs `manifest.json` against the app's applied-set - and prints the migrations still to run, in order. - -The applied-set is the app-owned file `cella/cella.migrations.json`, listing the ids already run. -Pending is a plain set difference, so it works the same whether the app tracks releases or a branch. - -## For apps: applying migrations - -After a `cella sync` pull, from the repo root: - -```sh -pnpm exec tsx cella/migrations/run.ts # print the pending plan, in order -``` - -Work the list top to bottom. For each migration: run its codemod (or the manual steps in its -`README.md`), run the follow-ups it lists (`pnpm generate`, `pnpm sdk`, ...), gate on `pnpm check`, -then record it: - -```sh -pnpm exec tsx cella/migrations/run.ts mark -``` - -The [`migrate` skill](../skills/migrate/SKILL.md) drives this loop with an agent; `run.ts --json` -feeds it the plan. - -## For maintainers: authoring a migration - -Ship the migration in the same PR as the breaking change: - -1. Create `cella/migrations/-/README.md` from [`_TEMPLATE.md`](./_TEMPLATE.md) - (`date -u +%Y%m%dT%H%M` for the prefix) plus the codemod / SQL / data files it needs. -2. Add an entry to [`manifest.json`](./manifest.json). Set `version` to the target release, or - `"next"` if unknown, and backfill it when the release is cut. -3. Keep codemods entity-agnostic and driven by allow-lists or explicit maps, so apps extend them - via a flag (e.g. `--extra-renames`) instead of editing the shipped script, which would conflict - on the next sync. - -A `syncBreaking: true` change without a migration folder is what this system exists to prevent; -treat it like a missing `clientCacheVersion` bump. diff --git a/cella/migrations/_TEMPLATE.md b/cella/migrations/_TEMPLATE.md deleted file mode 100644 index 7af0ecf17..000000000 --- a/cella/migrations/_TEMPLATE.md +++ /dev/null @@ -1,42 +0,0 @@ - - -# - -## What & why - -<At most 80 words: what changed upstream and the one-sentence reason. Name the concrete symbols, -files, or columns so a reader can grep for them in their app.> - -## Blast radius - -<At most 50 words: who is affected, whether it is sync-breaking, bumps `clientCacheVersion`, ships a -lens, or touches the database. Say when an app that never customized this area is unaffected.> - -## Run - -<The codemod invocation, or "No script: manual." Always from the repo root.> - -```sh -pnpm exec tsx cella/migrations/<id>/<script>.ts inventory <roots> # report only -pnpm exec tsx cella/migrations/<id>/<script>.ts rewrite <roots> # apply -``` - -## Manual steps - -<Numbered, per-file steps the codemod cannot do: file renames (`git mv`), ambiguous identifiers -it deliberately skips, DB migrations, config keys. `backend/drizzle` is app-owned (the default sync -config ignores it), so a template migration never arrives: say what `pnpm generate` produces and -name the hand-written SQL to port (backfills, data moves), or state that `pnpm generate` alone is -enough. Omit the section only if there are none.> - -## Verify - -<The exact gates to run, ending in `pnpm check`. List any follow-up like `pnpm generate`, -`pnpm sdk`, or a recalculation runbook.> diff --git a/cella/migrations/manifest.json b/cella/migrations/manifest.json deleted file mode 100644 index af14fec24..000000000 --- a/cella/migrations/manifest.json +++ /dev/null @@ -1,1443 +0,0 @@ -{ - "schemaVersion": 1, - "migrations": [ - { - "id": "20260722T0902-drop-entity-suffix-renames", - "version": "next", - "title": "Drop redundant Entity suffix from single-family identifiers", - "kind": "codemod", - "syncBreaking": true, - "clientCacheBump": false, - "script": "cella/migrations/20260722T0902-drop-entity-suffix-renames/drop-entity-suffix-renames.ts", - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts", - "frontend/src", - "shared", - "cdc/src", - "yjs/src" - ], - "requires": [ - "pnpm sdk", - "pnpm check" - ], - "summary": "ChannelEntityBase->ChannelBase, ProductEntityBase->ProductBase, getValid*Entity->getValid*, EnrichedChannelEntity->EnrichedChannel, bare channelEntity->channel and the rest of the single-family Entity identifiers (48 ids + 9 files). Allow-list codemod; internal rename, no wire-shape change." - }, - { - "id": "20260722T1906-embedding-propagation-rename", - "version": "next", - "title": "Rename embedding propagation contract to embedded/host product", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": true, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "shared", - "cdc/src" - ], - "requires": [ - "pnpm sdk", - "pnpm check" - ], - "summary": "PropagationHint fields sourceType->embeddedProduct, targetType->hostProduct, field->hostColumn on StreamNotification.propagation + catchup propagation; propagationTargets->hostsByEmbeddedProduct; product-type wire fields tightened to z.enum(productEntityTypes). Manual (field names too generic to codemod); apps bump clientCacheVersion." - }, - { - "id": "20260722T2050-sonner-style-toaster-api", - "version": "next", - "title": "Adopt the Sonner-style toaster API", - "kind": "codemod", - "syncBreaking": true, - "clientCacheBump": false, - "script": "cella/migrations/20260722T2050-sonner-style-toaster-api/sonner-style-toaster-api.ts", - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "Rewrite toaster(message, severity, options) calls to Sonner-style toaster.success/info/warning/error methods. TypeScript-AST codemod handles literal severities and reports dynamic calls for manual review; internal API only, no wire-shape change." - }, - { - "id": "20260722T2105-prepared-mutation-compose", - "version": "next", - "title": "Compose prepared mutations over useMutation (drop usePreparedMutation)", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "Removed the usePreparedMutation hook; buildPreparedHandlers(mutation, prepare) stays. Mutation hooks now call useMutation directly and spread { ...mutation, ...buildPreparedHandlers(mutation, prepare) }, dropping five explicit generics and the Mutatable cast. PreparedVars/COALESCED unchanged. Frontend-only, no wire-shape change. Manual: each query.ts hook restructured (import swap + return rewrite + inline prepare input annotation)." - }, - { - "id": "20260723T0739-hierarchy-owned-id-columns", - "version": "next", - "title": "Hierarchy-owned id-column keys and row-location API", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared", - "backend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "EntityHierarchy instance owns row location: idColumnKeys/idColumnKey/idColumnName, resolve*, compute*Path, pathColumnSql, deepestAncestorSql. appConfig.entityIdColumnKeys now derives from hierarchy.idColumnKeys (replace the literal map in config.default.ts). backend pathColumnExpression removed in favor of hierarchy.pathColumnSql. No wire-shape or DB change." - }, - { - "id": "20260723T0802-hierarchy-derived-entity-arrays", - "version": "next", - "title": "Hierarchy-derived entity arrays in config", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared" - ], - "requires": [ - "pnpm check" - ], - "summary": "config.default.ts derives entityTypes/channelEntityTypes/productEntityTypes from the hierarchy via nonEmpty(); bidirectional config-validation checks and EntityIdColumnKeysShape removed. Apps replace their literal arrays with the derived form. No wire or DB change." - }, - { - "id": "20260723T0814-hierarchy-instance-only-api", - "version": "next", - "title": "Hierarchy instance-only row-location API", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "cdc/src", - "frontend/src", - "shared", - "yjs/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "Free row-location functions, entity guards (isChannelEntity/isProductEntity/getChannelRoles), and the AncestorSource/CountsHierarchy/TopologyHierarchy types are removed; the EntityHierarchy instance is the only API. Mechanical rewrite: fn(h, ...) to h.fn(...), guards to hierarchy.isChannel/isProduct/getRoles, topology params typed EntityHierarchy. No wire or DB change." - }, - { - "id": "20260723T0822-drop-product-path-column", - "version": "next", - "title": "Drop the product tables' stored path column", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": true, - "script": null, - "roots": [ - "backend/src", - "backend/drizzle", - "cdc/src" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "Product rows compute their location path from ancestor id columns (hierarchy.computeProductPath) in CDC batching, move detection, and stream notifications; the stored generated column is dropped (wire: product responses lose the path field, clientCacheVersion bumped to v4-no-product-path). Channel tables keep their generated path for channel_counters ancestry." - }, - { - "id": "20260723T0824-public-read-flag", - "version": "next", - "title": "Public read is a flag, not a mode", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared", - "backend/src", - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "Deleted the single-member PublicReadMode union. PublicReadGrants values are now `true`, the config builder's publicRead() takes no argument, and GrantSource's public variant is { type: 'public' }. Decision logic unchanged (still the shared 'public' row condition over the row's publicAt); no wire-shape or database change. Manual: single-token edits at each call site." - }, - { - "id": "20260723T0959-permission-vocabulary", - "version": "next", - "title": "Permission vocabulary consolidation", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared", - "backend/src", - "backend/tests", - "frontend/src", - "yjs/src" - ], - "requires": [ - "pnpm sdk", - "pnpm check" - ], - "summary": "Access/Policy/Permission naming rule: AccessPolicies family -> PolicyMatrix/EntityPolicies/PolicyEntry, accessPolicies -> policyMatrix, PermissionValue/NormalizedPermissionValue -> PolicyCellInput/PolicyCell, ActionPermissionState -> CanState, resolvePermission -> resolveCan, PermissionMembership -> AccessMembership, isAllowed/enabled -> allowed, PermissionTopology removed (options.hierarchy + options.entityActions), config DSL ({ subject, contexts }) -> ({ entityType, channels }), collection scopes subChannelIds -> homeChannelIds/channelIds, AncestorScope -> IntermediateScope, permission-manager/ -> engine/, actor.ts -> access.ts. No wire-shape or DB change." - }, - { - "id": "20260723T1237-typed-nullable-user-and-tree-rows", - "version": "next", - "title": "Nullable store user and TreeItem-constrained tree rows", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "useUserStore().user is MeUser | null (was MeUser seeded with `null as unknown as MeUser`); authenticated call sites use the new useCurrentUser()/getCurrentUser() accessors, which throw while signed out, and anything reachable signed out handles null. useTreeRows requires T extends TreeItem; buildTree is overloaded so other row shapes must pass all three accessors. Also widens getEntityPolicies/getPolicyPermissions to string and gives actorFrom/accessFrom a structural AccessContext (actorFrom now returns { anonymous: true } without a userId). Compile-time detected; no wire-shape or DB change." - }, - { - "id": "20260723T1311-batch-presigned-urls", - "version": "next", - "title": "Batch presigned URLs replace the single presign endpoint", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": true, - "script": null, - "roots": [ - "backend/src", - "frontend/src" - ], - "requires": [ - "pnpm sdk", - "pnpm check" - ], - "summary": "GET /attachments/presigned-url (getPresignedUrl) -> POST /attachments/presigned-urls (getPresignedUrls): up to 50 id+variant items per call, uniform rejectedIds (no 403/404 existence oracle). Backend getPresignedUrlOp/findAttachmentById/presignedUrlQuerySchema -> getPresignedUrlsOp/findAttachmentsByIds/presignedUrlsBodySchema. Frontend getPrivateFileUrlById delegates to the presign-batch coalescer (flush window, in-flight dedupe, 1h memo); per-id denials reject with PresignRejectedError. No DB change." - }, - { - "id": "20260723T1705-media-attachment-ref", - "version": "next", - "title": "Media blocks carry an attachment reference", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src", - "shared", - "yjs/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "Media block specs (image/video/audio/file) gain an attachmentId prop via withAttachmentRef, applied to both the frontend editor schema and the yjs relay's server schema so Y.Docs round-trip it; UppyFilePanel stamps it on upload. Dead checklistGroupConfig plus checklist-group-block/render deleted, the four mediaBlockTypes copies consolidated onto the shared/blocknote export, and derive-description-core added as the shared block walk for description derivation. No wire-shape or DB change." - }, - { - "id": "20260723T2257-deploy-engine-waves", - "version": "next", - "title": "Deploy engine: waved rollout, internal routes, one deploy command", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "infra", - ".github/workflows" - ], - "requires": [ - "pnpm --filter infra compose:generate", - "pnpm check" - ], - "summary": "Two-wave rollout with concurrent cutovers and one deferred reap; single 'infra deploy' command replaces the pulumi/roll-*/publish/smoke jobs (branch-protection checks change to 'deploy'); cdc binds the backend through the LB's ACL-guarded internal route (services.config: backend internalRoute + internalHost/internalPort binding); optional INFRA_PULUMI_DRIVER=automation and INFRA_STACK_TOPOLOGY=micro knobs." - }, - { - "id": "20260729T0922-app-product-mocks", - "version": "next", - "title": "Rename the app product mock registry", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src/mocks", - "cella.config.ts" - ], - "requires": [ - "pnpm check" - ], - "summary": "Rename the pinned product mock registry file and export to app-product-mocks.ts and appProductMocks. Internal extension API only; no wire-shape or database change." - }, - { - "id": "20260730T0425-boot-image-rename", - "version": "next", - "title": "Rename the boot runner image (cella-boot to infra-boot)", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "infra", - ".github/workflows" - ], - "requires": [ - "pnpm --filter infra exec vitest run", - "pnpm check" - ], - "summary": "Boot runner image renamed cella-boot->infra-boot, centralized as BOOT_IMAGE_NAME in infra/lib/scaleway/boot-image.ts (build-images.ts, deploy-pipeline.yml, compute.ts). Immutable generations pin the boot image by name+sha and a digest is only pullable from its own repository, so resolveBootImage falls back to LEGACY_BOOT_IMAGE_NAMES on a 404 (threading the resolved name into cloud-init) and a pre-existing generation whose image is gone degrades to an unpinned tag instead of failing the plan. Auto-migrating: no app code changes; deploy staging first, drop 'cella-boot' from LEGACY_BOOT_IMAGE_NAMES once no environment runs a pre-rename generation. No wire-shape or DB change." - }, - { - "id": "20260730T0624-attachment-keys-map", - "version": "next", - "title": "Attachment variant keys collapse to a single jsonb map", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": true, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts", - "backend/drizzle", - "frontend/src", - "shared", - "bench/src" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "attachments' per-variant originalKey/convertedKey/thumbnailKey/thumbnailTinyKey columns collapse into one keys jsonb map (AttachmentKeys: { original, preview?, thumbnail?, converted? }); selectVariantKey becomes keys[variant] ?? keys.original. Variant vocabulary realigned: mid-size thumbnail->preview, grid-cell thumbnail-tiny->thumbnail (attachmentVariantSchema/BlobVariant = original|preview|thumbnail|converted). Wire-breaking: clientCacheVersion bumped to v8-attachment-keys. DB: drop the four *_key columns, add keys jsonb with a jsonb_build_object backfill (thumbnail_key->preview, thumbnail_tiny_key->thumbnail). Manual: field-specific rename plus a data-preserving migration, no codemod. Apps that home attachments on a product entity keep their own taskId/projectId columns." - }, - { - "id": "20260730T0858-frontend-module-placements", - "version": "next", - "title": "Frontend module registry and UI placements", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src", - "cella.config.ts" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "Frontend *-module.ts(x) files switch registerModule -> defineFrontendModule (~/lib/module); a glob composition root frontend/src/modules.ts loads them before first render. UI placements arrive with settled vocabulary: modules declare tools into slots ('${channelType}.settings', 'account.settings'); built-in settings sections are registered tools and the settings pages are pure consumers. Tools render full cards (lazy), gate on requires (grants) and visibleTo context-role pairs ('organization.admin', hierarchy-validated, matched over the ancestor chain; UI-only). Arrangement layers: manifest defaults, pinned placement-config.ts overrides, then the new organizations.tools_config jsonb (per-slot order/hidden/settings, admin Tools card, fail-closed reconciliation; locked tools immune to channel hiding). organizationSettingsSections (pinned file + type) removed. staticData.navTab typed PlacementDescriptor; system default tab derives from first visible tab. nav-buttons id special-cases move to NavItem iconSlot/badgeSlot in pinned nav-config.tsx. DB: additive tools_config column (pnpm generate); wire: additive optional toolsConfig on organization (no cache bump). Needs @cellajs/cli with the define*Module territory-scan matcher." - }, - { - "id": "20260730T1009-owned-host-embedding", - "version": "next", - "title": "Product host FKs move to owned embeddings", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/drizzle", - "frontend/src", - "shared" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "Child-side product-to-product host FK columns (taskId-style) are deprecated in favor of host-side id arrays registered as lifecycle 'owned' productEmbeddings. The template machinery (owned-embedding GC in the CDC worker, attachmentId media-block refs, derive-description-core collection, propagation id-array patching) already ships and activates on the first 'owned' entry. Template-side this is docs-only: apps without a host FK are unaffected; apps with one flip on their own schedule (host array + GIN, backfill from FK, drop FK and in-request cascades, own cache bump, feat!)." - }, - { - "id": "20260730T1258-cella-config-into-cella-folder", - "version": "next", - "title": "Move cella sync files into the cella/ folder", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "cella.config.ts", - "cella.manifest.json", - "cella.migrations.json" - ], - "requires": [ - "pnpm check" - ], - "summary": "Relocate cella.config.ts, cella.manifest.json and cella.migrations.json from the repo root into the cella/ folder (cella/cella.config.ts, cella/cella.manifest.json, cella/cella.migrations.json). The @cellajs/cli release with cella/-folder discovery finds them there (config loader, MANIFEST_FILE, managed-file match); run.ts reads/writes the applied-set at cella/cella.migrations.json with a read-only fallback to the old root path. Manual git mv, no codemod; sync-breaking, no wire-shape or DB change. App localPath values still resolve against the repo root and the config contents are unchanged." - }, - { - "id": "20260731T0844-iam-model-v2", - "version": "next", - "title": "IAM model v2: per-mode/per-service principals, per-deploy keys, S3 key retirement", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "roots": [ - "infra" - ], - "requires": [ - "pnpm check" - ], - "summary": "Operational migration: per-mode IAM principals in a group, admin app replaces operator, per-service secret folders with resource-level conditions, per-deploy key rotation + single-access handoff, s3 managed key and s3AccessKeyId/Secret removed (backend signs with its service key; S3_* env names unchanged). Run the infra CLI 'Migrate IAM model' per environment." - }, - { - "id": "20260804T1641-headless-settings-placements", - "version": "next", - "title": "Headless settings placements: sections hook, descriptor bases, consumers as presentation", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src", - "backend/src" - ], - "requires": [ - "pnpm check", - "pnpm test" - ], - "summary": "Settings-slot resolution moves into useChannelSettingsSections (grants, context-role pairs, overrides, stored toolsConfig); ChannelSettingsPage is a presentation-only map and a ChannelSettingsSheet reference consumer ships upstream. channelSettingsTools factory removed: spread generalToolBase/detailsToolBase/tabsToolBase/dangerToolBase(channelType, resource) and render full cards (DeleteToolCard exported; general now requires 'update'). getTools applies the section default order 50 (getSlotDescriptors stays raw for tabs); getChannelSettingsTools + ChannelSettingsToolFor removed. Surface trims: SlotToolsConfig.settings key dropped (no reader; stored config = order + hidden), PlacementOverride narrowed to hidden/order, heldContextRoles loses its unused entity-less overload; prefer requires over visibleTo (grants inherit down the ancestor chain). Backend mergeJsonbShallow util replaces inlined jsonb || merge fragments. Supersedes the channelSettingsTools guidance in 20260730T0858 step 5. No DB change, no cache bump. Arrangement UI pivots to tabs: ToolsArrangementCard deleted, TabsArrangementCard (flat data grid, row drag + visibility switches, writes toolsConfig['<channelType>.tabs']) demoed on organization settings via getNavTabCandidates (new ungated export from tab-nav); organization settings navTab now locked. toolsConfig is UI visibility only, never authorization — enforcement belongs to permissions/quotas." - }, - { - "id": "20260811T0905-ts-import-extensions", - "version": "next", - "title": "Explicit .ts import extensions in the Vite config-load graph", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared", - "frontend/vite", - "frontend/vite.config.ts" - ], - "requires": [ - "pnpm check" - ], - "summary": "Vite 8's future-default configLoader 'native' needs fully-specified ESM imports; vite.config.ts loads the whole shared/ graph at config time. All relative imports in shared/ and frontend/vite/ gain .ts extensions, directory-index imports name index.ts explicitly, __dirname becomes import.meta.dirname, allowImportingTsExtensions hoists to root tsconfig, and a biome.jsonc override enforces correctness/useImportExtensions over the territory. Codemod = Biome's own fixer (see README Run). Not sync-breaking, no cache bump, no DB." - }, - { - "id": "20260812T1724-deploy-vocabulary", - "version": "next", - "title": "Deploy vocabulary: start-first/stop-first, pathPrefix, storeOutputs", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "infra" - ], - "requires": [ - "pnpm --filter infra exec vitest run", - "pnpm check" - ], - "summary": "Planned generation roll: replacementStrategy values lb-overlap->start-first / exclusive->stop-first, lbPathBegin->pathPrefix (field + emitted x-service key), genId fingerprint key runMigrate->runRelease (deliberately re-rolls every generation on first deploy), flat db* stack outputs retired in favor of storeOutputs.<storeId>.<key> (db-exposure/seed CLI reads the primary store entry). Fork change is three seds in infra/config/services.config.ts plus any fork-local reader of the retired outputs; regenerate compose.gen.yml." - }, - { - "id": "20260816T0704-dev-port-offsets", - "version": "next", - "title": "Dev service ports become one config knob (devPorts)", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared/config", - "backend/src", - "cdc/src", - "yjs/src", - "mcp/src", - "frontend/vite.config.ts" - ], - "requires": [ - "pnpm check" - ], - "summary": "Service listen ports and Vite proxy targets default from a single devPorts block in config.default.ts (api 4000, cdcHealth 4001, yjs 4002, mcp 4003) instead of five hardcodes; PORT-style env vars still override. Not sync-breaking and defaults are unchanged, but forks must add an offset devPorts override (paired with their frontendUrl port) to stop parallel stacks colliding on :4000, where the first backend up answers every fork's /api proxy. Also drop stale PORT= lines from backend/.env." - }, - { - "id": "20260817T1053-product-view-count-helpers", - "version": "next", - "title": "Product view-count helpers move to the entities module", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "findAttachmentViewCount is deleted; the shared findProductViewCount / productViewCountSelect / productViewCountJoin (entities-queries.ts) and productViewCountSchema (entities-schema.ts) replace the per-module copies, and the attachment module is rewritten onto them. No DB or wire change. Forks: swap imports (opts key entityId->productId), replace inline coalesce-select/leftJoin/schema fields with the helpers, delete fork-local duplicates like findItemViewCount. Apps that PIN the attachment module must hand-apply the rewrite or their pinned copy silently keeps the deleted-export pattern." - }, - { - "id": "20260817T1055-tools-config-channel-columns", - "version": "next", - "title": "toolsConfig moves into channelColumns()", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "The toolsConfig jsonb column (sparse, NOT NULL DEFAULT '{}') moves from organizations into the shared channelColumns() factory; mockChannelColumns mirrors it. Additive DB change: every fork channel table gains tools_config on the next pnpm generate (dormant where unused; no backfill). CRITICAL: apps that hand-copied the column per 20260730T0858 step 5 must DELETE their local declaration — a leftover explicit key after the spread silently wins with no TS error. Supersedes the hand-copy instruction in 20260730T0858." - }, - { - "id": "20260817T1447-remove-filter-tab-ids", - "version": "next", - "title": "Remove the filterTabIds allow-list from nav tabs", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "PageTabNav/resolveNavTabs lose the filterTabIds prop: an imperative allow-list with zero template consumers that the nav-tab guards never honored, so allow-list-hidden tabs stayed URL-reachable and could become the landing tab. Apps passing it must express each gate declaratively before syncing: navTab.requires + grants for permission gates, visibleTo + pairs for role gates on registry tabs, or placementOverrides/route removal for hard removal. Compile error at sync until call sites are migrated." - }, - { - "id": "20260817T2052-comment-budget", - "version": "next", - "title": "Comment budget: delete comments that restate their own code", - "kind": "codemod", - "syncBreaking": false, - "clientCacheBump": false, - "script": "trim-comment-budget.ts", - "roots": [ - "frontend/src", - "backend/src", - "shared", - "cdc/src", - "yjs/src", - "infra" - ], - "requires": [ - "pnpm lint:fix", - "pnpm check" - ], - "summary": "check-frontend-style.ts loses the export-description rule, which required a JSDoc on every exported function and const and so produced hundreds of docs paraphrasing their own identifier. cella/AGENTS.md gains a Comment budget (members, locals, JSX, no-repeats) governing which comments earn their place. trim-comment-budget.ts deletes the docs the old rule produced under two conservative rules, name-restating and boilerplate, protecting tool directives, @tag JSDoc, TODO/FIXME, fork: markers, .stories. files and generated trees. A third duplicate rule is report-only: it cannot tell a copied note from the same local pattern recurring. Comment-only, so an app may skip it, at the cost of comment-only conflicts in shared files on the next sync." - }, - { - "id": "20260821T1532-app-table-classifications-and-jobs", - "version": "next", - "title": "App table classifications and scheduled jobs register from app-owned code", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/scripts/migrations", - "backend/tests" - ], - "requires": [ - "pnpm check" - ], - "summary": "backend/src/tables.ts (pinned) gains appPartitionConfigs, appFullCrudTables and appReadOnlyTables; the partman and RLS migrations, the verify block and partman-parity.test.ts merge them after cella's entries. defineBackendModule gains a jobs slot ({ name, start }) that main.api.ts starts under the migration-owner guard and stops on shutdown; scheduleDbMaintenance is the first registrant. Apps move their partition/grant entries into tables.ts and their cron jobs onto the module, then drop the edits to the four cella-owned files." - }, - { - "id": "20260828T1711-formlabel-help-popover", - "version": "next", - "title": "FormDescription removed: field help moves into a FormLabel popover", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "field.tsx no longer exports FormDescription; FormLabel gains a help prop rendering a question-mark popover. Fork call sites move description content into help={...} on the sibling FormLabel and drop the import; bare description paragraphs switch to FieldDescription (now a plain <p>, collapse behavior gone)." - }, - { - "id": "20260828T2030-elevated-grants-root-roles", - "version": "next", - "title": "Per-channel elevatedGrants and explicit rootRoles", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "shared", - "backend/src", - "frontend/src" - ], - "requires": [ - "pnpm check", - "pnpm test:core" - ], - "summary": "Global elevatedRoles export removed: channels declare elevated (compiled to hierarchy.elevatedGrants ${channelType}:${role} keys); an empty set makes non-home grants home-scoped, so forks must declare elevation to keep subtree reads. resolveParentMembershipRole splits into resolveAssociatedMembershipRole and a throwing resolveRootMembershipRole; channels auto-creating root membership rows declare a complete rootRoles map. Tests inject elevation via elevateAcross." - }, - { - "id": "20260828T2157-notifications-module", - "version": "next", - "title": "Notifications module: inbox, mentions and email digest", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "locales" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "New dormant-by-default notifications module: per-recipient inbox (partitioned notifications table + notification_preferences), mention derivation, permission/mute-filtered fan-out, instant mention email, daily/weekly digest job. Product modules opt in via a notifications source declaration on defineBackendModule (mentionable/loadRows/writeMentions/resolveRecipients/resolveContextId/loadPreview/loadContextNames/resolveEmailLink). Apps run pnpm generate for the tables, add the bell to their pinned nav-config, and (if they forked notifications before) reconcile onto the cella module with item_id renamed to context_id." - }, - { - "id": "20260831T1533-web-push", - "version": "next", - "title": "Web Push for notifications; Periodic Background Sync retired", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "shared/config" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "New push module: push_subscriptions table (unique-endpoint upsert, grants only, no CDC), /push subscribe/unsubscribe/VAPID routes, sender delivering {t:'notif', activityId, channelId, type} to offline subscribers of fresh notification rows (online users subtracted via their SSE user channel; 404/410 prunes, 429 backs off). Settings card gains a per-device toggle; SW gains push/notificationclick/pushsubscriptionchange and loses the periodicsync badge path. Double-gated: has.push config flag AND VAPID_* env keys. Apps run pnpm generate, set keys + flag to enable, and verify on desktop Chrome plus an installed iOS PWA." - }, - { - "id": "20260902T0906-generic-channel-path-resolver", - "version": "next", - "title": "Generic channel-path resolver replaces the register-channel-paths seam", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src", - "cella" - ], - "requires": [ - "pnpm check" - ], - "summary": "resolveChannelPath in view-declaration.ts now reads the server-computed path off cached rows of every non-root hierarchy channel type itself; registerChannelPathResolver and the pinned frontend/src/query/realtime/register-channel-paths.ts seam are gone. Apps delete the file, drop its side-effect import from list-queries-config.tsx and remove the pinned entry from cella/cella.config.ts." - }, - { - "id": "20260902T0939-role-vocabulary-from-hierarchy", - "version": "next", - "title": "Role vocabulary derived from the hierarchy", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend", - "frontend/src", - "shared/src" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "hierarchy.rootChannelType, getLeastPrivilegedRole and getMostPrivilegedRole replace every 'member' literal in cella tests, stories, email previews, membership column defaults and the invite default role. Apps with another vocabulary drop their `fork: role vocabulary` markers and take upstream." - }, - { - "id": "20260902T0945-attachment-placement-seam-v2", - "version": "next", - "title": "Attachment placement seam v2", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend", - "frontend/src", - "shared/config" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "The pinned attachment-placement seam gains attachmentHomeColumnKey, resolveAttachmentHomeScope and seedAttachmentPlacements; list reads take a generic channelId param; channelRelationColumns adds lazy ancestor foreign keys via registerChannelTable plus channelRelationIndexes; appConfig.attachmentUploadTargets gates the upload affordance; frontend placement handling is hierarchy-derived. Apps extend the seam file, register their channel tables, add the config key and take upstream for the surrounding files." - }, - { - "id": "20260902T0949-generic-app-adoptions", - "version": "next", - "title": "Generic app improvements adopted upstream", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "shared" - ], - "requires": [ - "pnpm sdk", - "pnpm check" - ], - "summary": "tenantReadAs, wider resolveEmailLink input, getCreatedChannelRoute/getNearestAncestorRoute, text-compared host ids in recalculate-counters, derive-description-core in the frontend, a ./config/* subpath export on the shared package, appConfig.memberStatProductTypes driving member stats, and channelRouteConfig.notificationSearch. Apps add the config key and the package export, then take upstream for the listed files." - }, - { - "id": "20260902T0950-brand-and-app-locale-ignored", - "version": "next", - "title": "Brand assets and the app locale namespace are ignored, not pinned", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "cella", - "locales" - ], - "requires": [ - "pnpm check" - ], - "summary": "favicons, thumbnail.png, logo.tsx, legal-config.ts and locales/*/app.json move from pinned to ignored in the template cella.config.ts; cella's onboarding copy moves from app.json to common.json. Apps take upstream's lists, drop stale ignores and re-adopt comment-only pins." - }, - { - "id": "20260902T1355-members-config-and-hierarchy-test-seeds", - "version": "next", - "title": "Members table config seam and hierarchy-aware test seeds", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src", - "backend/src/mocks", - "backend/tests", - "yjs/src/tests" - ], - "requires": [ - "pnpm check", - "pnpm test" - ], - "summary": "memberStatIcons and hiddenMemberCountColumns move out of members-columns.tsx into the new pinned frontend/src/members-config.ts; buildInsertableProduct, the CDC event-bus test and the yjs permissions test derive ancestor ids from the hierarchy instead of inventing them. Apps add the pin, move their icon and hidden-column values into members-config.ts, and take upstream for the four files." - }, - { - "id": "20260902T1536-seam-consolidation", - "version": "next", - "title": "Seam consolidation: derived memberships, module routes, product tables, app schemas", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "cella" - ], - "requires": [ - "pnpm generate", - "pnpm check", - "pnpm test" - ], - "summary": "memberships-db.ts, routes.ts and tables.ts lose their pins: membership channel columns and indexes derive from the hierarchy, route mounts are declared on defineBackendModule (routes with a phase), entityTables derives from channel-tables.ts plus the new pinned product-tables.ts (also home of the app partition and grant lists). setup-config-schema.ts and app-channel-counts.ts merge into the pinned schemas/app-schemas.ts. user-profile-content.tsx hosts a user.profile slot (cella's organizations grid is a tool). Template pins add modules.ts and drop nav-config.tsx, onboarding-config.ts and user-profile-content.tsx. Apps move their fills, take upstream for the five files, and run generate (index-only migration for sub-root channels)." - }, - { - "id": "20260903T0640-notifications-contract", - "version": "next", - "title": "Notifications contract: table-derived sources, attachment mentions, /n deep link", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "shared", - "yjs/src", - "locales" - ], - "requires": [ - "pnpm generate", - "pnpm check", - "pnpm test:core" - ], - "summary": "Notification sources derive from the product table (notifications: true; live rows, mentions writing via mentionableColumns, previews and digest names, deriveFrom both with a yjsMaterializer), apps declare only resolveRecipients/resolveContextId. Email and push links are self-describing /n links resolved by the frontend, resolveEmailLink is gone. Attachments are the template consumer (mentions column, description update op, materializer, collaborative editor in a sheet; useDescriptionUpdate is the shared persistence policy). appNotificationTypes extends the type enum and labels. Shared server BlockNote schema; pnpm style app allowlist. Inbox items carry actor, channelName and subjectTitle for sentence-style cards; channelRouteConfig.notificationSearch opens the subject; a notifications seed on mockSeedNotification." - }, - { - "id": "20260904T0746-organization-spine", - "version": "next", - "title": "Organization is the spine: no configurable root channel, explicit request scope", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "frontend/src", - "shared", - "cdc/src", - "yjs/src" - ], - "requires": [ - "pnpm generate", - "pnpm check", - "pnpm test:core" - ], - "summary": "The hierarchy builder declares the organization with organization({ roles, elevated }) as the only parentless entity and channel() requires a parent; hierarchy.rootChannelType, RootChannelType, rootRoles/getRootRole (now organizationRoles/getOrganizationRole), resolveRootMembershipRole (now resolveOrganizationMembershipRole) and the rootChannelId test-plan option (now organizationId) are gone, and every root-detection idiom becomes the literal 'organization'. Organization-bound tables declare organizationForeignKey(table). getValidProduct requires tenant + organization scope on the request context (500 otherwise) and reads wrong-tenant, wrong-organization, deleted, draft and missing rows as one 404 before the engine; getValidChannel compares whichever of the two the context set. No DB or wire change." - }, - { - "id": "20260904T0846-rls-defense-in-depth", - "version": "next", - "title": "RLS defense in depth: explicit request scope, lazy admin credential, verified catalog, runtime-role parity run", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/scripts", - "backend/tests", - "yjs/src", - ".github/workflows" - ], - "requires": [ - "pnpm generate", - "pnpm check", - "pnpm test:core", - "pnpm test:core:runtime" - ], - "summary": "requestScopeWhere(ctx, table) adds the guarded tenant + organization predicate to every organization-bound product query and splitByPermission rejects unknown and out-of-scope ids, so removing RLS broadens no application query. The admin pool is lazy (getAdminDb(purpose), getSeedDb(); migrationDb, unsafeInternalAdminDb and seedDb are gone) and DATABASE_ADMIN_URL is optional for the request-serving API. The RLS and verify side-effect blocks refuse to run without runtime_role and admin_role; the verifier asserts owner, the four-policy contract (rlsPolicyContract), grants per classification and that runtime_role has no BYPASSRLS. Test global setup creates roles before migrating and refuses a degraded volume; verification tests inspect the catalog without repairing it. The backend suite also runs as runtime_role (test:core:runtime, CI step). The Yjs startup sweep lists and deletes per tenant inside tenant-scoped transactions; deleteStaleDoc takes the row." - }, - { - "id": "20260904T0947-rls-owner-bypass", - "version": "next", - "title": "RLS owner bypass replaces BYPASSRLS: NO FORCE RLS, effective CDC role probe, smoke warnings", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/scripts", - "backend/tests", - "cdc/src", - "infra" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "RLS tables are ENABLE (never FORCE) so admin_role bypasses as owner; Scaleway cannot grant BYPASSRLS (found by the 0.10.0 smoke failure). create-db-roles and the test setup create admin_role without BYPASSRLS; verify asserts enabled-not-forced. CDC worker probes effective bypass per table (rlsBypass/rlsBlockedTables, health reason rls_bypass_missing replaces role_missing_bypassrls). Smoke results are ok/warn/fail (degraded warns via ::warning::, unhealthy fails); infra status gains live.components on the shared lib/health-components.ts." - }, - { - "id": "20260904T1349-table-bar-sticky-cleanup", - "version": "next", - "title": "Table filter bars drop the dead sticky wiring", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "TableBarContainer no longer wraps filter rows in StickyBox: enableSticky defaulted to false and no bar ever set it, so offsetTop, enableSticky, the focusView subscription and the group/sticky classes were dead. The container is now a plain flex row plus the search-vars scroll reset, and EntityGridBar drops its isSheet prop (it only fed offsetTop). App-owned bars remove offsetTop from <TableBarContainer> and isSheet from <EntityGridBar>. StickyBox itself is untouched." - }, - { - "id": "20260907T0619-route-tree-generation-script", - "version": "next", - "title": "Route tree generation script", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "package.json", - "frontend/package.json", - "frontend/vite.config.ts" - ], - "requires": [ - "pnpm install", - "pnpm generate:routes", - "pnpm check" - ], - "summary": "pnpm generate:routes (root) and pnpm gen:routes (frontend) regenerate frontend/src/routes/routeTree.gen.ts via frontend/vite/generate-routes.ts and @tanstack/router-generator, using the router options vite.config.ts now imports from frontend/vite/router-options.ts. The generated tree is app-owned and never syncs, so a sync that adds a route file failed typecheck until a Vite build or dev server ran. Apps add the two scripts and the dev dependency by hand; the vite/ files arrive with the sync." - }, - { - "id": "20260907T0711-description-seed-preview-hooks", - "version": "next", - "title": "Seed description builder, sheet static preview, shared description-update halves", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/scripts", - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "backend/scripts/seeds/description-document.ts is the one seed block builder (paragraphBlock, textDocument, mentionDocument) with explicit default props in BlockNote's key order so seeded documents round-trip the relay seed and the editor's on-load comparison; attachment and notifications seeds use it, apps drop local builders (raak createDescription, both mentionDocument). Attachment description sheet renders a faded BlockNoteFullHtml as waitingFallback. Apps compose patchCollaborativeDescription/persistStandaloneDescription in their description-update hooks (pc material hook is the reference; raak task hook and pc item hook still carry copies). 20-attachment.seed.ts conflicts on sync after cella#1146." - }, - { - "id": "20260909T0740-yjs-update-log", - "version": "next", - "title": "Yjs relay: append-only update log, per-socket ordering, compaction", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "yjs/src", - "backend/src/modules/yjs", - "backend/scripts/migrations", - "backend/tests", - "frontend/src/modules/common/blocknote" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "The relay lost the first update of a burst (in-memory merge across awaits, no ordering). New yjs_updates table: every frame is appended before broadcast, each socket's frames run in order through a serial queue, compaction merges the log into yjs_documents.state under a per-document lock and deletes exactly the rows it read; last_edited_by dropped in favor of per-row user_id. Server sends its own Step1 so clients upload what the relay lacks; client watches pendingStructs and resyncs. Storage API renamed (loadBase/ensureDoc/appendUpdate/readLog/compactState/deleteDoc), materializeState folded into compactDocument, YJS_SAVE_DEBOUNCE_MS -> YJS_COMPACT_DEBOUNCE_MS, retry timers removed. Apps run pnpm generate and add yjs_updates to any RLS table list of their own." - }, - { - "id": "20260911T0759-registry-owned-iam-principals", - "version": "next", - "title": "VM IAM principals and policies follow the service registry", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "infra" - ], - "requires": [ - "pnpm --filter infra exec vitest run", - "pnpm check" - ], - "summary": "Bootstrap-owned IAM (vm-<service> applications, their path-conditioned policies, the singleVM host condition) derives from the service registry; compute, LB and key minting keep deriving from the enabled set. Toggling appConfig.services.<slug>.enabled needs no Apply infra change; adding a registry service or flipping singleVM does, and Apply creates the missing applications itself. Dormant principals must hold zero API keys: the deploy asserts it and the key mint purges them. One Apply infra change per bootstrapped stack after syncing (cella: host condition gains /mcp/; raak: new vm-yjs and vm-mcp principals)." - }, - { - "id": "20260917T1850-sign-out-lifecycle", - "version": "next", - "title": "Sign-out lifecycle: session-bound streams and cross-tab teardown", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend", - "backend" - ], - "requires": [ - "pnpm --filter backend exec vitest run src/modules/entities/stream", - "pnpm check" - ], - "summary": "The sign-out route flushes seen batches and drops the push subscription before the session ends; teardownUserState clears the app badge. LocalUserDatabase closes for good on a delete from another tab and runs deletedElsewhereListeners, so every tab of the user signs out with the deleting one instead of recreating the database. Backend: ctx.var.sessionId and AppStreamSubscriber.sessionId; sign-out and session termination emit authEvents session.deleted, and the entities listeners close the matching SSE streams with the unauthorized error; keepAlive now exits on closed or aborted streams." - }, - { - "id": "20260917T2101-email-verification-token-removed", - "version": "next", - "title": "The email-verification token type is removed", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "shared/config", - "locales" - ], - "requires": [ - "pnpm sdk", - "pnpm check" - ], - "summary": "sendVerificationEmail had no caller since password sign-up was removed, so the whole path goes: the sender, handleEmailVerification, the email-verification template and invokeToken branch, the 'email-verification' entry in tokenTypes, and its error and email locale keys. deleteVerificationTokens became deleteOAuthVerificationTokens (identityId since 20260918T0831); the OAuth mail subject moved to email.oauth_verification.subject. No database change (tokens.type is an unconstrained varchar). Apps remove 'email-verification' from their own tokenTypes and drop the locale keys; the /auth/email-verification page stays for the OAuth flows." - }, - { - "id": "20260918T0711-email-ledger-proof-stamps", - "version": "next", - "title": "The emails table records inbox proofs", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "emails becomes the ledger of inboxes proven per account: lastVerifiedBy ('magic' or the OAuth provider whose verification mail was clicked) and lastVerifiedAt are stamped on every proof, verified/verifiedAt on the first. The OAuth verify click adds a differing provider address to the ledger through addProvenEmail (409 oauth_conflict when another account holds it), so a magic link for that address signs in to the same account and invitations to it bind directly. markEmailVerified takes by. Two nullable columns added and the write-only emails.tokenId dropped: apps run pnpm generate. Nothing is deleted as a side effect." - }, - { - "id": "20260918T0831-identities-table", - "version": "next", - "title": "oauth_accounts becomes identities, keyed on the provider subject", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "oauth_accounts is renamed to identities, the one table for every external identity of a user (social OAuth now, SSO and LTI later). Identity is (provider, providerUserId, issuer) through a unique expression index; the asserted email is a nullable display snapshot, out of the key and out of the callback lookup, refreshed with lastUsedAt on every sign-in. New nullable columns kind (default 'oauth'), issuer, connectionId, data. tokens.oauthAccountId becomes tokens.identityId; deleteOAuthVerificationTokens takes identityId. Apps run pnpm generate with the rename hints from the README so rows survive, swap the import, and rename the token column in their own code." - }, - { - "id": "20260918T1245-invitee-onboarding", - "version": "next", - "title": "Onboarding picks its steps from the user's invitations", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm check" - ], - "summary": "Onboarding no longer sends an invited user through the create-organization steps. getOnboardingSteps takes { hasOrganizations, hasInvitations } and every step has a when: a user with pending invitations gets an invitations step (explicit accept or reject per invitation) and the profile step, a user with neither organizations nor invitations keeps the three founder steps, a member gets profile only. The /welcome route loads both queries before the step list locks, the footer reads currentStep from the stepper, and the completed screen links into the first organization or offers the menu's organization createAction. Apps that never edited frontend/src/modules/home/onboarding are unaffected; apps with their own steps add a when to each and pass the context." - }, - { - "id": "20260921T1433-devices-table", - "version": "next", - "title": "Sign-ins enroll the browser in a devices table", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "New devices table (devicesTable in backend/src/modules/auth/devices-db.ts) remembers which browsers a user signed in from, keyed on (userId, deviceIdHash), the per-user HMAC of the device-id cookie. createSession enrolls the browser on every non-impersonation sign-in; a first insert on an account that signed in before sends the new-sign-in account security email, except after a magic link or email sign-in and beyond three notices per user per day. A daily prune-devices job removes rows unseen for 400 days and keeps 50 per user. In the same change the device-id cookie becomes SameSite Lax so OAuth callbacks and emailed links can read it, mfa sessions get a device id, the same-browser replace and the maxSessionsPerUser cap, and account security emails escape their details. Apps run pnpm generate; an app that lists tables by hand adds devices." - }, - { - "id": "20260921T1436-identity-issuer-slug", - "version": "next", - "title": "identities are keyed on (kind, issuer, subject); provider becomes the issuer slug", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "identities.provider and the nullable identities.issuer merge into one not-null issuer, always a slug namespaced by kind (a supported OAuth provider for 'oauth', an issuer-registry entry for sso and lti later); the issuer URL stays in config. providerUserId becomes subject, and the unique index is identities_kind_issuer_subject_idx on (kind, issuer, subject). Social lookups are scoped to kind = 'oauth'. No API change. Apps already on identities run pnpm generate with the README hints and add UPDATE identities SET issuer = provider above the DROP COLUMN so rows survive; apps still on oauth_accounts use the combined hints from the README in place of those in 20260918T0831-identities-table and get a plain rename." - }, - { - "id": "20260921T1629-node-26", - "version": "next", - "title": "Node.js 26 is the required runtime", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - ".github/workflows", - "frontend" - ], - "requires": [ - "pnpm install", - "pnpm check" - ], - "summary": "engines.node moves from 24.x to 26.x in every package, and CI workflows, Dockerfile, infra/boot/Dockerfile, the infra boot:build target and @types/node follow. Every developer machine, CI runner and image needs Node 26. Node 25+ defines a global localStorage that is undefined without --localstorage-file, which makes zustand persist crash on write in node-env vitest runs; frontend/vitest.setup.ts now deletes that global when there is no window. Synced files arrive migrated. Apps update their own workflows, Dockerfiles, Node version pins and engines fields, and copy the vitest guard into any setup file of their own. Node 25+ images ship no corepack, so a Dockerfile installs pnpm from npm at the packageManager pin." - }, - { - "id": "20260921T1945-devices-table-slim", - "version": "next", - "title": "devices keeps only what is read: the key and three timestamps", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "devices drops lastStrategy, deviceName, deviceType, deviceOs, browser and ipCountry: every sign-in wrote them and nothing read them, since the new sign-in notice builds its text from the request and the sessions list reads the same facts from the session row. The table is now (userId, deviceIdHash, firstSeenAt, lastSeenAt, notifiedAt) and enrollDevice(userId, deviceId) loses its context and strategy parameters. No API change. Apps run pnpm generate (six DROP COLUMN statements, no prompts; an app applying this together with 20260921T1433-devices-table gets the table in its final shape), drop the last two arguments from their own enrollDevice calls, and read a dropped column from the session row of that sign-in." - }, - { - "id": "20260922T1210-principals", - "version": "next", - "title": "principals: one actor table behind every provenance column", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/scripts", - "backend/tests" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "New principals (id, kind) table: users.id is a foreign key to it and createdBy/updatedBy/deletedBy in productColumns and channelColumns reference principals instead of users, so a later service account can be named in provenance with a real constraint. Users are inserted through insertUsers(), which writes the principal row first in one transaction; any direct db.insert(usersTable) fails on the new foreign key. AuthContext is renamed UserContext (a signed-in user) and ActorContext is its new supertype, carrying one actor { kind, id, grants } variable that accessFrom/actorFrom read; machine-callable operations are typed on it. Error bodies gain requestId. Apps rename AuthContext to UserContext with one perl/sed pass, run pnpm generate with one create hint per provenance foreign key, add the principals backfill INSERT after CREATE TABLE in the generated SQL, route user inserts through insertUsers, add principals to test TRUNCATE lists, and retype operations that only need the actor's id." - }, - { - "id": "20260922T1600-service-accounts", - "version": "next", - "title": "service accounts and API keys: the first machine principal", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "shared/config" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "Adds service_accounts (a tenant-scoped machine principal with role bindings in bindings, disabled never deleted) and api_keys (opaque API keys stored as hashes, with a scopes mask). serviceGuard authenticates Authorization: Bearer <app>_sk_live_… as the service account and actorGuard accepts a session or a key on routes whose operations take ActorContext. Scopes are derived from the policy matrix (scopes next to policyMatrix, <entityType>:read|write) and masked in checkAccess and collection reads; the points limiter keys on (tenantId, principalId); CSRF is skipped for machine credentials. Apps rename authGuard to userGuard with one perl pass (userGuard = session only, serviceGuard = API key, actorGuard = either), run pnpm generate and pnpm sdk, add the two tables to the RLS grant list and test TRUNCATE lists, switch machine-callable routes to actorGuard, give hand-built test contexts an actor ({ kind: 'user', id, grants, scopes: null }), rename the shared SQL actor type Actor to PredicateActor, narrow grants to membership rows with isMembershipRow where a membership is returned, and type operations that read the organization as OrgContext. Id brands: user-referencing columns are UserId, principal-referencing ones PrincipalId, service_accounts.id ServiceAccountId; only a service id (or actor.id) into a user column fails to compile. backend/tsconfig.json turns on noImplicitOverride and noImplicitReturns. Review round: service_accounts.updatedBy and credentials.revokedBy (migration audit_principals), queries take (ctx, opts) and never throw, one operation per file, credential cache by hash, guards declare their OpenAPI security, Access.scopes is required." - }, - { - "id": "20260922T1900-oauth-server", - "version": "next", - "title": "OAuth authorization server: keystore, consent, connected apps", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "frontend/src", - "shared/config", - "infra/config", - "locales" - ], - "requires": [ - "pnpm check", - "pnpm generate", - "pnpm generate:routes", - "pnpm infra:compose", - "pnpm install", - "pnpm sdk" - ], - "summary": "Adds node-oidc-provider as the MODE=oauth process on the same origin under /oauth/* (AUTH_SUBSTRATE_PLAN Phase D): oidc_payloads adapter, signing_keys (RS256, encrypted private JWK, current + next), oauth_clients for pre-registered apps, service_accounts.oauth_client_id for installations, tenants.restrictions.allowConsentedClients. Grant types authorization_code + refresh_token + client_credentials; client auth none (Client ID Metadata Documents) and client_secret_basic (clients rows and service accounts with their secret keys). Every token carries an RFC 8707 resource (<backendUrl>/t/<tenant> or <mcpUrl>/<tenant>/<org>/mcp). Consent page at /oauth/consent reads the session; GET/DELETE /me/connected-apps list and revoke grants; serviceGuard and actorGuard accept the JWT and resolve the consenting user (masked by token scopes) or the service account. Apps add oauthUrl, services.oauth and devPorts.oauth to config, the three tables to the RLS grant list, the oauth service to registry-enumerating infra tests, and the locale keys. Review round: oauth/ workspace package and singleVM fold (add oauth to pnpm-workspace.yaml), consent page at /auth/consent, oidc_payloads.account_id + hourly sweep job, unique current/next signing key, API-face protected resource metadata, health probe." - }, - { - "id": "20260922T1930-mcp-substrate", - "version": "next", - "title": "MCP on the substrate: tokens only, tools from routes", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "shared/config" - ], - "requires": [ - "pnpm install", - "pnpm sdk", - "pnpm check" - ], - "summary": "The MCP endpoint sits behind tokenGuard (access tokens from the authorization server only; sessions and API keys refused) and answers tokenless calls with the RFC 9728 challenge; a public route publishes the protected resource metadata. Tools are routes: createXRoute registers a route carrying x-tool ({ enabled, description, approvalRequired, category, entity, execute }) in the core tool registry, deriving the input schema from the route request with the sync transaction left out and rebuilt server-side; tools/list carries annotations and the required scope, tools/call outside the token scopes answers 403 insufficient_scope with the scope to step up to. The attachment module ships the showcase (list, get, create, update, delete). OpenAPI gains an oauth2 security scheme. buildTools and @tanstack/ai are gone; apps move their tools onto routes and enable services.mcp in development and test config." - }, - { - "id": "20260922T2010-app-voice", - "version": "next", - "title": "app voice: no product name in identifiers and wire strings", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "frontend/src", - "shared/scripts", - "cella" - ], - "requires": [ - "pnpm sdk", - "pnpm vocabulary:check" - ], - "summary": "The domain verification TXT record is _<appConfig.slug>-verification.<domain> (was _cella-verification) and the HKDF salt behind encryptData is the neutral constant data-encryption (was cella:data-encryption), which re-keys every stored ciphertext: TOTP secrets encrypted before the change no longer decrypt, so delete them (no users) or keep the old salt with a marker. pnpm vocabulary:check gains a product-name rule rejecting cella_*, Cella*, _cella-* identifiers and cellajs.com literals in backend/src, shared/src and frontend/src; cella/AGENTS.md records that cella in code names the template only where it contrasts with the app." - }, - { - "id": "20260923T0803-auth-renames-3", - "version": "next", - "title": "auth substrate renames, round 3: MissingAncestorError, allowUnregisteredClients, MCP_URL", - "kind": "codemod", - "syncBreaking": true, - "clientCacheBump": true, - "script": "cella/migrations/20260923T0803-auth-renames-3/auth-renames-3.ts", - "roots": [ - "backend/src", - "shared/src", - "frontend/src", - "yjs/src", - "locales", - "infra/config" - ], - "requires": [ - "pnpm sdk", - "pnpm --filter infra compose:generate" - ], - "summary": "MissingScopeError/missing_scope -> MissingAncestorError/missing_ancestor (engine error for an absent ancestor channel id; no longer shares a word with RFC 6750 insufficient_scope); tenant restriction allowConsentedClients -> allowUnregisteredClients and refusal clients_not_allowed -> unregistered_clients_not_allowed (it gates consent to OAuth clients with no registration), with a migration rewriting the column default and stored rows; env MCP_API_URL -> MCP_URL. Tenant wire shape changes: clientCacheVersion v9-tenant-restrictions." - }, - { - "id": "20260923T0835-auth-resource-activities", - "version": "next", - "title": "service accounts, API keys and OAuth clients are activity resources; one secret-column registry", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "cdc/src", - "yjs/src" - ], - "requires": [ - "pnpm generate", - "pnpm sdk" - ], - "summary": "resourceTypes gains service_account, api_key and oauth_client (resourceTables maps them), so key minting and revocation, account changes and client registration land in activities; user-owned rows stay out and are covered by notifications. Secret columns are declared once in backend/src/db/secret-columns.ts (secretColumns by table name, secretLookingColumns with reasons, secretColumnPattern): createSelectSchema omits them from every response schema (hand .omit() calls for them become type errors), lib/redact-keys.ts derives the pino redact paths for the backend and the workers (createWorkerLog takes them as third argument; redactedFields moved out of lib/pino.ts), and the CDC worker strips them from the row image via compactRowData(tableMeta, rowData). A CDC test globs every *-db.ts and fails on a column ending in hash, secret, jwk, token or password that is in neither map. createActivity attributes an update to revokedBy when updatedBy is absent. pnpm generate adds the three tables to cdc_pub with REPLICA IDENTITY FULL." - }, - { - "id": "20260923T0850-geoip-bucket-source", - "version": "next", - "title": "GeoIP databases come from the public bucket, not the image", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "Dockerfile", - "infra/tasks", - "locales/en" - ], - "requires": [ - "pnpm check" - ], - "summary": "The Docker geoip build stage and backend/scripts/download-geoip.ts are removed; backend/src/lib/geoip.ts downloads the DB-IP Lite databases from GEOIP_SOURCE_URL (default: the geoip/ prefix of the app's public bucket, the shared template bucket in development) at boot and daily, and geolocates GEOIP_DEV_SAMPLE_IP instead of loopback in development. infra/tasks/geoip-refresh.ts publishes the data: pnpm infra → Refresh GeoIP data, the deploy pipeline with a 35-day gate, and the monthly geoip-refresh workflow. print-deploy-env emits public_bucket. The new sign-in notice drops its location line when the country is unknown ({{- location}} plus the email.account_security.location key)." - }, - { - "id": "20260923T1200-partition-maintenance-pg-cron", - "version": "next", - "title": "partition retention moves from pg_partman to maintain_partitions() and pg_cron", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/scripts", - "backend/src", - "backend/db", - "infra/resources" - ], - "requires": [ - "pnpm generate", - "pnpm check" - ], - "summary": "pg_partman is removed: Scaleway Managed PostgreSQL never offered it, so production skipped partitioning entirely, and run_maintenance() depended on an in-process timer that production never started. 10-partitions.migration.ts converts activities, seen_by and notifications to native range partitions with a DEFAULT partition and installs the maintain_partitions() procedure (drop past retention, trim DEFAULT, create two intervals ahead, move DEFAULT rows into new ranges); schedule-partition-maintenance.ts registers it as a nightly pg_cron job from the cluster's cron database (rdb on Scaleway, postgres in the dev image) after every migrate and boot-time migration, failing loudly when pg_cron is not loaded. The dev image installs postgresql-17-cron instead of postgresql-17-partman and compose preloads it; infra adds admin-cron-privilege on rdb. db-maintenance.ts (lib and script) and the db-maintenance backend job are deleted; partman-parity.test.ts is partition-parity.test.ts. sessions, tokens and unsubscribe_tokens stop being partitioned: their primary key is id alone (migration 20260923092234_tidy_bruce_banner flattens partitioned copies and swaps the key) and the same procedure sweeps rows older than the retention with a DELETE, so tokens.id can now be referenced by a real foreign key." - }, - { - "id": "20260923T0902-principal-to-actor", - "version": "next", - "title": "principal becomes actor: one word for who acts", - "kind": "codemod", - "syncBreaking": true, - "clientCacheBump": false, - "script": "cella/migrations/20260923T0902-principal-to-actor/principal-to-actor.ts", - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts", - "shared/src", - "frontend/src", - "yjs/src", - "cdc/src" - ], - "requires": [ - "pnpm sdk" - ], - "summary": "The stored identity and the request-time value share one name. backend/src/modules/principals/ is backend/src/modules/actors/ (principalsTable -> actorsTable, principalKinds -> actorKinds, insertPrincipals -> insertActors, deleteDanglingPrincipals -> deleteDanglingActors), PrincipalId -> ActorId, api_keys.principal_id -> actor_id with its index and the *_principals_id_fkey constraints renamed in migration 20260923100637_principal_to_actor, access-token claim principal_kind -> actor_kind, rate-limit identifier principalId -> actorId, findApiKeysByPrincipal -> findApiKeysByActor, principalQuotaKeys -> machineQuotaKeys. The engine access field userId -> actorId on Access, PredicateActor, EngineAccess, ConditionActor and PermissionCheckOptions (manual: userId also names the membership column; pnpm check lists every literal). Docs and comments say actor; infra/ keeps principal for Scaleway IAM. The ApiKey wire shape carries actorId, which no client reads, so clientCacheVersion does not bump." - }, - { - "id": "20260923T2319-session-revocation", - "version": "next", - "title": "Sessions are revoked, not deleted", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "frontend/src", - "locales" - ], - "requires": [ - "pnpm generate", - "pnpm sdk", - "pnpm check" - ], - "summary": "A session ends the way an API key does: sessions gains revokedAt, revokedBy (actor; null for the server's housekeeping) and revocationReason (sign_out, other_session, mfa_enabled, session_cap, replaced), and the row stays until the nightly sweep, so the sessions list shows revoked and expired sessions of the last 30 days. revokeSessions (auth-queries.ts) replaces deleteSession and deleteSessionsByIds; validateSession answers 401 session_revoked; the route deleteMySessions is revokeMySessions and returns the revoked rows; the auth event session.deleted is session.revoked. Locale keys terminate, terminate_all, success.session_terminated and success.sessions_terminated are removed in favour of revoke, revoke_all and success.revoke_resource; revocation_reason.*, revoked, expired and session_history are added. Three nullable columns (migration 20260923232124_session_revocation); no clientCacheVersion bump. Passkeys and TOTP say delete everywhere (mail types passkey-deleted / totp-deleted, locale keys delete_mfa_last, passkey_delete_failed, totp_delete_failed; unlink, unlink_mfa_last, success.passkey_unlinked, success.totp_removed, passkey_unlink_failed, totp_remove_failed removed), and emails.lastVerifiedBy is lastVerifiedVia (markEmailVerified takes via) because every other *By column names an actor." - }, - { - "id": "20260923T2343-infra-operator-keys", - "version": "next", - "title": "Operator API keys and privileged runs in the infra CLI", - "kind": "manual", - "syncBreaking": false, - "clientCacheBump": false, - "script": null, - "roots": [ - ".github/workflows", - "infra" - ], - "requires": [], - "summary": "Keys are named after their Scaleway bearer: the Owner API key (SCW_OWNER_*, your own key as organization Owner; a durable one mints a 30-minute key per privileged run) and the admin application key (SCW_ADMIN_* in infra/.env.<mode>; setup writes it, Fetch admin application key does elsewhere). SCW_BOOTSTRAP_* and SCW_STATE_* are read for one release with rename warnings. Apply validates the key, previews and confirms once, and verifies live grants and database privileges afterwards. Stack locks are renewed leases released on Ctrl-C. A preflight names owed Applies in the deploy and in the infra-preflight job on release PRs. keychain: and op: references resolve when the env file loads. Synced files arrive migrated; operators update their infra/.env.<mode> once." - }, - { - "id": "20260926T0700-jobs-pg-boss", - "version": "next", - "title": "Scheduled jobs run on pg-boss through the jobs worker", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "backend/src", - "backend/scripts", - "infra/config", - "shared/config" - ], - "requires": [ - "pnpm generate", - "pnpm --filter infra compose:generate", - "pnpm check" - ], - "summary": "BackendJob is { name, cron, run }: pg-boss cron (UTC) on a singleton queue, run by the new jobs worker (MODE=jobs, devPorts.jobs 4006, jobs/ dev package, coHosted under singleVM, stop-first). defineBackendModule gains queues: [{ name, handler, ... }] (pg-boss queue options; the jobs worker creates and works them). The RUN_MIGRATIONS_ON_BOOT timers in main.api.ts are gone. The migrate companion installs the pgboss schema and grants runtime_role (installJobsSchema, side-effect block jobs_grants); every runtime process uses the runtime DSN with migrate: false. The MCP worker no longer starts pg-boss or needs DATABASE_ADMIN_URL; its health component drops queueDepth. /health gains a jobs component (cron freshness, per-queue depth, dead letters), pnpm jobs inspects the store. Pool defaults: DATABASE_POOL_MAX 20, cdc 10, YJS_DB_POOL_MAX 10. Apps convert each job, add services.jobs and devPorts.jobs to a pinned config, regenerate migrations and compose, and run Apply infra change once before deploying." - }, - { - "id": "20260927T0704-access-hardening", - "version": "next", - "title": "Access hardening: sessions, tokens, second factors, the authorization server, data access, realtime and infra", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": true, - "script": null, - "roots": [ - "backend/src", - "backend/tests", - "backend/scripts", - "backend/emails", - "bench/src", - "frontend/src", - "shared/src", - "shared/config", - "yjs/src", - "cdc/src", - "infra", - ".github/workflows", - "locales" - ], - "requires": [ - "pnpm --filter backend generate", - "pnpm --filter frontend gen:routes", - "pnpm sdk", - "pnpm check" - ], - "summary": "Session cookies carry a random token the database stores hashed, every session ending goes through endSessions, and one token module issues and spends every token; sign-ups create an account only after inbox proof. Second factors are single-use, account-security actions and API key minting need a step-up, check-email answers only a recognized browser, and impersonation lives on its admin's session. One grant policy governs the authorization server, its session follows the app session, and revocations reach every process. Cross-scope reads return narrow shapes and uniform refusals, attachment keys and media references are organization-bound, the Yjs relay takes per-entity Ed25519 tokens on an internal listener, secrets are scoped to their consumers, limits hold against bursts, and logs and traces are redacted. Stored blocks with external image URLs stop rendering. One schema migration and one side-effect set; new env vars and a privileged infra Apply; apps bump clientCacheVersion and cookieVersion, and everyone signs in again." - }, - { - "id": "20260930T0855-store-selectors-and-seams", - "version": "next", - "title": "Store selector gate, per-channel role types, idempotency and block-field seams, app bundle externals", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src", - "backend/src", - "backend/tests", - "shared/src", - "shared/scripts", - "cdc", - "yjs", - ".github/workflows" - ], - "requires": [ - "pnpm style", - "pnpm check" - ], - "summary": "pnpm style fails a zustand store hook called without a selector, after the overlay providers re-rendered every UI-store reader on each open and close. checkIdempotency takes the table and builds the creator and request-scope predicate itself, evolutionContract.product declares blockFields and exposes assertBlockFields for creates and updates, the hierarchy role getters are typed per channel and shared exports OrganizationRole, the three tsup configs derive their externals from one keepOnDisk helper plus the pinned backend/src/bundle-config.ts, pnpm generate forwards extra arguments to drizzle-kit, security suites declare the member attachment policy they assume, and PR CI typechecks, builds the three service bundles and, on the release PR, the three images." - }, - { - "id": "20260930T1510-base-ui-toast", - "version": "next", - "title": "Replace sonner with Base UI Toast", - "kind": "manual", - "syncBreaking": true, - "clientCacheBump": false, - "script": null, - "roots": [ - "frontend/src" - ], - "requires": [ - "pnpm install", - "pnpm check" - ], - "summary": "Toasts render through Base UI Toast (frontend/src/modules/ui/toast.tsx, ported from the shadcn base toast) and the sonner dependency is removed. toaster.<severity>(message, options) keeps its shape with Base UI options (description, actionProps, timeout, id, onClose, priority); the unused sonner methods are removed and close replaces dismiss. toaster holds toasts shown before the Toaster mounts, which retires toast-store.ts, and ReloadPrompt shows a persistent toast. Apps that import sonner directly, pass sonner-only options or use useToastStore adjust those calls by hand." - } - ] -} diff --git a/cella/migrations/run.ts b/cella/migrations/run.ts deleted file mode 100644 index b5736c844..000000000 --- a/cella/migrations/run.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * App migration planner. - * - * Computes which cella migrations an app still has to apply and prints them in order, so a - * human or an agent can work the list. The source of truth for "already done" is the app's - * applied-set file ({@link APPLIED_FILE} in the cella/ folder), not version math: pending = - * every migration in `manifest.json` whose id is absent from the applied-set. This is stable - * across release- and branch-tracking apps alike. - * - * Usage (from the repo root): - * pnpm exec tsx cella/migrations/run.ts # print the pending plan (human) - * pnpm exec tsx cella/migrations/run.ts --json # same plan as JSON, for an agent - * pnpm exec tsx cella/migrations/run.ts --all # every migration, applied or not - * pnpm exec tsx cella/migrations/run.ts status # one-line applied/pending summary - * pnpm exec tsx cella/migrations/run.ts mark <id…> # record migrations as applied - */ - -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' - -/** One migration as declared in `manifest.json`. */ -interface MigrationEntry { - /** Folder name and stable id: `<YYYYMMDDThhmm>-<slug>` (UTC, lexically sortable). */ - id: string - /** Cella release the change first ships in, or `"next"` until a release is cut. */ - version: string - /** Human title. */ - title: string - /** How the change is applied. */ - kind: 'codemod' | 'sql' | 'manual' | 'mixed' - /** Changes upstream in a way app-specific code must follow. */ - syncBreaking: boolean - /** Bumped `clientCacheVersion` or shipped a lens module. */ - clientCacheBump: boolean - /** Repo-root-relative path to the codemod, or null. */ - script: string | null - /** Default scan roots for the codemod. */ - roots: string[] - /** Follow-up commands the migration needs (e.g. `pnpm generate`). */ - requires: string[] - /** One-line summary. */ - summary: string -} - -interface Manifest { - schemaVersion: number - migrations: MigrationEntry[] -} - -/** App-owned record of applied migration ids, in the cella/ folder. */ -const APPLIED_FILE = 'cella.migrations.json' - -const here = dirname(fileURLToPath(import.meta.url)) -const cellaDir = resolve(here, '..') -const manifestPath = join(here, 'manifest.json') -const appliedPath = join(cellaDir, APPLIED_FILE) -// Pre-relocation location (repo root). Read-only fallback so the pending plan stays correct in the -// window between pulling this move and running the migration that git-mv's the file into cella/. -const legacyAppliedPath = join(cellaDir, '..', APPLIED_FILE) - -/** Read and lightly validate `manifest.json`. */ -function readManifest(): Manifest { - const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest - if (!Array.isArray(parsed.migrations)) throw new Error('manifest.json: `migrations` must be an array') - return parsed -} - -/** Read the app's applied-set; empty when the file is absent. */ -function readApplied(): Set<string> { - const path = existsSync(appliedPath) ? appliedPath : legacyAppliedPath - if (!existsSync(path)) return new Set() - const parsed = JSON.parse(readFileSync(path, 'utf8')) as { applied?: string[] } - return new Set(parsed.applied ?? []) -} - -/** Write the applied-set back, sorted and de-duplicated. */ -function writeApplied(ids: Set<string>): void { - const applied = [...ids].sort() - writeFileSync(appliedPath, `${JSON.stringify({ applied }, null, 2)}\n`) -} - -/** Migrations sorted by id (timestamp prefix gives chronological order). */ -function ordered(manifest: Manifest): MigrationEntry[] { - return [...manifest.migrations].sort((a, b) => a.id.localeCompare(b.id)) -} - -/** Warn about drift between manifest entries and on-disk folders. */ -function driftWarnings(manifest: Manifest): string[] { - const warnings: string[] = [] - const folders = readdirSync(here, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name) - const declared = new Set(manifest.migrations.map((m) => m.id)) - for (const m of manifest.migrations) { - if (!folders.includes(m.id)) warnings.push(`manifest entry "${m.id}" has no folder`) - else if (!existsSync(join(here, m.id, 'README.md'))) warnings.push(`"${m.id}" is missing README.md`) - } - for (const f of folders) { - if (!declared.has(f)) warnings.push(`folder "${f}" is not in manifest.json`) - } - return warnings -} - -/** Print the pending plan for humans. */ -function printPlan(pending: MigrationEntry[]): void { - if (pending.length === 0) { - console.info('✓ No pending migrations. This app is up to date.') - return - } - console.info(`${pending.length} pending migration(s), in order:\n`) - for (const [i, m] of pending.entries()) { - const tags = [m.kind, m.syncBreaking ? 'sync-breaking' : null, m.clientCacheBump ? 'cache-bump' : null] - .filter(Boolean) - .join(', ') - console.info(`${i + 1}. ${m.title} [${tags}]`) - console.info(` id: ${m.id} (ships in ${m.version})`) - console.info(` summary: ${m.summary}`) - if (m.script) console.info(` codemod: pnpm exec tsx ${m.script} ${m.roots.join(' ')}`) - if (m.requires.length) console.info(` then: ${m.requires.join(', ')}`) - console.info(` readme: cella/migrations/${m.id}/README.md`) - console.info('') - } - console.info('After applying each, gate on `pnpm check`, then record it:') - console.info(` pnpm exec tsx cella/migrations/run.ts mark ${pending.map((m) => m.id).join(' ')}`) -} - -function main(): void { - const argv = process.argv.slice(2) - const cmd = argv[0] - const manifest = readManifest() - const applied = readApplied() - const all = ordered(manifest) - - if (cmd === 'mark') { - const ids = argv.slice(1) - if (ids.length === 0) throw new Error('mark: pass one or more migration ids') - const declared = new Set(all.map((m) => m.id)) - const unknown = ids.filter((id) => !declared.has(id)) - if (unknown.length) throw new Error(`mark: unknown migration id(s): ${unknown.join(', ')}`) - for (const id of ids) applied.add(id) - writeApplied(applied) - console.info(`Recorded ${ids.length} migration(s) as applied in ${APPLIED_FILE}.`) - return - } - - const warnings = driftWarnings(manifest) - for (const w of warnings) console.warn(`! ${w}`) - - const wantAll = argv.includes('--all') - const pending = wantAll ? all : all.filter((m) => !applied.has(m.id)) - - if (cmd === 'status') { - console.info(`applied: ${applied.size} pending: ${all.length - applied.size} total: ${all.length}`) - return - } - - if (argv.includes('--json')) { - console.info(JSON.stringify({ pending, appliedCount: applied.size, warnings }, null, 2)) - return - } - - printPlan(pending) -} - -main() diff --git a/cella/skills/cella-sync/SKILL.md b/cella/skills/cella-sync/SKILL.md index 3c84e5854..3b2f77063 100644 --- a/cella/skills/cella-sync/SKILL.md +++ b/cella/skills/cella-sync/SKILL.md @@ -18,7 +18,7 @@ advances one stage (steps 6 and 7). always wins; the CLI restores every changed pinned file to HEAD right after the merge, so no upstream hunk merges in and a pinned file never conflicts. Adopt upstream hunks by hand from the analyze list ("protected but behind upstream"). -- **fork marker**: `// fork: <why>` (css `/* fork: ... */`, md `<!-- fork: ... -->`) on every +- **fork marker**: `// fork: <why>` (css `/* fork: ... */`, md `<!-- fork: ... -->`, a ` * fork: ...` line in JSDoc) on every intentional app edit in a cella-owned file, one marker per contiguous edit, naming the customization axis, not the diff; unmarked drift counts as accidental. JSON cannot carry markers: pin or ignore changed JSON files. @@ -26,9 +26,9 @@ advances one stage (steps 6 and 7). ## 1. Preflight 1. Clean working tree, fresh branch (sync creates `cella/sync/<date>` itself). -2. Diff `cella/migrations/manifest.json` against the app's `cella.migrations.json` applied set - and read each pending migration's README BEFORE resolving conflicts; conflicts usually belong - to one of them. +2. Once the first run has merged, `pnpm cella migrate` lists the migration notes that arrived + (the merge already recorded them, conflicts or not). Read each one (`--show <id>`) BEFORE + resolving conflicts; conflicts usually belong to one of them. 3. Skim `git log --oneline <old>..cella-upstream/main`. Upstream commits that ADOPT this app's contributions come back as conflicts where ours = theirs + app payload. @@ -39,7 +39,7 @@ copy, and their upstream hunks wait in the analyze list. | Conflict shape | Resolution | |---|---| -| Both-added (AA) test or module, ours = upstream + app cases | Take upstream verbatim (`git checkout --theirs`); move the app cases to a fork-owned file beside its source (`<source>.test.ts` next to the fork's schema/module), never inside a cella-owned file. | +| Both-added (AA) test or module, ours = upstream + app cases | Take upstream verbatim (`git checkout --theirs`); move the app cases to an app-owned companion beside its source (`<source>-app.test.ts`; the style check flags the word fork in file names), never inside a cella-owned file. | | Cella-owned file with fork markers (UU) | Take theirs, grep the pre-merge version (`git show :2:<file> \| grep -n -A2 'fork:'`), re-apply exactly the marked deltas with their markers. | | Cella-owned file, no markers, unclear delta | Suspect accidental drift. Diff `:2:` vs `:3:`: no intentional axis on the fork side, take theirs; intentional, re-apply WITH a new `// fork:` marker. | | Generated output (sdk/gen, routeTree.gen, openapi cache) | Take either side; regenerate at step 4. | @@ -57,6 +57,10 @@ git log -p MERGE_HEAD -1 --stat # what upstream intended For each auto-merged file in an area with `fork:` markers (grep them repo-wide as the map), verify the marked lines survived; CI stays green until typecheck when one is dropped. +Ignored paths never merge, so upstream changes there arrive only by hand. Read +`git diff HEAD MERGE_HEAD -- shared/config` for new config keys and version bumps, and the same for every +app-owned module folder (`owner: 'app'`) that started as an upstream module. + ## 4. Regenerate and gate 1. `pnpm generate` if any `*-db.ts` changed (drive the drizzle TTY prompt with expect; verify @@ -70,8 +74,8 @@ the marked lines survived; CI stays green until typecheck when one is dropped. ## 5. Migration bookkeeping Run the `migrate` skill; entries whose change originated here or arrived by an earlier sync are -verified (README "Verify" steps) and marked, not re-applied. The pending list must be empty at -the end of a sync. +verified (README "Verify" steps) and marked, not re-applied. Handle the open notes in the sync PR +when you can; one left for later stays listed by `pnpm cella migrate` until it is marked. ## 6. Commit, then drift triage diff --git a/cella/skills/migrate/SKILL.md b/cella/skills/migrate/SKILL.md index 74f48b51a..26aabccc4 100644 --- a/cella/skills/migrate/SKILL.md +++ b/cella/skills/migrate/SKILL.md @@ -1,44 +1,49 @@ --- name: migrate -description: Apply pending cella upstream migrations to an app after a sync. Computes the pending set from cella/migrations/manifest.json, runs each migration's codemod or manual steps in order, gates on pnpm check, and records what was applied. +description: Apply pending cella upstream migrations to an app after a sync. Lists the open migration notes with pnpm cella migrate, runs each note's codemod or manual steps in order, gates on pnpm check, and records what was handled. --- # Applying cella migrations to an app -Run after a `cella sync` pull, or whenever `cella/migrations/run.ts` reports pending work. One -migration at a time, in array order (later migrations may assume earlier ones ran). Never batch or -skip ahead: apply, gate, record, next. +Run after a `cella sync`, or whenever its closing line reports open migration notes. The notes +live upstream; `pnpm cella migrate` reads them from the upstream commit the app last synced to. +One note at a time, oldest first (later notes may assume earlier ones ran): apply, gate, record, +next. Open notes are a to-do list, not a gate: one left for later stays listed until it is marked. ## 1. Inventory From the repo root: ```sh -pnpm exec tsx cella/migrations/run.ts --json +pnpm cella migrate --json ``` -Elements carry `id`, `title`, `kind`, `syncBreaking`, `clientCacheBump`, `script`, `roots`, -`requires`, `summary`. Address `warnings` (manifest and folders disagree) first. Empty list: up to -date, stop. +`notes` carries the open notes, oldest first, each with `id`, `title`, `kind` (`codemod` or +`manual`), `syncBreaking`, `clientCacheBump`, `codemod`, `roots`, `summary` and `url` (a GitHub +permalink to its README). Empty list: up to date, stop. -## 2. For each pending migration, in array order +## 2. For each open note, oldest first -Read `cella/migrations/<id>/README.md` in full first (the manifest is only a summary). Then: +Read the full README first (the list shows only its summary): -- **`kind: codemod` or `mixed`**: report mode first, read what it will touch, then apply: +```sh +pnpm cella migrate --show <id> +``` + +- **`kind: codemod`**: extract the folder, then run report mode, read what it will touch, and + apply. The README's `cella/migrations/<id>/` paths are the extracted folder: ```sh - pnpm exec tsx <script> inventory <roots> # or the exact command in the README - pnpm exec tsx <script> rewrite <roots> + pnpm cella migrate --extract <id> + pnpm exec tsx node_modules/.cache/cella/migrations/<id>/<codemod> inventory <roots> + pnpm exec tsx node_modules/.cache/cella/migrations/<id>/<codemod> rewrite <roots> ``` - If the app renamed or added entities, pass the migration's customization flag (e.g. - `--extra-renames app-renames.json`); never edit the shipped script. -- **`kind: sql`**: follow the README's **Manual steps** and **Verify** sections exactly (SQL, - drizzle regen, rename-prompt answers). -- **`kind: manual`**: work the numbered **Manual steps** (per-file changes a codemod skips) - wherever the app customized that code. + If the app renamed or added entities, pass the note's customization flag (e.g. + `--extra-renames app-renames.json`); never edit the extracted script. +- **Both kinds**: work the numbered **Manual steps** (per-file changes a codemod skips, SQL, + drizzle regen, rename-prompt answers) wherever the app customized that code. -Then run every command in `requires` (e.g. `pnpm generate`, `pnpm sdk`) and every follow-up in -the README's **Verify** section (recalculation runbooks, seed steps). +Then run every command and follow-up in the README's **Verify** section (`pnpm generate`, +`pnpm sdk`, recalculation runbooks, seed steps). ## 3. Validate @@ -46,21 +51,21 @@ the README's **Verify** section (recalculation runbooks, seed steps). pnpm check ``` -On failure, fix within this migration's scope (or report the blocker) before recording. Never -mark a migration applied over a red check. +On failure, fix within this note's scope (or report the blocker) before recording. Never mark a +note handled over a red check. -## 4. Ship (record) +## 4. Record ```sh -pnpm exec tsx cella/migrations/run.ts mark <id> +pnpm cella migrate --mark <id> ``` -Appends the id to `cella/cella.migrations.json`. Commit that file with the migration's code -changes, then return to step 2 for the next migration. +Removes the id from `cella/cella.migrations.json` (the file goes once the list is empty). Commit +that change with the note's code changes, then return to step 2 for the next note. ## Notes - **Idempotency.** Codemods are no-ops on migrated code, so a rerun after a partial failure is safe. Manual and SQL steps may not be; read before re-running. -- **`syncBreaking: false`** migrations an in-sync app gets for free (compiler-enforced renames, no - app-specific surface) are still recorded once `pnpm check` is green, so the plan stays accurate. +- **`syncBreaking: false`** notes an in-sync app gets for free (compiler-enforced renames, no + app-specific surface) are still marked once `pnpm check` is green, so the list stays accurate. diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index f1437e99c..3d1c47844 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,4 +1,18 @@ import type { StorybookConfig } from '@storybook/react-vite'; +import tailwindcss from '@tailwindcss/vite'; +import { appConfig } from 'shared'; +import type { Plugin } from 'vite'; +import { docsFrontmatter } from '../vite/docs-frontmatter.ts'; + +// The PWA plugin is app-only; stories get a service-worker hook that never reports an update. +const pwaRegisterStub: Plugin = { + name: 'storybook-pwa-register-stub', + resolveId: (id) => (id === 'virtual:pwa-register/react' ? '\0pwa-register-stub' : undefined), + load: (id) => + id === '\0pwa-register-stub' + ? 'export const useRegisterSW = () => ({ needRefresh: [false, () => {}], offlineReady: [false, () => {}], updateServiceWorker: async () => {} });' + : undefined, +}; const config: StorybookConfig = { "stories": [ @@ -27,6 +41,18 @@ const config: StorybookConfig = { NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'), }, __DEV_TOOLS__: 'true', + __APP_VERSION__: JSON.stringify('storybook'), + }; + // storybook dev and build merge frontend/vite.config.ts, which registers Tailwind already. The vitest storybook + // project applies only this viteFinal, so it gets Tailwind here. + const hasTailwind = (config.plugins ?? []).flat(2).some((plugin) => plugin && 'name' in plugin && plugin.name.startsWith('@tailwindcss/vite')); + // Every virtual module the app imports must resolve: an unresolved import fails Vite's dependency scan, so + // dependencies are found mid-run and each discovery reloads the tests. + config.plugins = [...(config.plugins ?? []), ...(hasTailwind ? [] : [tailwindcss()]), docsFrontmatter(), pwaRegisterStub]; + // The email stories render backend HTML: proxy the dev preview route so their fetch stays same-origin. + config.server = { + ...config.server, + proxy: { ...config.server?.proxy, '/api/dev/emails': { target: `http://localhost:${appConfig.devPorts.api}` } }, }; return config; }, diff --git a/frontend/package.json b/frontend/package.json index d4f8e3d2b..602022f8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "@blocknote/react": "^0.55.0", "@blocknote/shadcn": "^0.55.0", "@hookform/resolvers": "^5.9.1", - "@maple-dev/browser": "^0.9.0", + "@maple-dev/browser": "^0.10.1", "@mdx-js/react": "^3.1.1", "@mdx-js/rollup": "^3.1.1", "@opentelemetry/api": "^1.9.1", @@ -56,38 +56,36 @@ "@tanstack/react-query": "^5.104.0", "@tanstack/react-query-devtools": "^5.104.0", "@tanstack/react-query-persist-client": "^5.104.0", - "@tanstack/react-router": "^1.170.40", + "@tanstack/react-router": "^1.170.41", "@tanstack/react-router-devtools": "^1.167.2", - "@uppy/audio": "^4.0.0", - "@uppy/core": "^6.1.0", - "@uppy/dashboard": "^6.0.0", + "@uppy/audio": "^4.0.1", + "@uppy/core": "^6.2.0", + "@uppy/dashboard": "^6.0.1", "@uppy/image-editor": "^5.0.0", "@uppy/react": "6.0.0", - "@uppy/screen-capture": "^6.0.0", - "@uppy/transloadit": "^6.0.0", - "@uppy/url": "^6.0.0", - "@uppy/webcam": "^6.0.0", + "@uppy/screen-capture": "^6.0.1", + "@uppy/transloadit": "^6.0.1", + "@uppy/url": "^6.0.1", + "@uppy/webcam": "^6.0.1", "@vis.gl/react-google-maps": "^1.10.1", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "cnfast": "^0.2.0", "dayjs": "^1.11.23", "dexie": "^4.4.6", - "dexie-react-hooks": "^4.4.0", "dompurify": "^3.4.16", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", - "gleap": "^18.1.2", + "gleap": "^19.0.0", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.2", "immer": "^11.1.18", - "input-otp": "^1.5.0", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", - "lib0": "^0.2.118", - "lucide-react": "^1.48.0", - "motion": "^13.4.4", + "lib0": "^0.2.119", + "lucide-react": "^1.49.0", + "motion": "^13.4.6", "nanoid": "^6.0.1", "onedollarstats": "^0.0.23", "pdfjs-dist": "6.3.289", @@ -95,7 +93,7 @@ "prosemirror-state": "^1.4.4", "qrcode.react": "^4.2.0", "react": "^19.3.0", - "react-day-picker": "^10.0.1", + "react-day-picker": "^10.0.2", "react-dom": "^19.3.0", "react-error-boundary": "^6.1.6", "react-hook-form": "^7.89.0", @@ -109,10 +107,10 @@ "sdk": "workspace:*", "serwist": "^9.5.12", "shared": "workspace:*", - "shiki": "^4.4.3", + "shiki": "^4.5.0", "slugify": "1.6.9", "tailwindcss": "^4.3.3", - "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0", "use-debounce": "^10.1.1", "uuidv7": "^1.2.1", "virtua": "^0.50.1", @@ -124,18 +122,18 @@ "zustand": "^5.0.15" }, "devDependencies": { - "@chromatic-com/storybook": "^5.3.1", + "@chromatic-com/storybook": "^5.4.0", "@react-scan/vite-plugin-react-scan": "^0.2.7", "@rolldown/plugin-babel": "^0.2.4", "@rollup/plugin-terser": "^1.0.0", - "@shikijs/rehype": "^4.4.3", - "@storybook/addon-a11y": "^10.6.0", - "@storybook/addon-docs": "^10.6.0", - "@storybook/addon-vitest": "^10.6.0", - "@storybook/react-vite": "^10.6.0", + "@shikijs/rehype": "^4.5.0", + "@storybook/addon-a11y": "^10.6.1", + "@storybook/addon-docs": "^10.6.1", + "@storybook/addon-vitest": "^10.6.1", + "@storybook/react-vite": "^10.6.1", "@tailwindcss/vite": "^4.3.3", - "@tanstack/router-generator": "^1.167.39", - "@tanstack/router-plugin": "^1.168.41", + "@tanstack/router-generator": "^1.167.40", + "@tanstack/router-plugin": "^1.168.42", "@types/canvas-confetti": "^1.9.0", "@types/node": "^26.6.3", "@types/react": "19.3.0", @@ -143,29 +141,29 @@ "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-react": "^6.1.1", - "@vitest/browser": "^5.0.2", - "@vitest/browser-playwright": "^5.0.2", - "babel-plugin-react-compiler": "19.1.0-rc.3", + "@vitest/browser": "^5.0.3", + "@vitest/browser-playwright": "^5.0.3", + "babel-plugin-react-compiler": "1.0.0", "concurrently": "^10.0.5", "cross-env": "^10.1.0", "fake-indexeddb": "^6.2.5", "github-slugger": "^2.0.0", - "hono": "^4.13.10", + "hono": "^4.13.12", "jsdom": "^30.1.1", "lucide-static": "1.25.0", "playwright": "^1.63.0", "react-scan": "^0.5.7", "rehype-slug": "^6.0.0", "rollup-plugin-visualizer": "^7.1.1", - "storybook": "^10.6.0", + "storybook": "^10.6.1", "tsx": "^4.23.15", - "typescript": "^6.0.3", + "typescript": "6.0.3", "vite": "^8.3.1", "vite-plugin-html": "^3.2.2", "vite-plugin-pwa": "^1.3.0", "vite-plugin-static-copy": "^4.1.1", "vite-plugin-svgr": "^5.2.0", - "vitest": "^5.0.2", + "vitest": "^5.0.3", "workbox-build": "^7.4.1", "yaml": "^2.9.1" }, diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts index 467dc1e25..4f701fa43 100644 --- a/frontend/src/globals.d.ts +++ b/frontend/src/globals.d.ts @@ -10,10 +10,7 @@ declare const __DEV_TOOLS__: boolean; /** Build-time frontmatter + headings index of docs pages (vite/docs-frontmatter.ts). */ declare module 'virtual:docs-frontmatter' { - export const docsIndex: Record< - string, - { frontmatter: unknown; headings: { id: string; text: string; depth: number }[] } - >; + export const docsIndex: Record<string, { frontmatter: unknown; headings: { id: string; text: string; depth: number }[] }>; } /** diff --git a/frontend/src/hooks/use-breakpoints.tsx b/frontend/src/hooks/use-breakpoints.tsx index ce5fdd01d..c70e23106 100644 --- a/frontend/src/hooks/use-breakpoints.tsx +++ b/frontend/src/hooks/use-breakpoints.tsx @@ -2,16 +2,12 @@ import { useSyncExternalStore } from 'react'; import { appConfig } from 'shared'; const breakpoints: { [key: string]: string } = appConfig.theme.screenSizes; -const sortedBreakpoints = Object.keys(breakpoints).sort( - (a, b) => Number.parseInt(breakpoints[a], 10) - Number.parseInt(breakpoints[b], 10), -); +const sortedBreakpoints = Object.keys(breakpoints).sort((a, b) => Number.parseInt(breakpoints[a], 10) - Number.parseInt(breakpoints[b], 10)); // One media query per breakpoint, so JS agrees with the CSS `md:` variants. `window.innerWidth` does not: on // mobile it can follow the visual viewport (pinch zoom, overflowing content) and flip layouts the CSS never flips. // jsdom has no matchMedia; tests fall back to innerWidth there. -const mediaQueries = new Map( - sortedBreakpoints.map((bp) => [bp, window.matchMedia?.(`(min-width: ${breakpoints[bp]})`) ?? null] as const), -); +const mediaQueries = new Map(sortedBreakpoints.map((bp) => [bp, window.matchMedia?.(`(min-width: ${breakpoints[bp]})`) ?? null] as const)); function matchesBreakpoint(bp: string) { const mql = mediaQueries.get(bp); @@ -78,20 +74,12 @@ function getServerSnapshot() { type BreakpointKey = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; export function useCurrentBreakpoint(enableReactivity = true): BreakpointKey { - const breakpointState = useSyncExternalStore( - enableReactivity ? subscribe : () => () => {}, - getSnapshot, - getServerSnapshot, - ); + const breakpointState = useSyncExternalStore(enableReactivity ? subscribe : () => () => {}, getSnapshot, getServerSnapshot); return breakpointState as BreakpointKey; } function useBreakpointState(enableReactivity = true) { - const breakpointState = useSyncExternalStore( - enableReactivity ? subscribe : () => () => {}, - getSnapshot, - getServerSnapshot, - ); + const breakpointState = useSyncExternalStore(enableReactivity ? subscribe : () => () => {}, getSnapshot, getServerSnapshot); return sortedBreakpoints.indexOf(breakpointState); } diff --git a/frontend/src/hooks/use-hot-keys-helpers.ts b/frontend/src/hooks/use-hot-keys-helpers.ts index 067164c72..2f3cce88d 100644 --- a/frontend/src/hooks/use-hot-keys-helpers.ts +++ b/frontend/src/hooks/use-hot-keys-helpers.ts @@ -1,14 +1,6 @@ -type KeyboardModifiers = { - alt: boolean; - ctrl: boolean; - meta: boolean; - mod: boolean; - shift: boolean; -}; +type KeyboardModifiers = { alt: boolean; ctrl: boolean; meta: boolean; mod: boolean; shift: boolean }; -type Hotkey = KeyboardModifiers & { - key?: string; -}; +type Hotkey = KeyboardModifiers & { key?: string }; type CheckHotkeyMatch = (event: KeyboardEvent) => boolean; @@ -33,10 +25,7 @@ function parseHotkey(hotkey: string): Hotkey { const freeKey = keys.find((key) => !reservedKeys.includes(key)); - return { - ...modifiers, - key: freeKey, - }; + return { ...modifiers, key: freeKey }; } function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean { @@ -47,11 +36,8 @@ function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean { return false; } - if ( - key && - (pressedKey.toLowerCase() === key.toLowerCase() || - event.code.replace('Key', '').toLowerCase() === key.toLowerCase()) - ) { + const lowerKey = key?.toLowerCase(); + if (lowerKey && (pressedKey.toLowerCase() === lowerKey || event.code.replace('Key', '').toLowerCase() === lowerKey)) { return true; } diff --git a/frontend/src/hooks/use-hot-keys.ts b/frontend/src/hooks/use-hot-keys.ts index 37c9647e2..b61409fac 100644 --- a/frontend/src/hooks/use-hot-keys.ts +++ b/frontend/src/hooks/use-hot-keys.ts @@ -2,16 +2,10 @@ import { useEffect } from 'react'; import { getHotkeyMatcher, type HotkeyItem, shouldFireEvent } from '~/hooks/use-hot-keys-helpers'; /** Register global shortcuts as `[combination, handler, options?]` tuples. */ -export function useHotkeys( - hotkeys: HotkeyItem[], - tagsToIgnore: string[] = ['INPUT', 'TEXTAREA', 'SELECT'], - triggerOnContentEditable = false, -) { +export function useHotkeys(hotkeys: HotkeyItem[], tagsToIgnore: string[] = ['INPUT', 'TEXTAREA', 'SELECT'], triggerOnContentEditable = false) { useEffect(() => { const keydownListener = (event: KeyboardEvent) => { - const isFormElement = tagsToIgnore.some( - (tag) => event.target instanceof HTMLElement && event.target.closest(tag), - ); + const isFormElement = tagsToIgnore.some((tag) => event.target instanceof HTMLElement && event.target.closest(tag)); if (isFormElement) return; for (const [hotkey, handler, options = { preventDefault: true }] of hotkeys) { diff --git a/frontend/src/hooks/use-measure.tsx b/frontend/src/hooks/use-measure.tsx index bd83bc66b..a8d465e88 100644 --- a/frontend/src/hooks/use-measure.tsx +++ b/frontend/src/hooks/use-measure.tsx @@ -12,10 +12,7 @@ export const useMeasure = <T extends Element = Element>() => { const box = entry.borderBoxSize?.[0]; if (box) { - setBounds({ - width: box.inlineSize, - height: box.blockSize, - }); + setBounds({ width: box.inlineSize, height: box.blockSize }); } else { // fallback for older browsers const rect = entry.target.getBoundingClientRect(); diff --git a/frontend/src/hooks/use-mounted-state.tsx b/frontend/src/hooks/use-mounted-state.tsx index 94eef55d3..c2fc1d1db 100644 --- a/frontend/src/hooks/use-mounted-state.tsx +++ b/frontend/src/hooks/use-mounted-state.tsx @@ -17,9 +17,5 @@ export const useMountedState = () => { }; }, []); - return { - hasMounted: mountedRef.current, - hasStarted: stage >= 1, - hasWaited: stage >= 2, - }; + return { hasMounted: mountedRef.current, hasStarted: stage >= 1, hasWaited: stage >= 2 }; }; diff --git a/frontend/src/hooks/use-preload-lazy-components.tsx b/frontend/src/hooks/use-preload-lazy-components.tsx index 78507c22a..a539e7850 100644 --- a/frontend/src/hooks/use-preload-lazy-components.tsx +++ b/frontend/src/hooks/use-preload-lazy-components.tsx @@ -8,9 +8,8 @@ export function usePreloadLazyComponents(components: LazyExoticComponent<Compone // React.lazy keeps the loader in _payload/_init; calling _init starts the import without rendering. if ('_payload' in lazyComponent && '_init' in lazyComponent) { try { - (lazyComponent as { _init: (payload: unknown) => void; _payload: unknown })._init( - (lazyComponent as { _payload: unknown })._payload, - ); + const lazy = lazyComponent as { _init: (payload: unknown) => void; _payload: unknown }; + lazy._init(lazy._payload); } catch { // Errors are expected for unresolved promises - component will load when rendered } diff --git a/frontend/src/hooks/use-prerender.ts b/frontend/src/hooks/use-prerender.ts index 9afceed28..b76b846b8 100644 --- a/frontend/src/hooks/use-prerender.ts +++ b/frontend/src/hooks/use-prerender.ts @@ -18,11 +18,7 @@ const usePrerenderStore = create<PrerenderState>((set) => ({ }), })); -const hiddenStyle: CSSProperties = { - contentVisibility: 'hidden', - height: 0, - overflow: 'hidden', -}; +const hiddenStyle: CSSProperties = { contentVisibility: 'hidden', height: 0, overflow: 'hidden' }; /** Prerendered sections mount hidden so opening them is instant. */ export function usePrerenderSection(scope: string, sectionId: string, isOpen: boolean) { diff --git a/frontend/src/hooks/use-route-context.ts b/frontend/src/hooks/use-route-context.ts index 6fdad3b63..a2f9f7ba1 100644 --- a/frontend/src/hooks/use-route-context.ts +++ b/frontend/src/hooks/use-route-context.ts @@ -3,19 +3,23 @@ import type { Organization } from 'sdk'; // String route IDs avoid circular imports between route files and component modules, which break Vite HMR. -type OrganizationLayoutContext = { organization: Organization; tenantId: string }; +type OrganizationContext = { organization: Organization; tenantId: string }; -/** Organization context from the nearest route that provides it; throws when no match carries one. */ -export const useOrganizationLayoutContext = (): OrganizationLayoutContext => { - const match = useRouterState({ - select: (s) => - s.matches.find((m) => { - const ctx = m.context as Record<string, unknown>; - return ctx?.organization && typeof ctx?.tenantId === 'string'; - }), - }); +const findOrganizationContext = (matches: { context: unknown }[]) => + matches.find((m) => { + const ctx = m.context as Record<string, unknown>; + return ctx?.organization && typeof ctx?.tenantId === 'string'; + })?.context as OrganizationContext | undefined; - if (match) return match.context as OrganizationLayoutContext; +/** + * Organization and tenant ids from the nearest route that provides them; throws when no match carries them. + * Selects primitives because match context is rebuilt on every navigation, search-only ones included. + */ +export const useOrganizationLayoutContext = (): { organizationId: string; tenantId: string } => { + const organizationId = useRouterState({ select: (s) => findOrganizationContext(s.matches)?.organization.id }); + const tenantId = useRouterState({ select: (s) => findOrganizationContext(s.matches)?.tenantId }); + + if (organizationId && tenantId) return { organizationId, tenantId }; throw new Error('useOrganizationLayoutContext must be used within a route that provides organization context'); }; diff --git a/frontend/src/hooks/use-scroll-spy-store.ts b/frontend/src/hooks/use-scroll-spy-store.ts index a0b65bb8b..53ece79cc 100644 --- a/frontend/src/hooks/use-scroll-spy-store.ts +++ b/frontend/src/hooks/use-scroll-spy-store.ts @@ -1,4 +1,7 @@ -/** DOM id prefix (e.g. id="spy-intro") prevents browser auto-scroll on hash change */ +/** + * DOM id prefix (e.g. id="spy-intro") prevents auto-scroll on hash change. No element may carry the bare section id: + * TanStack history patches replaceState, so each hash write runs the router's hash scroll, which jumps to that element. + */ const SPY_PREFIX = 'spy-'; const sections = new Map<string, number>(); // sectionId → intersection ratio @@ -9,6 +12,8 @@ let initTime = 0; let pendingScrollTarget: string | null = null; let scrollSettleTimer = 0; let savedSection = ''; // Preserved across quick re-registrations (effect re-runs) +// Section a scroll was sent to: stays current until the user scrolls, even where the page can't bring it up to the trigger line +let pinnedSection = ''; // Subscribers for useSyncExternalStore const listeners = new Set<() => void>(); @@ -41,14 +46,13 @@ const canWriteHash = () => Date.now() > hashWriteBlockedUntil && initTime && Dat export const isProgrammaticScroll = () => Date.now() < hashWriteBlockedUntil; -/** Within this many px of the top, no section counts as anchored. */ +/** Within this many px of the top the page counts as unscrolled: the topmost section is current and the hash is dropped. */ const TOP_THRESHOLD = 64; let topWatchTarget: HTMLElement | Window | null = null; let topWatchFrame = 0; -const scrollTopOf = (target: HTMLElement | Window) => - target === window ? window.scrollY : (target as HTMLElement).scrollTop; +const scrollTopOf = (target: HTMLElement | Window) => (target === window ? window.scrollY : (target as HTMLElement).scrollTop); const isAtTop = () => scrollTopOf(topWatchTarget ?? window) <= TOP_THRESHOLD; @@ -65,8 +69,9 @@ const syncHash = (id: string) => { else if (location.hash !== `#${id}`) history.replaceState(null, '', `#${id}`); }; -/** The observer misses the final stretch back to the top, so watch scroll directly; this only clears the hash. */ -const onScrollNearTop = () => { +/** A user scroll releases the pin. The observer misses the final stretch back to the top, so this also clears the hash there. */ +const onScroll = () => { + if (!isProgrammaticScroll()) pinnedSection = ''; if (topWatchFrame) return; topWatchFrame = requestAnimationFrame(() => { topWatchFrame = 0; @@ -77,20 +82,18 @@ const onScrollNearTop = () => { /** Point the top watcher at the sections' scroller (window when that is the document scroller). */ const watchScroller = () => { - const anchor = [...sections.keys()] - .map((id) => document.getElementById(`${SPY_PREFIX}${id}`)) - .find((el): el is HTMLElement => el !== null); + const anchor = [...sections.keys()].map((id) => document.getElementById(`${SPY_PREFIX}${id}`)).find((el): el is HTMLElement => el !== null); const scroller = anchor ? findScrollParent(anchor) : null; const next: HTMLElement | Window = !scroller || isRootScroller(scroller) ? window : scroller; if (next === topWatchTarget) return; - topWatchTarget?.removeEventListener('scroll', onScrollNearTop); + topWatchTarget?.removeEventListener('scroll', onScroll); topWatchTarget = next; - topWatchTarget.addEventListener('scroll', onScrollNearTop, { passive: true }); + topWatchTarget.addEventListener('scroll', onScroll, { passive: true }); }; const unwatchScroller = () => { - topWatchTarget?.removeEventListener('scroll', onScrollNearTop); + topWatchTarget?.removeEventListener('scroll', onScroll); topWatchTarget = null; cancelAnimationFrame(topWatchFrame); topWatchFrame = 0; @@ -113,20 +116,25 @@ const blockHashWrites = (ms: number) => { }, ms + 50); }; -/** Picks the last anchor to have crossed a trigger line near the top of the viewport. */ +/** Picks the pinned section while in view, else the topmost one at the top, else the last anchor past a trigger line near the top. Null while none is in view. */ const getBestSection = (): string | null => { const visible = [...sections.entries()].filter(([, r]) => r > 0); if (!visible.length) return null; + if (pinnedSection && visible.some(([id]) => id === pinnedSection)) return pinnedSection; const triggerY = window.innerHeight * 0.25; - const withPositions = visible - .map(([id]) => ({ - id, - top: document.getElementById(`${SPY_PREFIX}${id}`)?.getBoundingClientRect().top ?? Number.POSITIVE_INFINITY, - })) + // Every rendered anchor counts, not only those in view: after a long scroll the current section's anchor sits above the viewport + const withPositions = [...sections.keys()] + .flatMap((id) => { + const el = document.getElementById(`${SPY_PREFIX}${id}`); + return el?.getClientRects().length ? [{ id, top: el.getBoundingClientRect().top }] : []; + }) .sort((a, b) => a.top - b.top); + // Nothing is scrolled to yet, even when a short intro puts later anchors past the trigger + if (isAtTop()) return withPositions[0].id; + const pastTrigger = withPositions.filter(({ top }) => top <= triggerY); if (pastTrigger.length) return pastTrigger[pastTrigger.length - 1].id; @@ -203,6 +211,7 @@ export const registerSections = (ids: string[]) => { const inInitWindow = Date.now() - initTime < 500; if (hash && sections.has(hash) && currentSection !== hash && (inInitWindow || !currentSection)) { currentSection = hash; + pinnedSection = hash; syncActiveDOM(); notify(); blockHashWrites(1000); @@ -226,6 +235,7 @@ export const unregisterSections = (ids: string[]) => { observer?.disconnect(); observer = null; unwatchScroller(); + pinnedSection = ''; savedSection = currentSection; if (currentSection !== '') { currentSection = ''; @@ -239,8 +249,7 @@ export const unregisterSections = (ids: string[]) => { }; /** The document owns the scroller, so scroll events land on window. */ -const isRootScroller = (el: HTMLElement) => - el === document.scrollingElement || el === document.documentElement || el === document.body; +const isRootScroller = (el: HTMLElement) => el === document.scrollingElement || el === document.documentElement || el === document.body; /** Find the nearest scrollable ancestor (overflow-y auto/scroll with actual overflow), else the document scroller. */ const findScrollParent = (el: HTMLElement): HTMLElement => { @@ -262,6 +271,7 @@ const performScroll = (el: HTMLElement, id: string) => { const smooth = Math.abs(delta) < window.innerHeight * 2; blockHashWrites(smooth ? 1200 : 500); + pinnedSection = id; if (location.hash !== `#${id}`) { history.replaceState(null, '', `#${id}`); diff --git a/frontend/src/hooks/use-scroll-spy.tsx b/frontend/src/hooks/use-scroll-spy.tsx index cb099c0ff..61e70d4f8 100644 --- a/frontend/src/hooks/use-scroll-spy.tsx +++ b/frontend/src/hooks/use-scroll-spy.tsx @@ -3,13 +3,23 @@ import { getSection, registerSections, subscribeSection, unregisterSections } fr /** Register sections whose active ID the scroll-spy store writes to the URL hash. */ export const useScrollSpy = (sectionIds?: string[]) => { + // Keyed on the ids, not the array: callers map a fresh array each render, and re-registering rebuilds the observer. + // Section ids are DOM id suffixes, which can't hold whitespace, so the newline join round-trips. + const idsKey = sectionIds?.join('\n') ?? ''; + useEffect(() => { - if (sectionIds?.length) { - registerSections(sectionIds); - return () => unregisterSections(sectionIds); - } - }, [sectionIds]); + if (!idsKey) return; + const ids = idsKey.split('\n'); + registerSections(ids); + return () => unregisterSections(ids); + }, [idsKey]); }; /** Current scroll-spy section; updates once scrolling settles or immediately on an explicit action. */ export const useCurrentSection = () => useSyncExternalStore(subscribeSection, getSection); + +/** Render after lazy content inside its Suspense boundary: the spy only observes anchors that exist at registration time. */ +export function RegisterSpySections({ ids }: { ids: string[] }) { + useScrollSpy(ids); + return null; +} diff --git a/frontend/src/hooks/use-scroll-visibility.ts b/frontend/src/hooks/use-scroll-visibility.ts index ee1fc803e..51e6c9359 100644 --- a/frontend/src/hooks/use-scroll-visibility.ts +++ b/frontend/src/hooks/use-scroll-visibility.ts @@ -6,10 +6,9 @@ const MIN_VISIBLE_MS = 800; // Grace period after showing before a down-scroll m const RESET_COOLDOWN_MS = 500; // Post-reset window that swallows layout-driven scroll jank (e.g. drawer close, docs page swap) const INITIAL_COOLDOWN_MS = 500; // Brief cooldown on mount to prevent hiding from restored scroll position -/** Shows on scroll up, hides on scroll down; returns `{ isVisible, scrollTop, reset }`. */ +/** Shows on scroll up, hides on scroll down; returns `{ isVisible, reset }`. For a scroll offset threshold use `useScrolledPast`. */ export const useScrollVisibility = (enabled = true, containerRef?: RefObject<HTMLElement | null>) => { const [isVisible, setIsVisible] = useState(true); - const [scrollTop, setScrollTop] = useState(0); const [container, setContainer] = useState<HTMLElement | Window | null>(null); const lastScrollY = useRef(0); const lastScrollHeight = useRef(0); @@ -58,21 +57,17 @@ export const useScrollVisibility = (enabled = true, containerRef?: RefObject<HTM return; } - const getScrollHeight = () => - container instanceof Window ? document.documentElement.scrollHeight : container.scrollHeight; + const getScrollHeight = () => (container instanceof Window ? document.documentElement.scrollHeight : container.scrollHeight); // Sync baselines with the actual position to handle restored scroll on page reload const initialY = container instanceof Window ? container.scrollY : container.scrollTop; lastScrollY.current = initialY; lastScrollHeight.current = getScrollHeight(); - setScrollTop(initialY); const handleScroll = () => { const currentY = container instanceof Window ? container.scrollY : container.scrollTop; ticking.current = false; - setScrollTop(currentY); - // Scroll anchoring turns a content-height change into a non-gesture scroll event: resync without flipping. const currentHeight = getScrollHeight(); if (currentHeight !== lastScrollHeight.current) { @@ -113,5 +108,5 @@ export const useScrollVisibility = (enabled = true, containerRef?: RefObject<HTM return () => container.removeEventListener('scroll', onScroll); }, [enabled, container]); - return { isVisible, scrollTop, reset }; + return { isVisible, reset }; }; diff --git a/frontend/src/hooks/use-scrolled-past.ts b/frontend/src/hooks/use-scrolled-past.ts new file mode 100644 index 000000000..b4dc4d85a --- /dev/null +++ b/frontend/src/hooks/use-scrolled-past.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +/** True while the window is scrolled past `offset` px. Re-renders only when the threshold is crossed, not per scroll frame. */ +export const useScrolledPast = (offset: number, enabled = true) => { + const [isPast, setIsPast] = useState(false); + + useEffect(() => { + if (!enabled) { + setIsPast(false); + return; + } + + // Same-value updates bail out, so the handler can run on every scroll event + const update = () => setIsPast(window.scrollY > offset); + update(); + window.addEventListener('scroll', update, { passive: true }); + return () => window.removeEventListener('scroll', update); + }, [offset, enabled]); + + return isPast; +}; diff --git a/frontend/src/hooks/use-search-params.test.tsx b/frontend/src/hooks/use-search-params.test.tsx new file mode 100644 index 000000000..9303dc58c --- /dev/null +++ b/frontend/src/hooks/use-search-params.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import { createMemoryHistory, createRootRoute, createRoute, createRouter, Outlet, RouterProvider } from '@tanstack/react-router'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useSearchParams } from '~/hooks/use-search-params'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type TableSearch = { q?: string }; + +const seen = { renders: 0, search: {} as TableSearch, setSearch: (_values: Partial<TableSearch>) => {} }; + +function Table() { + const { search, setSearch } = useSearchParams<TableSearch>(); + seen.renders++; + seen.search = search; + seen.setSearch = setSearch; + return null; +} + +const createTestRouter = (url: string) => { + const rootRoute = createRootRoute({ staticData: { isAuth: false }, component: Outlet }); + const tableRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/table', + staticData: { isAuth: false }, + validateSearch: (search: Record<string, unknown>) => ({ + q: search.q as string | undefined, + attachmentDialogId: search.attachmentDialogId as string | undefined, + }), + component: Table, + }); + return createRouter({ routeTree: rootRoute.addChildren([tableRoute]), history: createMemoryHistory({ initialEntries: [url] }) }); +}; + +let root: Root | null = null; + +const mount = async (url: string) => { + const router = createTestRouter(url); + root = createRoot(document.createElement('div')); + await act(async () => { + root?.render(<RouterProvider router={router} />); + await router.load(); + }); + return router; +}; + +type TestRouter = ReturnType<typeof createTestRouter>; + +const resolved = (router: TestRouter) => + new Promise<void>((resolve) => { + const off = router.subscribe('onResolved', () => { + off(); + resolve(); + }); + }); + +const writeSearch = async (router: TestRouter, values: Record<string, string | undefined>) => { + await act(() => router.navigate({ to: '.', replace: true, search: (prev: Record<string, unknown>) => ({ ...prev, ...values }) })); +}; + +afterEach(() => { + act(() => root?.unmount()); + root = null; + seen.renders = 0; +}); + +describe('useSearchParams', () => { + it('ignores overlay keys, so an overlay write does not re-render the table', async () => { + const router = await mount('/table?q=a'); + const renders = seen.renders; + + await writeSearch(router, { attachmentDialogId: 'one' }); + await writeSearch(router, { attachmentDialogId: 'two' }); + + expect(seen.renders).toBe(renders); + expect(seen.search).toEqual({ q: 'a' }); + }); + + it('syncs its own keys when the URL changes elsewhere', async () => { + const router = await mount('/table?q=a'); + + await writeSearch(router, { q: 'c' }); + + expect(seen.search).toEqual({ q: 'c' }); + }); + + it('keeps the live overlay value when setSearch writes', async () => { + const router = await mount('/table?q=a&attachmentDialogId=one'); + expect(seen.search).toEqual({ q: 'a' }); + + await writeSearch(router, { attachmentDialogId: undefined }); + await act(async () => { + const done = resolved(router); + seen.setSearch({ q: 'b' }); + await done; + }); + + expect(router.state.location.search).toEqual({ q: 'b' }); + expect(seen.search).toEqual({ q: 'b' }); + }); +}); diff --git a/frontend/src/hooks/use-search-params.tsx b/frontend/src/hooks/use-search-params.tsx index 63a122ed1..2c61d8ff9 100644 --- a/frontend/src/hooks/use-search-params.tsx +++ b/frontend/src/hooks/use-search-params.tsx @@ -1,13 +1,25 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import type { router } from '~/routes/router'; import { objectKeys } from '~/utils/object-keys'; type RoutesById = keyof typeof router.routesById; -type SearchParams = { - from?: RoutesById; - saveDataInSearch?: boolean; +type SearchParams = { from?: RoutesById; saveDataInSearch?: boolean }; + +/** + * Keys owned by URL-driven overlays (user sheet, attachment dialog). Left out of the hook's state, so overlay writes + * don't re-render the table behind them, and setSearch never writes back a stale overlay value. + */ +const overlaySearchKeys = new Set(['userSheetId', 'attachmentDialogId', 'groupId']); + +/** Sorted keys and dropped undefined values, so equal search content always serializes to the same string. */ +const serializeSearch = (search: Record<string, unknown>) => { + const own: Record<string, unknown> = {}; + for (const key of Object.keys(search).sort()) { + if (!overlaySearchKeys.has(key)) own[key] = search[key]; + } + return JSON.stringify(own); }; /** Query param state; with `saveDataInSearch` it reads and writes the URL. Routes own defaults and stripping. */ @@ -16,13 +28,12 @@ export function useSearchParams<T extends Record<string, string | string[] | und const navigate = useNavigate(); const params = useParams(from ? { from, strict: true } : { strict: false }); - const search = useSearch(from ? { from, strict: true } : { strict: false }); - // Stable serialization of URL search, changes only when the URL changes. - const searchKey = saveDataInSearch ? JSON.stringify(search) : ''; - const prevSearchKeyRef = useRef(searchKey); + // A string select re-renders only when the hook's own keys change, not on every URL write. + const select = (search: Record<string, unknown>) => (saveDataInSearch ? serializeSearch(search) : '{}'); + const searchKey = useSearch(from ? { from, strict: true, select } : { strict: false, select }); - const getMergedSearch = () => (saveDataInSearch ? { ...search } : {}) as T; + const getMergedSearch = () => JSON.parse(searchKey) as T; const [currentSearch, setCurrentSearch] = useState<T>(getMergedSearch); @@ -56,9 +67,9 @@ export function useSearchParams<T extends Record<string, string | string[] | und } }; + // The URL write from setSearch lands here too; only a change made elsewhere (back, link, defaults) needs new state. useEffect(() => { - if (!saveDataInSearch || searchKey === prevSearchKeyRef.current) return; - prevSearchKeyRef.current = searchKey; + if (!saveDataInSearch || serializeSearch(currentSearch) === searchKey) return; setCurrentSearch(getMergedSearch()); }, [searchKey]); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ddefc8443..5e512bf3a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -12,37 +12,25 @@ export const clientConfig = { }; /** SDK API-error payload with a required, Hono-branded status and optional synthesized fields. */ -export type ApiErrorInit = Partial<Omit<ApiErrorPayload, 'status'>> & { - status: ClientErrorStatusCode | ServerErrorStatusCode; -}; +export type ApiErrorInit = Partial<Omit<ApiErrorPayload, 'status'>> & { status: ClientErrorStatusCode | ServerErrorStatusCode }; +/** The payload's fields are copied onto the error as they are; `declare` keeps them typed without emitting class fields. */ export class ApiError extends Error implements ApiErrorInit { - name: string; - status: ApiErrorInit['status']; - type?: string; - entityType?: ApiErrorPayload['entityType']; - severity?: ApiErrorPayload['severity']; - logId?: string; - path?: string; - method?: string; - timestamp?: string; - userId?: string; - organizationId?: string; - meta?: ApiErrorPayload['meta']; + declare status: ApiErrorInit['status']; + declare type?: string; + declare entityType?: ApiErrorPayload['entityType']; + declare severity?: ApiErrorPayload['severity']; + declare requestId?: string; + declare path?: string; + declare method?: string; + declare timestamp?: string; + declare userId?: string; + declare organizationId?: string; + declare meta?: ApiErrorPayload['meta']; - constructor(init: ApiErrorInit) { - super(init.message ?? init.type ?? init.name ?? `HTTP ${init.status}`); - this.name = init.name ?? init.type ?? 'ApiError'; - this.status = init.status; - this.type = init.type; - this.entityType = init.entityType; - this.severity = init.severity; - this.logId = init.logId; - this.path = init.path; - this.method = init.method; - this.timestamp = init.timestamp; - this.userId = init.userId; - this.organizationId = init.organizationId; - this.meta = init.meta; + constructor({ message, name, ...fields }: ApiErrorInit) { + super(message ?? fields.type ?? name ?? `HTTP ${fields.status}`); + Object.assign(this, fields); + this.name = name ?? fields.type ?? 'ApiError'; } } diff --git a/frontend/src/lib/export.test.ts b/frontend/src/lib/export.test.ts new file mode 100644 index 000000000..49aa4ad9e --- /dev/null +++ b/frontend/src/lib/export.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { exportToCsv } from '~/lib/export'; + +/** The text of the CSV file an export of these columns and rows downloads. */ +async function csvText(columns: { key: string; name: string }[], rows: Record<string, unknown>[]) { + let file: Blob | undefined; + vi.stubGlobal('document', { createElement: () => ({ click: () => {} }) }); + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + file = blob as Blob; + return 'blob:export'; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + await exportToCsv(columns, rows, 'export.csv'); + return file ? file.text() : ''; +} + +describe('exportToCsv', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('quotes cells with a comma, a quote or a line break and doubles inner quotes', async () => { + const columns = [ + { key: 'name', name: 'Name' }, + { key: 'message', name: 'Message' }, + ]; + const rows = [ + { name: 'Plain', message: 'Say "hi"' }, + { name: 'Smith, Jo', message: 'first line\nsecond line' }, + ]; + + expect(await csvText(columns, rows)).toBe(['Name,Message', 'Plain,"Say ""hi"""', '"Smith, Jo","first line\nsecond line"'].join('\n')); + }); + + it('writes a list as one quoted cell', async () => { + const rows = [{ labels: ['bug', 'ui'] }, { labels: ['solo'] }]; + + expect(await csvText([{ key: 'labels', name: 'Labels' }], rows)).toBe(['Labels', '"bug, ui"', 'solo'].join('\n')); + }); +}); diff --git a/frontend/src/lib/export.ts b/frontend/src/lib/export.ts index a113deb74..4a035647d 100644 --- a/frontend/src/lib/export.ts +++ b/frontend/src/lib/export.ts @@ -1,6 +1,5 @@ import dayjs from 'dayjs'; import localizedFormat from 'dayjs/plugin/localizedFormat'; -import type { ReactElement } from 'react'; import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; import type { Mode } from '~/modules/ui/ui-store'; @@ -8,14 +7,12 @@ dayjs.extend(localizedFormat); // biome-ignore lint/suspicious/noExplicitAny: any is required here type Row = Record<string, any>; -type Column = ColumnOrColumnGroup<Row>; + +/** A date as an export writes it, for a column's `exportValue`; a missing date stays missing. */ +export const exportDate = (date?: string | number | Date | null) => (date ? dayjs.utc(date).local().format('lll') : null); /** Exports visible table columns/rows to a downloadable CSV file. */ -export async function exportToCsv<R extends Row>( - columns: { key: string; name: ReactElement | string }[], - rows: R[], - fileName: string, -) { +export async function exportToCsv<R extends Row>(columns: ColumnOrColumnGroup<R>[], rows: R[], fileName: string) { if (!rows.length) return; const preparedColumns = columns.filter((column) => filterColumns(column)); @@ -27,22 +24,13 @@ export async function exportToCsv<R extends Row>( } /** Exports visible table columns/rows to a PDF styled for `mode`, with a page-name and export-date header. */ -export async function exportToPdf<R extends Row>( - columns: { key: string; name: ReactElement | string }[], - rows: R[], - fileName: string, - pageName: string, - mode: Mode, -) { +export async function exportToPdf<R extends Row>(columns: ColumnOrColumnGroup<R>[], rows: R[], fileName: string, pageName: string, mode: Mode) { const preparedColumns = columns.filter((column) => filterColumns(column)); const head = [preparedColumns.map((column) => String(column.name))]; const body = formatBodyData(rows, preparedColumns); const [{ jsPDF }, autoTable] = await Promise.all([import('jspdf'), (await import('jspdf-autotable')).default]); - const doc = new jsPDF({ - orientation: 'l', - unit: 'px', - }); + const doc = new jsPDF({ orientation: 'l', unit: 'px' }); const exportDate = dayjs().format('lll'); const exportInfo = `Exported from page: ${pageName}\nExport Date: ${exportDate}`; @@ -57,51 +45,31 @@ export async function exportToPdf<R extends Row>( body, startY: 40, horizontalPageBreak: true, - styles: { - cellPadding: 1.5, - fontSize: 10, - cellWidth: 'wrap', - textColor, - fillColor: backgroundColor, - }, - bodyStyles: { - fillColor: backgroundColor, - }, + styles: { cellPadding: 1.5, fontSize: 10, cellWidth: 'wrap', textColor, fillColor: backgroundColor }, + bodyStyles: { fillColor: backgroundColor }, alternateRowStyles: { fillColor: alternateBackgroundColor }, tableWidth: 'wrap', }); doc.save(fileName); } -const formatRowData = <R extends Row>(row: R, column: Column) => { - if ((column.key === 'adminCount' || column.key === 'memberCount') && 'counts' in row && 'membership' in row.counts) { - const key = column.key.replace('Count', ''); - return row.counts.membership[key]; - } - if (column.key === 'role') return row.role ?? row.membership?.role ?? '-'; - - const date = dayjs.utc(row[column.key]).local(); - if (date.isValid()) return date.format('lll'); - - return row[column.key] ?? '-'; -}; +const formatRowData = <R extends Row>(row: R, column: ColumnOrColumnGroup<R>) => + (column.exportValue ? column.exportValue(row) : row[column.key]) ?? '-'; -const formatBodyData = <R extends Row>(rows: R[], columns: Column[]): (string | number)[][] => { +const formatBodyData = <R extends Row>(rows: R[], columns: ColumnOrColumnGroup<R>[]): (string | number)[][] => { return rows.map((row) => columns.map((column) => formatRowData(row, column))); }; -const filterColumns = (column: Column) => { +/** Exports the columns the table shows, leaving out selection and nameless (action) columns. */ +const filterColumns = <R extends Row>(column: ColumnOrColumnGroup<R>) => { const invalidColumnKeys = ['subscription', 'checkbox-column']; - if ('visible' in column && !invalidColumnKeys.includes(column.key) && column.name !== '') return column.visible; - return false; + return !column.hidden && !invalidColumnKeys.includes(column.key) && column.name !== ''; }; +/** A cell with a comma, quote or line break is quoted with its quotes doubled (RFC 4180); a list is one cell. */ function serialiseCellValue(value: unknown) { - if (typeof value === 'string') { - const formattedValue = value.replace(/"/g, '""'); - return formattedValue.includes(',') ? `"${formattedValue}"` : formattedValue; - } - return value; + const text = Array.isArray(value) ? value.join(', ') : String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } function downloadFile(fileName: string, data: Blob) { diff --git a/frontend/src/lib/i18n-locales.ts b/frontend/src/lib/i18n-locales.ts index e38732835..ddb754d0c 100644 --- a/frontend/src/lib/i18n-locales.ts +++ b/frontend/src/lib/i18n-locales.ts @@ -4,17 +4,12 @@ import enApp from '../../../locales/en/app.json'; import enCommon from '../../../locales/en/common.json'; import enError from '../../../locales/en/error.json'; -const enCommonExtended = { - ...enCommon, - ...enApp, -}; +const enCommonExtended = { ...enCommon, ...enApp }; /** Any valid translation key: bare `c` keys plus `c:`/`about:`/`error:` prefixed ones, per i18next-resources.d.ts. */ export type TKey = ParseKeys; -const locales = { - en: { about: enAbout, c: enCommonExtended, error: enError }, -}; +const locales = { en: { about: enAbout, c: enCommonExtended, error: enError } }; export { locales }; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 916cc3e69..67d6d9b97 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -18,9 +18,7 @@ const initOptions: InitOptions = { interpolation: { escapeValue: false, // React escapes by default }, - react: { - useSuspense: false, - }, + react: { useSuspense: false }, defaultNS: 'c', backend: { // Processed namespaces (common + app merged into `c`), served by the vite plugin in dev and as build assets. diff --git a/frontend/src/lib/i18next-resources.d.ts b/frontend/src/lib/i18next-resources.d.ts index 62b2231e4..9d3087774 100644 --- a/frontend/src/lib/i18next-resources.d.ts +++ b/frontend/src/lib/i18next-resources.d.ts @@ -6,10 +6,6 @@ import type enError from '../../../locales/en/error.json'; declare module 'i18next' { interface CustomTypeOptions { defaultNS: ['c', 'about', 'error']; - resources: { - c: typeof enCommon & typeof enApp; - about: typeof enAbout; - error: typeof enError; - }; + resources: { c: typeof enCommon & typeof enApp; about: typeof enAbout; error: typeof enError }; } } diff --git a/frontend/src/lib/oauth-interaction.ts b/frontend/src/lib/oauth-interaction.ts index f66c237ff..3bc5d6720 100644 --- a/frontend/src/lib/oauth-interaction.ts +++ b/frontend/src/lib/oauth-interaction.ts @@ -14,8 +14,7 @@ export interface ConsentDetails { } /** The interaction routes live on the authorization server, outside the OpenAPI spec, so no SDK function exists. */ -const interactionUrl = (uid: string, suffix: string) => - `${appConfig.oauthUrl}/interaction/${encodeURIComponent(uid)}/${suffix}`; +const interactionUrl = (uid: string, suffix: string) => `${appConfig.oauthUrl}/interaction/${encodeURIComponent(uid)}/${suffix}`; async function request<T>(url: string, init?: RequestInit): Promise<T> { const response = await clientConfig.fetch(url, init); diff --git a/frontend/src/lib/otel.ts b/frontend/src/lib/otel.ts index 17fa9667c..8b8c4568b 100644 --- a/frontend/src/lib/otel.ts +++ b/frontend/src/lib/otel.ts @@ -13,10 +13,7 @@ export const spanStore = createSpanStore({ maxSpans: 500 }); if (!mapleEnabled) { const provider = new WebTracerProvider({ - resource: resourceFromAttributes({ - [ATTR_SERVICE_NAME]: `${appConfig.slug}-frontend`, - 'deployment.environment.name': appConfig.mode, - }), + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: `${appConfig.slug}-frontend`, 'deployment.environment.name': appConfig.mode }), spanProcessors: [createSpanStoreProcessor({ store: spanStore })], }); @@ -24,14 +21,9 @@ if (!mapleEnabled) { // Guarded for test environments where appConfig is partially mocked. if (appConfig.backendUrl) { + const backendOrigin = new RegExp(`^${appConfig.backendUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/|$)`); registerInstrumentations({ - instrumentations: [ - new FetchInstrumentation({ - propagateTraceHeaderCorsUrls: [ - new RegExp(`^${appConfig.backendUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/|$)`), - ], - }), - ], + instrumentations: [new FetchInstrumentation({ propagateTraceHeaderCorsUrls: [backendOrigin] })], }); } } diff --git a/frontend/src/lib/placements.test.ts b/frontend/src/lib/placements.test.ts index 5966ba080..5b33a6978 100644 --- a/frontend/src/lib/placements.test.ts +++ b/frontend/src/lib/placements.test.ts @@ -111,11 +111,7 @@ describe('resolvePlacementList', () => { it('drops entries whose required grant or visibleTo pair is absent', () => { expect(resolvePlacementList('host', items, { overrides: {} }).map((i) => i.id)).toEqual(['general', 'extra']); - const full = resolvePlacementList('host', items, { - overrides: {}, - grants: ['delete'], - pairs: ['organization.admin'], - }); + const full = resolvePlacementList('host', items, { overrides: {}, grants: ['delete'], pairs: ['organization.admin'] }); expect(full.map((i) => i.id)).toEqual(['general', 'staff-only', 'extra', 'danger']); }); @@ -135,10 +131,7 @@ describe('resolvePlacementList', () => { grants: ['delete'], pairs: ['organization.admin'], overrides: { - host: { - general: { hidden: true }, - danger: { order: 5 }, - }, + host: { general: { hidden: true }, danger: { order: 5 } }, }, }); // locked 'general' still hidden by the code layer; 'danger' moved first by the order override diff --git a/frontend/src/lib/placements.ts b/frontend/src/lib/placements.ts index 8a9f426fb..9dfefe9cb 100644 --- a/frontend/src/lib/placements.ts +++ b/frontend/src/lib/placements.ts @@ -68,10 +68,7 @@ export interface SlotContexts extends ChannelSettingsSlotContexts, ChannelTabsSl export type Slot = keyof SlotContexts & string; /** A tool in one slot: `render` takes the slot context, returns its full content unit, and lazy-loads heavy UI. */ -export type ToolFor<S extends Slot> = PlacementDescriptor & { - slot: S; - render: (context: SlotContexts[S]) => ReactNode; -}; +export type ToolFor<S extends Slot> = PlacementDescriptor & { slot: S; render: (context: SlotContexts[S]) => ReactNode }; export type Tool = { [S in Slot]: ToolFor<S> }[Slot]; @@ -127,10 +124,7 @@ export function getSlotDescriptors(slot: string): (PlacementDescriptor & { slot: } /** Stored ids first in stored order, unlisted placements appended by declared `order`, unmatched stored ids ignored. */ -export function orderBySlotConfig<T extends PlacementDescriptor & { order: number }>( - items: T[], - slotConfig?: SlotToolsConfig, -): T[] { +export function orderBySlotConfig<T extends PlacementDescriptor & { order: number }>(items: T[], slotConfig?: SlotToolsConfig): T[] { const stored = slotConfig?.order; if (!stored?.length) return [...items].sort((a, b) => a.order - b.order); const rank = new Map(stored.map((id, index) => [id, index])); diff --git a/frontend/src/lib/sw.ts b/frontend/src/lib/sw.ts index 72edc7371..e7945cfd8 100644 --- a/frontend/src/lib/sw.ts +++ b/frontend/src/lib/sw.ts @@ -1,17 +1,13 @@ /// <reference lib="webworker" /> import { CacheFirst, ExpirationPlugin, type PrecacheEntry, Serwist, StaleWhileRevalidate } from 'serwist'; -declare const self: ServiceWorkerGlobalScope & { - __WB_MANIFEST: (PrecacheEntry | string)[]; -}; +declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: (PrecacheEntry | string)[] }; declare const __BACKEND_URL__: string; // Excludes a same-origin backend prefix from the SPA navigation fallback so OAuth and downloads hit the network. const apiPathPrefix = new URL(__BACKEND_URL__, self.location.origin).pathname.replace(/\/+$/, ''); -const navigationDenylist = apiPathPrefix - ? [new RegExp(`^${apiPathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`)] - : []; +const navigationDenylist = apiPathPrefix ? [new RegExp(`^${apiPathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`)] : []; // `skipWaiting: false` keeps the update prompt: Serwist listens for the client's `{type: 'SKIP_WAITING'}` message. const serwist = new Serwist({ @@ -29,8 +25,7 @@ const serwist = new Serwist({ { // Docs files keep stable names per release; the app appends ?v=<sha> so each release keys fresh cache entries. matcher: ({ url }) => - url.origin === self.location.origin && - (url.pathname.startsWith('/static/docs.gen/') || url.pathname === '/static/openapi.json'), + url.origin === self.location.origin && (url.pathname.startsWith('/static/docs.gen/') || url.pathname === '/static/openapi.json'), handler: new StaleWhileRevalidate({ cacheName: 'docs-gen', plugins: [new ExpirationPlugin({ maxEntries: 40 })], @@ -49,11 +44,7 @@ const serwist = new Serwist({ // English-only titles by design: the payload carries ids and a type, never localized content, so // the closed-app toast stays generic and the app renders the localized inbox on open. -const pushTitles: Record<string, string> = { - mention: 'You were mentioned', - reply: 'New reply', - comment: 'New comment', -}; +const pushTitles: Record<string, string> = { mention: 'You were mentioned', reply: 'New reply', comment: 'New comment' }; /** { t: 'notif', activityId, channelId, type, url } from push-sender.ts; anything else is dropped. */ interface NotificationPushData { diff --git a/frontend/src/lib/tracing.ts b/frontend/src/lib/tracing.ts index cae2879f6..618be8d2c 100644 --- a/frontend/src/lib/tracing.ts +++ b/frontend/src/lib/tracing.ts @@ -55,11 +55,8 @@ export async function withSpan<T>(name: string, attrs: SpanAttrs, fn: (ctx: Trac return tracer.startActiveSpan(name, async (span) => { applyAttrs(span, attrs); try { - const ctx: TraceContext = { - traceId: span.spanContext().traceId, - spanId: span.spanContext().spanId, - cdcTimestamp: Date.now(), - }; + const { traceId, spanId } = span.spanContext(); + const ctx: TraceContext = { traceId, spanId, cdcTimestamp: Date.now() }; const result = await fn(ctx); span.setStatus({ code: SpanStatusCode.OK }); return result; @@ -77,11 +74,8 @@ export function withSpanSync<T>(name: string, attrs: SpanAttrs, fn: (ctx: TraceC const span = tracer.startSpan(name); applyAttrs(span, attrs); try { - const ctx: TraceContext = { - traceId: span.spanContext().traceId, - spanId: span.spanContext().spanId, - cdcTimestamp: Date.now(), - }; + const { traceId, spanId } = span.spanContext(); + const ctx: TraceContext = { traceId, spanId, cdcTimestamp: Date.now() }; const result = fn(ctx); span.setStatus({ code: SpanStatusCode.OK }); return result; diff --git a/frontend/src/list-queries-config.tsx b/frontend/src/list-queries-config.tsx index 9455aa4b3..c4c3b05ff 100644 --- a/frontend/src/list-queries-config.tsx +++ b/frontend/src/list-queries-config.tsx @@ -63,12 +63,8 @@ export const buildEntitySyncQueries = ({ case 'project': { addMembersQuery('project'); - syncQueries.push( - tasksCanonicalOptions({ organizationId: currentOrganizationId, tenantId, projectId: targetEntityId }), - ); - syncQueries.push( - labelsCanonicalOptions({ organizationId: currentOrganizationId, tenantId, projectId: targetEntityId }), - ); + syncQueries.push(tasksCanonicalOptions({ organizationId: currentOrganizationId, tenantId, projectId: targetEntityId })); + syncQueries.push(labelsCanonicalOptions({ organizationId: currentOrganizationId, tenantId, projectId: targetEntityId })); break; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7eb734d27..8c44add85 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -48,7 +48,7 @@ ReactDOM.createRoot(root, { <StrictMode> <Themer /> {/* size="1rem" keeps the emitted width/height attributes truthful: they mirror the - `:where(svg.lucide)` CSS default instead of lucide's misleading px 24. Sizing itself + `:where(svg.lucide)` CSS default, not lucide's misleading px 24. Sizing itself stays class-based (icon-* utilities); classes override both the rule and the attrs. The cast bridges a lucide-react typing gap: LucideConfig narrows size to number, while the icons consuming the context accept LucideProps' string | number. */} diff --git a/frontend/src/members-config.ts b/frontend/src/members-config.ts index 8cc79d8eb..272054b95 100644 --- a/frontend/src/members-config.ts +++ b/frontend/src/members-config.ts @@ -7,9 +7,7 @@ import type { ChannelEntityType, ProductEntityType } from 'shared'; * the attachment paperclip and hides nothing; apps map their own product types here without * editing the template's members-columns. */ -export const memberStatIcons: Partial<Record<ProductEntityType, LucideIcon>> = { - attachment: PaperclipIcon, -}; +export const memberStatIcons: Partial<Record<ProductEntityType, LucideIcon>> = { attachment: PaperclipIcon }; /** * Count columns (`${type}Count`, product and sub-channel types alike) hidden by default. Users can diff --git a/frontend/src/menu-config.tsx b/frontend/src/menu-config.tsx index 7238aa97e..c171c9583 100644 --- a/frontend/src/menu-config.tsx +++ b/frontend/src/menu-config.tsx @@ -23,15 +23,15 @@ function createOrganizationAction(triggerRef: RefObject<HTMLButtonElement | null } }; + const title = i18n.t('c:create_resource', { resource: i18n.t('c:organization').toLowerCase() }); + return useDialoger.getState().create(<CreateOrganizationForm dialog callback={callback} />, { className: 'md:max-w-2xl', id: 'create-organization', description: i18n.t('c:create_organization.text'), triggerRef, - title: i18n.t('c:create_resource', { resource: i18n.t('c:organization').toLowerCase() }), - titleContent: ( - <UnsavedBadge title={i18n.t('c:create_resource', { resource: i18n.t('c:organization').toLowerCase() })} /> - ), + title, + titleContent: <UnsavedBadge title={title} />, }); } @@ -46,9 +46,7 @@ const createWorkspaceAction = (triggerRef: RefObject<HTMLButtonElement | null>) description: i18n.t('c:create_workspace.text'), triggerRef, title: i18n.t('c:create_resource', { resource: i18n.t('c:workspace').toLowerCase() }), - titleContent: ( - <UnsavedBadge title={i18n.t('c:create_resource', { resource: i18n.t('c:workspace').toLowerCase() })} /> - ), + titleContent: <UnsavedBadge title={i18n.t('c:create_resource', { resource: i18n.t('c:workspace').toLowerCase() })} />, }); }; @@ -57,10 +55,5 @@ const createWorkspaceAction = (triggerRef: RefObject<HTMLButtonElement | null>) */ export const menuSectionsSchema: Partial<Record<ChannelEntityType, MenuSectionOptions>> = { organization: { createAction: createOrganizationAction, label: 'c:organization_other', entityType: 'organization' }, - workspace: { - createAction: createWorkspaceAction, - label: 'c:workspace_other', - icon: FolderIcon, - entityType: 'workspace', - }, + workspace: { createAction: createWorkspaceAction, label: 'c:workspace_other', icon: FolderIcon, entityType: 'workspace' }, }; diff --git a/frontend/src/modules/attachment/README.md b/frontend/src/modules/attachment/README.md index 6e25ca3e8..c60e6d681 100644 --- a/frontend/src/modules/attachment/README.md +++ b/frontend/src/modules/attachment/README.md @@ -24,7 +24,7 @@ Transloadit produces up to four cloud objects per file, stored as one `keys` map | URL resolution | `helpers/resolve-url.ts` | `resolveAttachmentUrl`: local blob first, cloud fallback, enqueues a background download. `resolveBlockNoteFileRef`: the same for editor blocks, after the media grammar (`shared/src/utils/media-ref.ts`) accepts the ref as an attachment id, a key under the document's organization or an asset URL; any other ref renders nothing. | | URL hooks | `hooks/use-attachment-url.ts`, `hooks/use-resolved-attachments.ts` | React bindings, single and batch (retry-before-"not found", blob-URL lifecycle). | | Table | `table/` | Org grid (route `/$tenantId/$orgSlug/organization/attachments`): thumbnail with upload-status badge, inline rename, description cell whose editor is the sheet (double click, Enter or the hover pencil; text selectable in place), per-row cloud download, bulk delete with confirmation, seen-marking via row visibility. | -| Description | `attachment-description-sheet.tsx` | `CollaborativeBlockNote` in a sheet, which fetches a Yjs token for the attachment it opens (the backend op is the Yjs materializer); organization members feed the mention menu. Mentions are derived server-side into `mentions` and fan out as notifications: the template's consumer of the notifications contract, the shape apps copy for their products. | +| Description | `attachment-description-sheet.tsx` | `CollaborativeBlockNote` in a sheet, which fetches a Yjs token for the attachment it opens (the backend op is the Yjs materializer); organization members feed the mention menu. The description is a notification source: the fan-out reads its mentions from the stored body. It is the template's consumer of the notifications contract, the shape apps copy for their products. | | Viewer | `dialog/`, `attachments-carousel.tsx` | Full-screen dialog driven by the `attachmentDialogId` (+ `groupId`) search params via the globally mounted `AttachmentDialogHandler`: deep-linkable, reload-safe, back button closes it; slide navigation rewrites the param with `replace: true`. The caption shows the description text and opens the editor sheet (closing the dialog first: sheets stack below dialogs). Param keys and dialog chrome: `dialog/params.ts`, never spelled out elsewhere. | | Renderers | `render/` | Lazy per-mime renderers (pan/zoom image, audio, video, react-pdf); unsupported types show a "download to view" placeholder. | diff --git a/frontend/src/modules/attachment/attachment-description-sheet.tsx b/frontend/src/modules/attachment/attachment-description-sheet.tsx index bdcab49ed..08741f80f 100644 --- a/frontend/src/modules/attachment/attachment-description-sheet.tsx +++ b/frontend/src/modules/attachment/attachment-description-sheet.tsx @@ -14,6 +14,7 @@ import { membersListQueryOptions } from '~/modules/memberships/query'; import type { Member } from '~/modules/memberships/types'; import { findInCache } from '~/query/basic/find-in-list-cache'; import { flattenInfiniteData } from '~/query/basic/flatten'; +import { tw } from '~/utils/tw'; const sheetId = 'attachment-description'; @@ -35,9 +36,7 @@ function AttachmentDescriptionForm({ attachment }: { attachment: Attachment }) { // The map is the home channel's own, so the row is at home for a home-scoped grant. const canEdit = resolveCan(channel?.can?.attachment?.update, attachment.createdBy, { row: homeId, channel: homeId }); - const membersQuery = useInfiniteQuery( - membersListQueryOptions({ entityId: homeId, entityType: homeType, tenantId, organizationId }), - ); + const membersQuery = useInfiniteQuery(membersListQueryOptions({ entityId: homeId, entityType: homeType, tenantId, organizationId })); const members = flattenInfiniteData<Member>(membersQuery.data); const { mutateAsync } = useAttachmentUpdateMutation(tenantId, organizationId); @@ -88,7 +87,7 @@ export function openAttachmentDescriptionSheet(attachment: Attachment, triggerRe id: sheetId, triggerRef, side: 'right', - className: 'max-w-full lg:max-w-3xl', + className: tw('max-w-full lg:max-w-3xl'), title: attachment.name, description: i18n.t('c:description'), }, diff --git a/frontend/src/modules/attachment/attachments-carousel.tsx b/frontend/src/modules/attachment/attachments-carousel.tsx index edeeff79b..95ad03b89 100644 --- a/frontend/src/modules/attachment/attachments-carousel.tsx +++ b/frontend/src/modules/attachment/attachments-carousel.tsx @@ -8,11 +8,7 @@ import { textFromDocument } from 'shared/blocknote'; import { isCDNUrl } from 'shared/utils/is-cdn-url'; import { useLatestCallback, useLatestRef } from '~/hooks/use-latest-ref'; import { openAttachmentDialog } from '~/modules/attachment/dialog/open-attachment-dialog'; -import { - ATTACHMENT_DIALOG_PARAM, - attachmentDialogStageClassName, - clearAttachmentDialogSearchParams, -} from '~/modules/attachment/dialog/params'; +import { ATTACHMENT_DIALOG_PARAM, attachmentDialogStageClassName, clearAttachmentDialogSearchParams } from '~/modules/attachment/dialog/params'; import { FilePlaceholder } from '~/modules/attachment/file-placeholder'; import { AttachmentRender } from '~/modules/attachment/render/attachment-render'; import { CloseButton } from '~/modules/common/close-button'; @@ -52,27 +48,15 @@ interface CarouselPropsBase { } type CarouselProps = - | (CarouselPropsBase & { - isDialog: true; - saveInSearchParams: boolean; - }) - | (CarouselPropsBase & { - isDialog?: false; - saveInSearchParams?: never; - }); + | (CarouselPropsBase & { isDialog: true; saveInSearchParams: boolean }) + | (CarouselPropsBase & { isDialog?: false; saveInSearchParams?: never }); -export function AttachmentsCarousel({ - items, - isDialog = false, - itemIndex = 0, - saveInSearchParams = false, - classNameContainer, -}: CarouselProps) { +export function AttachmentsCarousel({ items, isDialog = false, itemIndex = 0, saveInSearchParams = false, classNameContainer }: CarouselProps) { const navigate = useNavigate(); const removeDialog = useDialoger((state) => state.remove); const { download, isInProgress } = useDownloader(); - const { attachmentDialogId } = useSearch({ strict: false }); + const attachmentDialogId = useSearch({ strict: false, select: (s) => s.attachmentDialogId }); const nextButtonRef = useRef(null); const [watchDrag, setWatchDrag] = useState(items.length > 1); @@ -104,12 +88,7 @@ export function AttachmentsCarousel({ return; } - navigate({ - to: '.', - replace: true, - resetScroll: false, - search: (prev) => ({ ...prev, [ATTACHMENT_DIALOG_PARAM]: newItem.id }), - }); + navigate({ to: '.', replace: true, resetScroll: false, search: (prev) => ({ ...prev, [ATTACHMENT_DIALOG_PARAM]: newItem.id }) }); }; const toggleWatchDrag = (enabled: boolean) => setWatchDrag(enabled && items.length > 1); @@ -132,7 +111,7 @@ export function AttachmentsCarousel({ isDialog={isDialog} opts={{ duration: 20, loop: true, startIndex: startIndexRef.current ?? 0, watchDrag }} plugins={isDialog ? [] : [Autoplay({ delay: 4000, stopOnInteraction: true, stopOnMouseEnter: true })]} - className="group h-full w-full" + className="group size-full" setApi={handleSetApi} > {/* z-20 matches the zoom controls: at z-10 the viewer, a flex item with the same index, paints over the title. */} @@ -142,9 +121,7 @@ export function AttachmentsCarousel({ {/* The visible name is the dialog's accessible name; with no name, a screen-reader-only title labels it. */} {currentItem.name ? ( <DialogTitle className="ml-1 flex h-6 min-w-0 items-center gap-2 truncate text-base leading-6 tracking-tight max-sm:text-sm"> - {currentItem.contentType && ( - <FilePlaceholder contentType={currentItem.contentType} className="icon-md shrink-0" strokeWidth={2} /> - )} + {currentItem.contentType && <FilePlaceholder contentType={currentItem.contentType} className="size-4 shrink-0" strokeWidth={2} />} <span className="truncate">{currentItem.name}</span> </DialogTitle> ) : ( @@ -156,20 +133,21 @@ export function AttachmentsCarousel({ size="icon" aria-expanded={descriptionOpen} aria-label={i18n.t('c:description')} - className="-my-1 size-8 shrink-0 opacity-70 hover:opacity-100 active:translate-y-0!" + className="-my-1 size-8 shrink-0 opacity-70 hover:opacity-100" + press={false} onClick={() => setDescriptionOpen(!descriptionOpen)} > <span className="relative size-5"> <InfoIcon className={cn( - 'absolute inset-0 h-5 w-5 transition-all duration-200 motion-reduce:transition-none', + 'absolute inset-0 size-5 transition-all duration-200 motion-reduce:transition-none', descriptionOpen ? 'rotate-90 opacity-0' : 'rotate-0 opacity-100', )} strokeWidth={1.5} /> <ChevronUpIcon className={cn( - 'absolute inset-0 h-5 w-5 transition-all duration-200 motion-reduce:transition-none', + 'absolute inset-0 size-5 transition-all duration-200 motion-reduce:transition-none', descriptionOpen ? 'rotate-0 opacity-100' : '-rotate-90 opacity-0', )} strokeWidth={1.5} @@ -182,10 +160,11 @@ export function AttachmentsCarousel({ <Button variant="ghost" size="icon" + aria-label={i18n.t('c:open')} className="-my-1 size-8 opacity-70 hover:opacity-100" onClick={() => window.open(currentItem.url, '_blank')} > - <ExternalLinkIcon className="h-5 w-5" strokeWidth={1.5} /> + <ExternalLinkIcon className="size-5" strokeWidth={1.5} /> </Button> )} @@ -195,14 +174,11 @@ export function AttachmentsCarousel({ variant="ghost" size="icon" disabled={isInProgress} + aria-label={i18n.t('c:download')} className="-my-1 size-8 opacity-70 hover:opacity-100" onClick={() => download(currentItem.url, currentItem.filename || 'file')} > - {isInProgress ? ( - <Spinner className="size-5 text-foreground/80" noDelay /> - ) : ( - <DownloadIcon className="h-5 w-5" strokeWidth={1.5} /> - )} + {isInProgress ? <Spinner className="size-5 text-foreground/80" noDelay /> : <DownloadIcon className="size-5" strokeWidth={1.5} />} </Button> )} @@ -222,7 +198,7 @@ export function AttachmentsCarousel({ descriptionOpen ? 'opacity-100' : 'opacity-0', )} > - <p className="mt-2 ml-1 max-w-3xl text-sm opacity-80 max-sm:mx-auto">{descriptionText}</p> + <p className="mt-2 ml-1 max-w-3xl text-muted-foreground text-sm max-sm:mx-auto">{descriptionText}</p> </div> </div> )} diff --git a/frontend/src/modules/attachment/dialog/attachment-dialog-handler.tsx b/frontend/src/modules/attachment/dialog/attachment-dialog-handler.tsx index d2cc877c3..39f54ee94 100644 --- a/frontend/src/modules/attachment/dialog/attachment-dialog-handler.tsx +++ b/frontend/src/modules/attachment/dialog/attachment-dialog-handler.tsx @@ -1,11 +1,7 @@ import { useMatch, useNavigate, useSearch } from '@tanstack/react-router'; import { memo, useEffect } from 'react'; import { AttachmentDialog } from '~/modules/attachment/dialog/attachment-dialog'; -import { - ATTACHMENT_DIALOG_PARAM, - attachmentDialogOptions, - clearAttachmentDialogSearch, -} from '~/modules/attachment/dialog/params'; +import { ATTACHMENT_DIALOG_PARAM, attachmentDialogOptions, clearAttachmentDialogSearch } from '~/modules/attachment/dialog/params'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { fallbackContentRef } from '~/utils/fallback-content-ref'; @@ -14,10 +10,8 @@ const dialogId = ATTACHMENT_DIALOG_PARAM; /** A stable dialog id keeps carousel navigation from recreating the dialog. */ function AttachmentDialogHandlerBase() { const navigate = useNavigate(); - const searchParams = useSearch({ strict: false }) as Record<string, string | undefined>; - const orgMatch = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false }); - const organizationId = orgMatch?.context?.organization?.id; - const isOpen = !!searchParams[ATTACHMENT_DIALOG_PARAM]; + const isOpen = useSearch({ strict: false, select: (s) => !!(s as Record<string, string | undefined>)[ATTACHMENT_DIALOG_PARAM] }); + const organizationId = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false, select: (m) => m.context.organization?.id }); useEffect(() => { if (!isOpen) return; diff --git a/frontend/src/modules/attachment/dialog/attachment-dialog.tsx b/frontend/src/modules/attachment/dialog/attachment-dialog.tsx index ec3b53d7c..346de0c08 100644 --- a/frontend/src/modules/attachment/dialog/attachment-dialog.tsx +++ b/frontend/src/modules/attachment/dialog/attachment-dialog.tsx @@ -18,9 +18,9 @@ type AttachmentDialogItem = Partial<CarouselItemData> & { id: string }; export function AttachmentDialog() { const removeDialog = useDialoger((state) => state.remove); - const orgMatch = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false }); - const tenantId = orgMatch?.params?.tenantId; - const organizationId = orgMatch?.context?.organization?.id; + // Primitive selects: the match object is rebuilt on every carousel URL write. + const tenantId = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false, select: (m) => m.params.tenantId }); + const organizationId = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false, select: (m) => m.context.organization?.id }); const groupId = useSearch({ strict: false, select: (s) => (s as { groupId?: string }).groupId }); @@ -59,7 +59,7 @@ export function AttachmentDialog() { if (blocking && !hasRenderedRef.current) { return ( <div className="flex h-dvh items-center justify-center"> - <Spinner className="h-12 w-12" /> + <Spinner className="size-12" /> </div> ); } diff --git a/frontend/src/modules/attachment/dialog/open-attachment-dialog.tsx b/frontend/src/modules/attachment/dialog/open-attachment-dialog.tsx index a997784a0..2d8fff72d 100644 --- a/frontend/src/modules/attachment/dialog/open-attachment-dialog.tsx +++ b/frontend/src/modules/attachment/dialog/open-attachment-dialog.tsx @@ -16,9 +16,7 @@ export const openAttachmentDialog = ({ attachmentIndex, attachments, triggerRef </div>, attachmentDialogOptions({ id: 'attachment-dialog', - triggerRef: triggerRef || { - current: document.activeElement instanceof HTMLButtonElement ? document.activeElement : null, - }, + triggerRef: triggerRef || { current: document.activeElement instanceof HTMLButtonElement ? document.activeElement : null }, }), ); }; diff --git a/frontend/src/modules/attachment/dialog/params.ts b/frontend/src/modules/attachment/dialog/params.ts index 37a81f6b2..d56f993d2 100644 --- a/frontend/src/modules/attachment/dialog/params.ts +++ b/frontend/src/modules/attachment/dialog/params.ts @@ -1,5 +1,6 @@ import type { DialogData } from '~/modules/common/dialoger/use-dialoger'; import { getRouter } from '~/routes/-router-instance'; +import { tw } from '~/utils/tw'; /** Search param holding the open attachment's id. Its presence is the dialog's open state. */ export const ATTACHMENT_DIALOG_PARAM = 'attachmentDialogId'; @@ -8,24 +9,20 @@ export const ATTACHMENT_DIALOG_PARAM = 'attachmentDialogId'; const ATTACHMENT_DIALOG_EXTRA_PARAMS = ['groupId'] as const; /** Chrome shared by both ways of opening the carousel (URL-driven and imperative). */ -export const attachmentDialogClassName = 'min-w-full h-dvh max-h-dvh border-0 p-0 rounded-none flex flex-col mt-0'; +export const attachmentDialogClassName = tw('mt-0 flex h-dvh max-h-dvh min-w-full flex-col rounded-none border-0 p-0'); /** Wrapper the carousel is mounted in, identical for both dialog entry points. */ -export const attachmentDialogContentClassName = 'relative -z-1 flex h-dvh grow flex-wrap justify-center p-2'; +export const attachmentDialogContentClassName = tw('relative -z-1 flex h-dvh grow flex-wrap justify-center p-2'); /** * Per-slide stage insets: a fitted image stays clear of the fixed header (3rem) and, from `sm` up where the pan/zoom * viewer renders them, the zoom controls (3.25rem from the bottom). Zoomed-in content still slides under the * translucent chrome because overflow clips at the padding edge. */ -export const attachmentDialogStageClassName = 'pt-12 sm:pb-14'; +export const attachmentDialogStageClassName = tw('pt-12 sm:pb-14'); export function openAttachmentDialogSearch(attachmentId: string, groupId?: string | null) { - return (prev: Record<string, unknown>) => ({ - ...prev, - [ATTACHMENT_DIALOG_PARAM]: attachmentId, - groupId: groupId || undefined, - }); + return (prev: Record<string, unknown>) => ({ ...prev, [ATTACHMENT_DIALOG_PARAM]: attachmentId, groupId: groupId || undefined }); } export function clearAttachmentDialogSearch(prev: Record<string, unknown>) { @@ -36,19 +33,10 @@ export function clearAttachmentDialogSearch(prev: Record<string, unknown>) { /** Clears the dialog search params through the router instance, where `to: '.'` resolves against the current location. */ export const clearAttachmentDialogSearchParams = () => { - getRouter().navigate({ - to: '.', - replace: true, - resetScroll: false, - search: clearAttachmentDialogSearch, - }); + getRouter().navigate({ to: '.', replace: true, resetScroll: false, search: clearAttachmentDialogSearch }); }; /** Dialoger options shared by both entry points. */ export function attachmentDialogOptions(overrides: DialogData): DialogData { - return { - drawerOnMobile: false, - className: attachmentDialogClassName, - ...overrides, - }; + return { drawerOnMobile: false, className: attachmentDialogClassName, ...overrides }; } diff --git a/frontend/src/modules/attachment/file-placeholder.tsx b/frontend/src/modules/attachment/file-placeholder.tsx index ab5f3a2ca..a102e8625 100644 --- a/frontend/src/modules/attachment/file-placeholder.tsx +++ b/frontend/src/modules/attachment/file-placeholder.tsx @@ -1,12 +1,4 @@ -import { - FileArchiveIcon, - FileHeadphoneIcon, - FileIcon, - FileImageIcon, - FilePlayIcon, - FileSpreadsheetIcon, - FileTextIcon, -} from 'lucide-react'; +import { FileArchiveIcon, FileHeadphoneIcon, FileIcon, FileImageIcon, FilePlayIcon, FileSpreadsheetIcon, FileTextIcon } from 'lucide-react'; const contentTypeMap = [ { match: ['image'], icon: FileImageIcon }, @@ -28,11 +20,11 @@ export function getFileIcon(contentType?: string) { interface Props { contentType?: string; strokeWidth?: number; - /** Size the icon here (icon-* / size-*); defaults to icon-lg. */ + /** Size the icon here (icon-* / size-*); defaults to size-5. */ className?: string; } -export function FilePlaceholder({ contentType, strokeWidth, className = 'icon-lg' }: Props) { +export function FilePlaceholder({ contentType, strokeWidth, className = 'size-5' }: Props) { const FileIconComponent = getFileIcon(contentType); return <FileIconComponent strokeWidth={strokeWidth} className={className} />; } diff --git a/frontend/src/modules/attachment/helpers/activity-feed.ts b/frontend/src/modules/attachment/helpers/activity-feed.ts index dda2b4096..8151f7d70 100644 --- a/frontend/src/modules/attachment/helpers/activity-feed.ts +++ b/frontend/src/modules/attachment/helpers/activity-feed.ts @@ -1,8 +1,5 @@ /** Newest-first ordering on publishedAt when the draft lifecycle set one, else createdAt: the same key unseen tracking uses. */ -export function selectRecentActivity<T extends { createdAt?: string | null; publishedAt?: string | null }>( - items: T[], - limit: number, -): T[] { +export function selectRecentActivity<T extends { createdAt?: string | null; publishedAt?: string | null }>(items: T[], limit: number): T[] { const recencyOf = (item: T) => Date.parse(item.publishedAt ?? item.createdAt ?? '') || 0; return [...items].sort((a, b) => recencyOf(b) - recencyOf(a)).slice(0, limit); } diff --git a/frontend/src/modules/attachment/helpers/resolve-url.ts b/frontend/src/modules/attachment/helpers/resolve-url.ts index 19b062e8e..22ce44b2c 100644 --- a/frontend/src/modules/attachment/helpers/resolve-url.ts +++ b/frontend/src/modules/attachment/helpers/resolve-url.ts @@ -1,12 +1,6 @@ import type { Attachment } from 'sdk'; import { parseMediaRef } from 'shared/utils/media-ref'; -import { - type CloudFileVariant, - getCloudUrl, - getPrivateFileUrlById, - getPublicFileUrl, - getVariantKey, -} from '~/modules/attachment/file-url'; +import { type CloudFileVariant, getCloudUrl, getPrivateFileUrlById, getPublicFileUrl, getVariantKey } from '~/modules/attachment/file-url'; import type { BlobVariant } from '~/modules/attachment/offline/attachments-db'; import { downloadService } from '~/modules/attachment/offline/download-service'; import { attachmentStorage } from '~/modules/attachment/offline/storage-service'; @@ -42,8 +36,7 @@ export async function resolveAttachmentUrl( if (!meta) return null; // Requested variant only when its key exists; private files pass id + variant since client keys are not trusted. - const effectiveVariant = - preferredVariant !== 'raw' && getVariantKey(meta, preferredVariant) ? preferredVariant : 'original'; + const effectiveVariant = preferredVariant !== 'raw' && getVariantKey(meta, preferredVariant) ? preferredVariant : 'original'; const fileUrl = await getCloudUrl({ ...meta, id: attachmentId }, effectiveVariant); if (!fileUrl) return null; diff --git a/frontend/src/modules/attachment/hooks/use-blob-upload-status.ts b/frontend/src/modules/attachment/hooks/use-blob-upload-status.ts index 847969c58..6378b80b0 100644 --- a/frontend/src/modules/attachment/hooks/use-blob-upload-status.ts +++ b/frontend/src/modules/attachment/hooks/use-blob-upload-status.ts @@ -1,6 +1,8 @@ -import { useLiveQuery } from 'dexie-react-hooks'; -import { type AttachmentBlob, attachmentsDb } from '~/modules/attachment/offline/attachments-db'; +import { liveQuery, type Subscription } from 'dexie'; +import { useSyncExternalStore } from 'react'; +import { type AttachmentBlob, attachmentsDb, type UploadStatus } from '~/modules/attachment/offline/attachments-db'; import { getLocalUserDb } from '~/query/local-user-db'; +import { subscribeOwnerChange } from '~/query/local-user-storage'; interface BlobUploadInfo { /** False when no local blob exists for this attachment, meaning it lives only in the cloud. */ @@ -24,32 +26,94 @@ const defaultUploadInfo: BlobUploadInfo = { lastError: null, }; -function blobsToUploadInfo(blobs: AttachmentBlob[]): BlobUploadInfo { - if (!blobs.length) return defaultUploadInfo; - - // The raw blob carries the upload state; downloaded variants are by definition already in cloud. - const rawBlob = blobs.find((b) => b.variant === 'raw'); - const primaryBlob = rawBlob || blobs[0]; - +function toUploadInfo(uploadStatus: UploadStatus, lastError: string | null | undefined): BlobUploadInfo { return { hasLocalBlob: true, - isUploaded: primaryBlob.uploadStatus === 'uploaded', - isUploading: primaryBlob.uploadStatus === 'uploading', - isFailed: primaryBlob.uploadStatus === 'failed', - isPending: primaryBlob.uploadStatus === 'pending', - isLocalOnly: primaryBlob.uploadStatus === 'local-only', - lastError: primaryBlob.lastError ?? null, + isUploaded: uploadStatus === 'uploaded', + isUploading: uploadStatus === 'uploading', + isFailed: uploadStatus === 'failed', + isPending: uploadStatus === 'pending', + isLocalOnly: uploadStatus === 'local-only', + lastError: lastError ?? null, + }; +} + +/** Marking a blob uploaded clears its lastError, and downloaded blobs never carry one. */ +const uploadedInfo = toUploadInfo('uploaded', null); + +const sameInfo = (a: BlobUploadInfo, b: BlobUploadInfo) => (Object.keys(a) as (keyof BlobUploadInfo)[]).every((key) => a[key] === b[key]); + +/** + * Upload info per attachment id. Primary keys come from the index alone, so cached downloads are never deserialized; + * only blobs that are not uploaded yet are read in full. + */ +async function readUploadInfos(): Promise<Map<string, BlobUploadInfo>> { + const infos = new Map<string, BlobUploadInfo>(); + if (!getLocalUserDb()) return infos; + + const ids = await attachmentsDb.blobs.toCollection().primaryKeys(); + const syncing: AttachmentBlob[] = await attachmentsDb.blobs.where('uploadStatus').notEqual('uploaded').toArray(); + const syncingById = new Map(syncing.map((blob) => [blob.id, blob])); + + // The raw blob carries the upload state; without one, the attachment's first blob in key order (`${attachmentId}:${variant}`). + const primaryIds = new Map<string, string>(); + for (const id of ids) { + const attachmentId = id.slice(0, id.lastIndexOf(':')); + if (!primaryIds.has(attachmentId) || id.endsWith(':raw')) primaryIds.set(attachmentId, id); + } + + for (const [attachmentId, id] of primaryIds) { + const blob = syncingById.get(id); + infos.set(attachmentId, blob ? toUploadInfo(blob.uploadStatus, blob.lastError) : uploadedInfo); + } + return infos; +} + +// One live query serves every mounted badge: rows resolve in a single render, and unchanged rows keep their snapshot. +let infos = new Map<string, BlobUploadInfo>(); +const listeners = new Set<() => void>(); +let subscription: Subscription | null = null; +let stopOwnerChange: (() => void) | null = null; + +function publish(next: Map<string, BlobUploadInfo>) { + for (const [attachmentId, info] of next) { + const prev = infos.get(attachmentId); + if (prev && sameInfo(prev, info)) next.set(attachmentId, prev); + } + infos = next; + for (const listener of listeners) listener(); +} + +function subscribeQuery() { + subscription?.unsubscribe(); + subscription = liveQuery(readUploadInfos).subscribe({ + next: publish, + error: (err) => console.error('[useBlobUploadStatus] Blob liveQuery error:', err), + }); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1) { + subscribeQuery(); + // liveQuery tracks only the DB it first resolved, so re-subscribe when the per-user localUserDb rebinds. + stopOwnerChange = subscribeOwnerChange(() => { + publish(new Map()); + subscribeQuery(); + }); + } + return () => { + listeners.delete(listener); + if (listeners.size > 0) return; + subscription?.unsubscribe(); + subscription = null; + stopOwnerChange?.(); + stopOwnerChange = null; + infos = new Map(); }; } /** Reactive upload status; falls back to the default "uploaded" info with no id or no blob. */ export function useBlobUploadStatus(attachmentId: string | null | undefined): BlobUploadInfo { - const blobs = useLiveQuery( - () => - attachmentId && getLocalUserDb() ? attachmentsDb.blobs.where('attachmentId').equals(attachmentId).toArray() : [], - [attachmentId], - [] as AttachmentBlob[], - ); - - return blobsToUploadInfo(blobs); + return useSyncExternalStore(subscribe, () => (attachmentId && infos.get(attachmentId)) || defaultUploadInfo); } diff --git a/frontend/src/modules/attachment/hooks/use-resolved-attachments.ts b/frontend/src/modules/attachment/hooks/use-resolved-attachments.ts index bf729921b..4f1922c32 100644 --- a/frontend/src/modules/attachment/hooks/use-resolved-attachments.ts +++ b/frontend/src/modules/attachment/hooks/use-resolved-attachments.ts @@ -22,11 +22,7 @@ interface ResolvedAttachmentsResult { errorIds: string[]; } -function buildItemData( - item: Partial<CarouselItemData> & { id: string }, - url: string, - isLocal: boolean, -): CarouselItemData { +function buildItemData(item: Partial<CarouselItemData> & { id: string }, url: string, isLocal: boolean): CarouselItemData { const cached = findAttachmentInCache(item.id); return { id: item.id, diff --git a/frontend/src/modules/attachment/offline/download-queue.ts b/frontend/src/modules/attachment/offline/download-queue.ts index 3e1610161..ee5c9f321 100644 --- a/frontend/src/modules/attachment/offline/download-queue.ts +++ b/frontend/src/modules/attachment/offline/download-queue.ts @@ -106,11 +106,7 @@ async function addNew(entries: DownloadQueueEntry[]): Promise<void> { } /** Whether an existing entry goes back to `pending`; other rows stay untouched so the table acts as the dedupe registry. */ -function shouldRevive( - entry: DownloadQueueEntry, - attachment: Attachment, - config: NonNullable<typeof appConfig.localBlobStorage>, -): boolean { +function shouldRevive(entry: DownloadQueueEntry, attachment: Attachment, config: NonNullable<typeof appConfig.localBlobStorage>): boolean { // Queued before its keys had synced; now they have. if (entry.status === 'skipped' && entry.skipReason === SKIP_REASON_NO_ORIGINAL_KEY && attachment.keys?.original) { return true; diff --git a/frontend/src/modules/attachment/offline/download-service.ts b/frontend/src/modules/attachment/offline/download-service.ts index f02b13f52..eb7a06bab 100644 --- a/frontend/src/modules/attachment/offline/download-service.ts +++ b/frontend/src/modules/attachment/offline/download-service.ts @@ -251,9 +251,7 @@ class AttachmentDownloadService { console.debug(`[DownloadService] Completed downloading attachment ${attachmentId}`); } else { await downloadQueue.transition(attachmentId, 'failed'); - console.debug( - `[DownloadService] No variants downloaded for ${attachmentId}, marked as failed${authFailed ? ' (auth)' : ''}`, - ); + console.debug(`[DownloadService] No variants downloaded for ${attachmentId}, marked as failed${authFailed ? ' (auth)' : ''}`); } } catch (error) { console.error(`[DownloadService] Failed to download ${attachmentId}:`, error); @@ -261,11 +259,7 @@ class AttachmentDownloadService { } } - private async downloadVariant( - attachment: Attachment, - variant: CloudFileVariant, - organizationId: string, - ): Promise<VariantResult> { + private async downloadVariant(attachment: Attachment, variant: CloudFileVariant, organizationId: string): Promise<VariantResult> { // This attachment has no object for this variant. if (!getVariantKey(attachment, variant)) return 'skipped'; @@ -297,9 +291,7 @@ class AttachmentDownloadService { const blob = await response.blob(); const contentType = - variant === 'converted' && attachment.convertedContentType - ? attachment.convertedContentType - : attachment.contentType || blob.type; + variant === 'converted' && attachment.convertedContentType ? attachment.convertedContentType : attachment.contentType || blob.type; await attachmentStorage.storeDownloadBlobWithVariant(attachment.id, variant, organizationId, blob, contentType); diff --git a/frontend/src/modules/attachment/offline/storage-service.ts b/frontend/src/modules/attachment/offline/storage-service.ts index 73c5bccb2..8b2217201 100644 --- a/frontend/src/modules/attachment/offline/storage-service.ts +++ b/frontend/src/modules/attachment/offline/storage-service.ts @@ -119,8 +119,7 @@ class AttachmentStorageService { const rawKey = makeBlobKey(attachmentId, 'raw'); try { // Never evict raw without a durable variant stored: a resource with no cloud key would become unresolvable. - const hasDurable = - (await this.hasVariant(attachmentId, 'original')) || (await this.hasVariant(attachmentId, 'converted')); + const hasDurable = (await this.hasVariant(attachmentId, 'original')) || (await this.hasVariant(attachmentId, 'converted')); if (!hasDurable) { console.debug(`[AttachmentStorage] Skipped raw eviction for ${attachmentId}: no durable variant stored`); return false; diff --git a/frontend/src/modules/attachment/offline/upload-service.ts b/frontend/src/modules/attachment/offline/upload-service.ts index 67dad7c4a..42f0c80e4 100644 --- a/frontend/src/modules/attachment/offline/upload-service.ts +++ b/frontend/src/modules/attachment/offline/upload-service.ts @@ -106,10 +106,7 @@ class AttachmentUploadService { private async uploadBlob(blob: AttachmentBlob): Promise<void> { await attachmentStorage.updateUploadStatus(blob.id, 'uploading'); - const uppy = new Uppy({ - autoProceed: false, - allowMultipleUploadBatches: false, - }); + const uppy = new Uppy({ autoProceed: false, allowMultipleUploadBatches: false }); try { uppy.use(Transloadit, { @@ -128,12 +125,7 @@ class AttachmentUploadService { }, }); - uppy.addFile({ - name: blob.filename || `${blob.id}.bin`, - type: blob.contentType, - data: blob.blob, - meta: { attachmentId: blob.id }, - }); + uppy.addFile({ name: blob.filename || `${blob.id}.bin`, type: blob.contentType, data: blob.blob, meta: { attachmentId: blob.id } }); const result = await uppy.upload(); diff --git a/frontend/src/modules/attachment/presign-batch.ts b/frontend/src/modules/attachment/presign-batch.ts index 8e8d85233..c58a57a80 100644 --- a/frontend/src/modules/attachment/presign-batch.ts +++ b/frontend/src/modules/attachment/presign-batch.ts @@ -97,12 +97,7 @@ function flush() { * memoized for an hour. Rejected ids reject with {@link PresignRejectedError}; transport failures * reject with the underlying error. Fails fast when offline. */ -export function getPresignedUrlBatched( - attachmentId: string, - variant: CloudFileVariant, - tenantId: string, - organizationId: string, -): Promise<string> { +export function getPresignedUrlBatched(attachmentId: string, variant: CloudFileVariant, tenantId: string, organizationId: string): Promise<string> { subscribeOwnerOnce(); const key = pairKey(attachmentId, variant); diff --git a/frontend/src/modules/attachment/query-mutations.ts b/frontend/src/modules/attachment/query-mutations.ts index bf142cd7c..b3ddc75cc 100644 --- a/frontend/src/modules/attachment/query-mutations.ts +++ b/frontend/src/modules/attachment/query-mutations.ts @@ -17,8 +17,7 @@ const placementKeys = hierarchy .map((type) => appConfig.entityIdColumnKeys[type]) as readonly string[]; // Placement keys keep the row's own nullability: a strict ancestor stays `string`, so an optimistic row validates. -export type CreateAttachmentInput = (Omit<CreateAttachmentItem, 'stx' | PlacementKey> & - Partial<Pick<Attachment, PlacementKey>>)[]; +export type CreateAttachmentInput = (Omit<CreateAttachmentItem, 'stx' | PlacementKey> & Partial<Pick<Attachment, PlacementKey>>)[]; type UpdateAttachmentFields = UpdateAttachmentData['body']['ops']; export type UpdateAttachmentVars = { id: string; ops: UpdateAttachmentFields }; @@ -42,12 +41,7 @@ export async function updateAttachmentMutationFn({ tenantId, organizationId, id, return updateAttachment({ path: { tenantId, organizationId, id }, body: { ops, stx: effectiveStx } }); } -export async function deleteAttachmentsMutationFn({ - tenantId, - organizationId, - attachments, - stx, -}: DeleteAttachmentVars) { +export async function deleteAttachmentsMutationFn({ tenantId, organizationId, attachments, stx }: DeleteAttachmentVars) { const ids = attachments.map((a) => a.id); const effectiveStx = stx ?? createStxForDelete(); return deleteAttachments({ path: { tenantId, organizationId }, body: { ids, stx: effectiveStx } }); diff --git a/frontend/src/modules/attachment/query.ts b/frontend/src/modules/attachment/query.ts index ba1a25d84..55511b90b 100644 --- a/frontend/src/modules/attachment/query.ts +++ b/frontend/src/modules/attachment/query.ts @@ -12,11 +12,7 @@ import type { UpdateAttachmentFullVars, UpdateAttachmentVars, } from '~/modules/attachment/query-mutations'; -import { - createAttachmentsMutationFn, - deleteAttachmentsMutationFn, - updateAttachmentMutationFn, -} from '~/modules/attachment/query-mutations'; +import { createAttachmentsMutationFn, deleteAttachmentsMutationFn, updateAttachmentMutationFn } from '~/modules/attachment/query-mutations'; import { attachmentsSearchDefaults } from '~/modules/attachment/search-params-schemas'; import { toaster } from '~/modules/common/toaster/toaster'; import { insertEntitiesIntoHome } from '~/query/basic/apply-entity-to-lists'; @@ -26,7 +22,7 @@ import { createEntityKeys } from '~/query/basic/create-query-keys'; import { registerEntityQueryKeys, SYNC_CHUNK_SIZE } from '~/query/basic/entity-query-registry'; import { fetchAllPages } from '~/query/basic/fetch-all-pages'; import { createCacheFinder } from '~/query/basic/find-in-list-cache'; -import { baseInfiniteQueryOptions } from '~/query/basic/infinite-query-options'; +import { offsetPaging, pageQuery } from '~/query/basic/infinite-query-options'; import { invalidateIfLastMutation, removePendingMutations } from '~/query/basic/invalidation-helpers'; import { syncStaleTime } from '~/query/basic/sync-stale-config'; import type { OrgRoutableItemData } from '~/query/basic/types'; @@ -45,8 +41,7 @@ const keys = { ...baseKeys, list: { ...baseKeys.list, - filtered: (organizationId: string, filters: AttachmentFilters) => - ['attachment', 'list', organizationId, filters] as const, + filtered: (organizationId: string, filters: AttachmentFilters) => ['attachment', 'list', organizationId, filters] as const, }, }; // Placement seam: a narrowed delta fetch names the covering home channel; org-wide passes none. @@ -80,39 +75,22 @@ export const attachmentsListQueryOptions = (params: AttachmentsListParams) => { } = params; const filters = { q, sort, order }; - const requestQuery = { ...filters, limit: String(limit) }; return infiniteQueryOptions({ queryKey: keys.list.filtered(organizationId, filters), - queryFn: ({ pageParam: { page, offset }, signal }) => { - const requestOffset = String(offset ?? (page ?? 0) * limit); - - return getAttachments({ - path: { tenantId, organizationId }, - query: { ...requestQuery, offset: requestOffset }, - signal, - }); - }, - ...baseInfiniteQueryOptions, + ...offsetPaging(limit, (offset, signal) => + getAttachments({ path: { tenantId, organizationId }, query: { ...filters, ...pageQuery(limit, offset) }, signal }), + ), meta: { persist: false }, staleTime: syncStaleTime, }); }; -export const attachmentsCanonicalOptions = ({ - organizationId, - tenantId, -}: { - organizationId: string; - tenantId: string; -}) => { +export const attachmentsCanonicalOptions = ({ organizationId, tenantId }: { organizationId: string; tenantId: string }) => { return queryOptions({ queryKey: keys.list.home(organizationId), queryFn: async () => { - return fetchAllPages( - ({ limit, offset }) => getAttachments({ path: { tenantId, organizationId }, query: { limit, offset } }), - 1000, - ); + return fetchAllPages(({ limit, offset }) => getAttachments({ path: { tenantId, organizationId }, query: { limit, offset } }), 1000); }, staleTime: syncStaleTime, }); @@ -134,11 +112,7 @@ export function useAttachmentActivityFeed(tenantId: string, organizationId: stri return data ?? []; } -export function useGroupAttachments( - tenantId: string | undefined, - organizationId: string | undefined, - groupId: string | undefined, -) { +export function useGroupAttachments(tenantId: string | undefined, organizationId: string | undefined, groupId: string | undefined) { const { data } = useQuery({ ...attachmentsCanonicalOptions({ organizationId: organizationId!, tenantId: tenantId! }), enabled: !!tenantId && !!organizationId && !!groupId, @@ -173,8 +147,7 @@ const attachmentCreateOptions = ( }, onError: (_err, variables, context) => { handleError('create'); - if (context?.optimisticAttachments) - cacheRemove(keys.list.org(variables.organizationId), context.optimisticAttachments); + if (context?.optimisticAttachments) cacheRemove(keys.list.org(variables.organizationId), context.optimisticAttachments); }, onSuccess: (result, variables, context) => { const orgKey = keys.list.org(variables.organizationId); @@ -182,8 +155,7 @@ const attachmentCreateOptions = ( insertEntitiesIntoHome(queryClient, result.data); }, onSettled: (_data, error, variables) => { - if (error) - invalidateIfLastMutation(queryClient, attachmentsMutationKeyBase, keys.list.org(variables.organizationId)); + if (error) invalidateIfLastMutation(queryClient, attachmentsMutationKeyBase, keys.list.org(variables.organizationId)); }, }); @@ -223,8 +195,7 @@ const attachmentUpdateOptions = ( syncEntityToCache({ entity: merged, listKey: orgKey, detailKey, queryClient }); }, onSettled: (_data, error, variables) => { - if (error) - invalidateIfLastMutation(queryClient, attachmentsMutationKeyBase, keys.list.org(variables.organizationId)); + if (error) invalidateIfLastMutation(queryClient, attachmentsMutationKeyBase, keys.list.org(variables.organizationId)); }, }); @@ -256,13 +227,10 @@ const attachmentDeleteOptions = ( const rejectedSet = new Set(rejectedIds); const rejectedAttachments = variables.attachments.filter((a) => rejectedSet.has(a.id)); insertEntitiesIntoHome(queryClient, rejectedAttachments); - toaster.info( - i18n.t('c:resources_delete_denied', { count: rejectedIds.length, total: variables.attachments.length }), - ); + toaster.info(i18n.t('c:resources_delete_denied', { count: rejectedIds.length, total: variables.attachments.length })); }, onSettled: (_data, error, variables) => { - if (error) - invalidateIfLastMutation(queryClient, attachmentsMutationKeyBase, keys.list.org(variables.organizationId)); + if (error) invalidateIfLastMutation(queryClient, attachmentsMutationKeyBase, keys.list.org(variables.organizationId)); }, }); diff --git a/frontend/src/modules/attachment/render/attachment-render.stories.tsx b/frontend/src/modules/attachment/render/attachment-render.stories.tsx new file mode 100644 index 000000000..291d5c53e --- /dev/null +++ b/frontend/src/modules/attachment/render/attachment-render.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { AttachmentRender } from '~/modules/attachment/render/attachment-render'; +import { MAX_ZOOM, MIN_ZOOM } from '~/modules/attachment/render/image-zoom'; + +const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><rect width="400" height="300" fill="#4f46e5"/></svg>`; +const imageUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; + +const onPanStateToggle = fn(); +const onBackdropClick = fn(); + +/** The dialog image viewer: wheel and button zoom, rotation, a pan toggle, reset and letterbox dismiss. */ +const meta = { + title: 'attachment/ImageViewer', + component: AttachmentRender, + parameters: { layout: 'centered' }, + // The viewer is a lazy chunk that a busy dev server can take seconds to transform; loading it before render keeps + // each first find inside its timeout. + loaders: [() => import('~/modules/attachment/render/image')], + args: { + type: 'image/svg+xml', + url: imageUrl, + altName: 'attachment', + imagePanZoom: true, + showButtons: true, + itemClassName: 'object-contain', + containerClassName: 'stage relative flex h-[500px] w-[900px] items-center justify-center overflow-hidden', + onPanStateToggle, + onBackdropClick, + }, + beforeEach: () => { + onPanStateToggle.mockClear(); + onBackdropClick.mockClear(); + }, +} satisfies Meta<typeof AttachmentRender>; + +export default meta; +type Story = StoryObj<typeof meta>; + +/** Scale and offset of the pan layer as rendered: the offset lives in CSS variables, so read the computed matrix. */ +const panLayerOf = (img: HTMLElement) => { + const { a: scale, e: x, f: y } = new DOMMatrixReadOnly(getComputedStyle(img.parentElement as HTMLElement).transform); + return { scale, x, y }; +}; + +const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + +const wheel = async (img: HTMLElement, deltaY: number, init: WheelEventInit = {}) => { + img.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, ...init })); + await frame(); +}; + +const drag = async (img: HTMLElement, dx: number, dy: number) => { + const rect = img.getBoundingClientRect(); + const start = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + img.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, ...start })); + await frame(); + const end = { clientX: start.clientX + dx, clientY: start.clientY + dy }; + img.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, ...end })); + await frame(); + img.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, ...end })); + await frame(); +}; + +/** Zoom in, zoom out, rotate, pan toggle, reset: the control bar's buttons in order. */ +const controlsOf = (canvasElement: HTMLElement) => { + const [zoomIn, zoomOut, rotate, pan, reset] = canvasElement.querySelectorAll<HTMLButtonElement>('.bottom-3 button'); + return { zoomIn, zoomOut, rotate, pan, reset }; +}; + +export const Viewer: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const img = await canvas.findByRole('img', { name: 'attachment' }); + const { zoomIn, zoomOut, rotate, pan, reset } = controlsOf(canvasElement); + + await expect(panLayerOf(img)).toEqual({ scale: 1, x: 0, y: 0 }); + + // Wheel and mouse moves are continuous events: React commits them in a scheduler task that can land after the + // next frame, so every changed value is awaited. + await step('wheel and trackpad pinch zoom exponentially around the centre', async () => { + await wheel(img, -100); + await waitFor(() => expect(panLayerOf(img).scale).toBeCloseTo(Math.exp(0.2), 3)); + await wheel(img, 100); + await waitFor(() => expect(panLayerOf(img).scale).toBeCloseTo(1, 3)); + // A line-mode wheel counts 16 px per line + await wheel(img, -3, { deltaMode: WheelEvent.DOM_DELTA_LINE }); + await waitFor(() => expect(panLayerOf(img).scale).toBeCloseTo(Math.exp(0.096), 3)); + await wheel(img, -10, { ctrlKey: true }); + await waitFor(() => expect(panLayerOf(img).scale).toBeCloseTo(Math.exp(0.116), 3)); + await expect(panLayerOf(img)).toMatchObject({ x: 0, y: 0 }); + }); + + await step('wheel zoom stays within the floor and the ceiling', async () => { + for (let i = 0; i < 12; i++) await wheel(img, -500); + await waitFor(() => expect(panLayerOf(img).scale).toBe(MAX_ZOOM)); + for (let i = 0; i < 12; i++) await wheel(img, 500); + await waitFor(() => expect(panLayerOf(img).scale).toBe(MIN_ZOOM)); + }); + + await step('the zoom buttons step by 0.2', async () => { + await userEvent.click(reset); + await userEvent.click(zoomIn); + await waitFor(() => expect(panLayerOf(img).scale).toBeCloseTo(1.2, 3)); + await userEvent.click(zoomOut); + await userEvent.click(zoomOut); + await waitFor(() => expect(panLayerOf(img).scale).toBeCloseTo(0.8, 3)); + }); + + await step('rotate turns the image a quarter per click, back to 0 after four', async () => { + for (const angle of [90, 180, 270, 0]) { + await userEvent.click(rotate); + await expect(img.style.transform).toBe(`rotate(${angle}deg)`); + } + }); + + await step('dragging does nothing while panning is off', async () => { + await drag(img, 40, 25); + await expect(panLayerOf(img)).toMatchObject({ x: 0, y: 0 }); + }); + + await step('with the pan toggle on, a drag moves the image', async () => { + await userEvent.click(pan); + // The toggle reports whether the carousel may take drags: not while panning + await expect(onPanStateToggle).toHaveBeenLastCalledWith(false); + + await drag(img, 40, 25); + await waitFor(() => expect(panLayerOf(img)).toMatchObject({ x: 40, y: 25 })); + await drag(img, -10, 5); + await waitFor(() => expect(panLayerOf(img)).toMatchObject({ x: 30, y: 30 })); + + await userEvent.click(pan); + await expect(onPanStateToggle).toHaveBeenLastCalledWith(true); + await drag(img, 40, 25); + await expect(panLayerOf(img)).toMatchObject({ x: 30, y: 30 }); + }); + + await step('a drag released outside the viewport ends the pan', async () => { + await userEvent.click(pan); + const rect = img.getBoundingClientRect(); + const start = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + img.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, ...start })); + await frame(); + // The button comes up outside the viewport, which never sees that mouseup + document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })); + await frame(); + + // Back over the image with no button held, the pointer moves without dragging the image along + const back = { clientX: start.clientX + 40, clientY: start.clientY + 25 }; + img.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, ...back })); + await new Promise((resolve) => setTimeout(resolve, 100)); + await expect(panLayerOf(img)).toMatchObject({ x: 30, y: 30 }); + await userEvent.click(pan); + }); + + await step('reset restores zoom, offset and rotation', async () => { + await userEvent.click(rotate); + await userEvent.click(reset); + await waitFor(() => expect(panLayerOf(img)).toEqual({ scale: 1, x: 0, y: 0 })); + await expect(img.style.transform).toBe('rotate(0deg)'); + }); + }, +}; + +export const PageScrollWhileHovering: Story = { + play: async ({ canvasElement }) => { + const img = await within(canvasElement).findByRole('img', { name: 'attachment' }); + const pageWheel = () => { + const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 100 }); + document.body.dispatchEvent(event); + return event.defaultPrevented; + }; + + await expect(pageWheel()).toBe(false); + await userEvent.hover(img); + await expect(pageWheel()).toBe(true); + await userEvent.unhover(img); + await expect(pageWheel()).toBe(false); + }, +}; + +export const BackdropDismiss: Story = { + play: async ({ canvasElement }) => { + const img = await within(canvasElement).findByRole('img', { name: 'attachment' }); + const stage = canvasElement.querySelector('.stage') as HTMLElement; + await waitFor(() => expect(img.getBoundingClientRect().width).toBeGreaterThan(0)); + + await userEvent.click(img); + await expect(onBackdropClick).not.toHaveBeenCalled(); + + // The letterbox beside the image belongs to the stage, not to the pan layers above it + const rect = stage.getBoundingClientRect(); + const letterbox = document.elementFromPoint(rect.left + 5, rect.top + 5) as HTMLElement; + await expect(letterbox).toBe(stage); + await userEvent.click(letterbox); + await expect(onBackdropClick).toHaveBeenCalledTimes(1); + }, +}; diff --git a/frontend/src/modules/attachment/render/attachment-render.tsx b/frontend/src/modules/attachment/render/attachment-render.tsx index cfe415d2d..6e4826f6d 100644 --- a/frontend/src/modules/attachment/render/attachment-render.tsx +++ b/frontend/src/modules/attachment/render/attachment-render.tsx @@ -6,7 +6,7 @@ import useDownloader from 'react-use-downloader'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { getFileIcon } from '~/modules/attachment/file-placeholder'; import { ContentPlaceholder } from '~/modules/common/content-placeholder'; -import { Spinner } from '~/modules/common/spinner'; +import { PageSpinner, Spinner } from '~/modules/common/spinner'; import { Button } from '~/modules/ui/button'; import { cn } from '~/utils/cn'; import { lazyNamed } from '~/utils/lazy-named'; @@ -48,7 +48,7 @@ export function AttachmentRender({ const isMobile = useBreakpointBelow('sm'); const { download, isInProgress } = useDownloader(); - if (!url) return <Spinner className="mt-[45vh] h-12 w-12" />; + if (!url) return <PageSpinner className="size-12" />; // Only the container itself is the backdrop: clicks on media or controls bubble here but fail the target check. const handleBackdropClick = onBackdropClick @@ -60,7 +60,7 @@ export function AttachmentRender({ return ( // biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismiss is a mouse affordance; ESC closes the dialog for keyboard users <div className={containerClassName} onClick={handleBackdropClick}> - <Suspense fallback={<Spinner className="mt-[45vh]" />}> + <Suspense fallback={<PageSpinner className="size-6" />}> {type.includes('image') && (imagePanZoom && !isMobile ? ( <ReactPanZoom @@ -72,11 +72,7 @@ export function AttachmentRender({ backdropDismiss={!!onBackdropClick} /> ) : ( - <img - src={url} - alt={altName} - className={cn(itemClassName, onBackdropClick ? 'max-h-full max-w-full' : 'h-full w-full')} - /> + <img src={url} alt={altName} className={cn(itemClassName, onBackdropClick ? 'max-h-full max-w-full' : 'size-full')} /> ))} {type.includes('audio') && <RenderAudio src={url} className="mx-auto -mt-48 h-20 w-[80vw]" />} {type.includes('video') && <RenderVideo src={url} className="mx-auto max-h-full max-w-7xl" />} @@ -84,14 +80,9 @@ export function AttachmentRender({ {!['image', 'audio', 'video', 'pdf'].some((k) => type.includes(k)) && ( <ContentPlaceholder icon={getFileIcon(type)} title="c:download_to_view"> {/* The URL is always fetchable: a CDN or presigned URL online, a local blob URL offline. */} - <Button - variant="plain" - className="mt-4" - disabled={isInProgress} - onClick={() => download(url, filename || 'file')} - > + <Button variant="plain" className="mt-4" disabled={isInProgress} onClick={() => download(url, filename || 'file')}> {isInProgress ? <Spinner className="size-4" noDelay /> : <DownloadIcon className="size-4" />} - <span className="ml-1">{t('c:download')}</span> + <span>{t('c:download')}</span> </Button> </ContentPlaceholder> )} diff --git a/frontend/src/modules/attachment/render/image-viewer.tsx b/frontend/src/modules/attachment/render/image-viewer.tsx deleted file mode 100644 index 2310ad99f..000000000 --- a/frontend/src/modules/attachment/render/image-viewer.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import * as React from 'react'; -import { WHEEL_ZOOM_SENSITIVITY } from '~/modules/attachment/render/image-zoom'; - -interface ImageDragData { - x: number; - y: number; - dx: number; - dy: number; -} - -interface ImageViewerStateType { - dragging: boolean; - mouseDown: boolean; - comesFromDragging: boolean; - dragData: ImageDragData; - matrixData: number[]; -} - -interface ImageViewerProps { - height?: string; - width?: string; - className?: string; - enablePan?: boolean; - zoom?: number; - pandx?: number; - pandy?: number; - rotation?: number; - onPan?: (x: number, y: number) => void; - setZoom: (z: number) => void; - // biome-ignore lint/suspicious/noExplicitAny:by author - onClick?: (e: React.MouseEvent<any>) => void; - children?: React.ReactNode; -} - -export class ImageViewer extends React.PureComponent<ImageViewerProps, ImageViewerStateType> { - private panWrapper: HTMLElement | null = null; - private panContainer: HTMLElement | null = null; - - public static defaultProps: Partial<ImageViewerProps> = { - enablePan: true, - onPan: () => undefined, - pandx: 0, - pandy: 0, - zoom: 0, - rotation: 0, - }; - - private getInitialState = (): ImageViewerStateType => { - const { pandx, pandy, zoom } = this.props; - - const defaultDragData: ImageDragData = { - dx: pandx || 0, - dy: pandy || 0, - x: 0, - y: 0, - }; - - return { - comesFromDragging: false, - dragData: defaultDragData, - dragging: false, - matrixData: [zoom || 1, 0, 0, zoom || 1, pandx || 0, pandy || 0], - mouseDown: false, - }; - }; - - public state = this.getInitialState(); - - public componentDidUpdate(prevProps: ImageViewerProps) { - const { zoom, pandx, pandy } = this.props; - const zoomChanged = prevProps.zoom !== zoom; - // Sync pan from props too (e.g. on reset) so local state follows external resets without remounting. - const panChanged = prevProps.pandx !== pandx || prevProps.pandy !== pandy; - if (!zoomChanged && !panChanged) return; - - const newMatrixData = [...this.state.matrixData]; - if (zoomChanged) { - newMatrixData[0] = zoom || newMatrixData[0]; - newMatrixData[3] = zoom || newMatrixData[3]; - } - if (panChanged) { - // Nullish checks preserve a legitimate 0 for reset or centered positions. - newMatrixData[4] = pandx ?? newMatrixData[4]; - newMatrixData[5] = pandy ?? newMatrixData[5]; - } - this.setState({ matrixData: newMatrixData }); - } - - public onClick = (e: React.MouseEvent<EventTarget>) => { - if (this.state.comesFromDragging) { - return; - } - - if (this.props.onClick) { - this.props.onClick(e); - } - }; - - public onTouchStart = (e: React.TouchEvent<EventTarget>) => { - const { pageX, pageY } = e.touches[0]; - this.panStart(pageX, pageY, e); - }; - - public onTouchEnd = () => { - this.onMouseUp(); - }; - - public onTouchMove = (e: React.TouchEvent<EventTarget>) => { - this.updateMousePosition(e.touches[0].pageX, e.touches[0].pageY); - }; - - public render() { - return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: by author - <div - className={`pan-container ${this.props.className || ''}`} - onMouseDown={this.onMouseDown} - onMouseUp={this.onMouseUp} - onTouchStart={this.onTouchStart} - onTouchMove={this.onTouchMove} - onTouchEnd={this.onTouchEnd} - onMouseMove={this.onMouseMove} - onWheel={this.onWheel} - onMouseEnter={this.onMouseEnter} - onMouseLeave={this.onMouseLeave} - onClick={this.onClick} - style={{ - height: this.props.height, - userSelect: 'none', - width: this.props.width, - }} - ref={(ref: HTMLDivElement | null): void => { - this.panWrapper = ref; - }} - > - <div - className="flex h-full w-full items-center justify-center" - ref={(ref: HTMLDivElement | null): void => { - this.panContainer = ref; - }} - style={{ - transform: `matrix(${this.state.matrixData.join(',')})`, - }} - > - {this.props.children} - </div> - </div> - ); - } - - private onMouseDown = (e: React.MouseEvent<EventTarget>) => { - this.panStart(e.pageX, e.pageY, e); - }; - - private panStart = ( - pageX: number, - pageY: number, - event: React.MouseEvent<EventTarget> | React.TouchEvent<EventTarget>, - ) => { - if (!this.props.enablePan) { - return; - } - - const { matrixData } = this.state; - const offsetX = matrixData[4]; - const offsetY = matrixData[5]; - const newDragData: ImageDragData = { - dx: offsetX, - dy: offsetY, - x: pageX, - y: pageY, - }; - this.setState({ - dragData: newDragData, - mouseDown: true, - }); - if (this.panWrapper) { - this.panWrapper.style.cursor = 'move'; - } - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation(); - event.preventDefault(); - }; - - private onMouseUp = () => { - this.panEnd(); - }; - - private panEnd = () => { - this.setState({ - comesFromDragging: this.state.dragging, - dragging: false, - mouseDown: false, - }); - if (this.panWrapper) { - this.panWrapper.style.cursor = ''; - } - if (this.props.onPan) { - this.props.onPan(this.state.matrixData[4], this.state.matrixData[5]); - } - }; - - public preventDefault(e: Event) { - const event = e || window.event; - if (event.preventDefault) { - event.preventDefault(); - } - event.returnValue = false; - } - - private onMouseMove = (e: React.MouseEvent<EventTarget>) => { - this.updateMousePosition(e.pageX, e.pageY); - }; - - // A trackpad pinch arrives as a ctrlKey wheel with small deltas: the exponential step keeps pinch and mouse wheel - // continuous, and the parent's clamp sets the floor and ceiling. - private onWheel = (e: React.WheelEvent<EventTarget>) => { - const deltaY = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * 16 : e.deltaY; - this.props.setZoom((this.props.zoom || 1) * Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY)); - }; - - private onMouseEnter = () => { - document.addEventListener('wheel', this.preventDefault, { - passive: false, - }); - }; - - private onMouseLeave = () => { - document.removeEventListener('wheel', this.preventDefault, false); - }; - - public componentWillUnmount() { - document.removeEventListener('wheel', this.preventDefault, false); - } - - private updateMousePosition = (pageX: number, pageY: number) => { - if (!this.state.mouseDown) return; - - const matrixData = this.getNewMatrixData(pageX, pageY); - this.setState({ - dragging: true, - matrixData, - }); - if (this.panContainer) { - this.panContainer.style.transform = `matrix(${this.state.matrixData.join(',')})`; - } - }; - - private getNewMatrixData = (x: number, y: number): number[] => { - const { dragData, matrixData } = this.state; - const deltaX = dragData.x - x; - const deltaY = dragData.y - y; - matrixData[4] = dragData.dx - deltaX; - matrixData[5] = dragData.dy - deltaY; - return matrixData; - }; -} diff --git a/frontend/src/modules/attachment/render/image.tsx b/frontend/src/modules/attachment/render/image.tsx index fe2efdb35..6b0a6e890 100644 --- a/frontend/src/modules/attachment/render/image.tsx +++ b/frontend/src/modules/attachment/render/image.tsx @@ -2,8 +2,7 @@ import { HandGrabIcon, HandIcon, MinusIcon, PlusIcon, RefreshCwIcon, RotateCwSqu import type React from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ImageViewer } from '~/modules/attachment/render/image-viewer'; -import { clampZoom, ZOOM_STEP } from '~/modules/attachment/render/image-zoom'; +import { usePanZoom } from '~/modules/attachment/render/use-pan-zoom'; import { TooltipButton } from '~/modules/common/tooltip-button'; import { Button } from '~/modules/ui/button'; import { cn } from '~/utils/cn'; @@ -27,54 +26,18 @@ interface ControlButtonProps { function ControlButton({ tooltipContent, onClick, icon, className }: ControlButtonProps) { return ( <TooltipButton toolTipContent={tooltipContent}> - <Button - onClick={onClick} - className={cn( - 'rounded-none border border-input bg-background text-accent-foreground hover:bg-accent', - className, - )} - > + <Button onClick={onClick} className={cn('rounded-none border border-input bg-background text-accent-foreground hover:bg-accent', className)}> {icon} </Button> </TooltipButton> ); } -export function ReactPanZoom({ - image, - alt, - showButtons, - imageClassName, - onPanStateToggle, - backdropDismiss = false, -}: RenderImageProps) { +export function ReactPanZoom({ image, alt, showButtons, imageClassName, onPanStateToggle, backdropDismiss = false }: RenderImageProps) { const { t } = useTranslation(); - const [dx, setDx] = useState(0); - const [dy, setDy] = useState(0); - - const [zoom, setZoomState] = useState(1); - // Every input (buttons, wheel, trackpad pinch) goes through the same clamp. - const setZoom = (next: number) => setZoomState(clampZoom(next)); - const [rotation, setRotation] = useState(0); // On by default when no onPanStateToggle is passed. const [panState, setPanState] = useState(!onPanStateToggle); - - // The image fits its container via CSS (object-contain), so zoom 1 is the natural fit. - const resetAll = () => { - setDx(0); - setDy(0); - setZoom(1); - setRotation(0); - }; - - const zoomIn = () => setZoom(zoom + ZOOM_STEP); - const zoomOut = () => setZoom(zoom - ZOOM_STEP); - const rotateRight = () => setRotation((prevRotation) => (prevRotation === 3 ? 0 : prevRotation + 1)); - - const onPan = (dx: number, dy: number) => { - setDx(dx); - setDy(dy); - }; + const { rotation, panProps, layerStyle, zoomIn, zoomOut, rotateRight, reset } = usePanZoom(panState); return ( <> @@ -83,19 +46,14 @@ export function ReactPanZoom({ <ControlButton tooltipContent={t('c:zoom_in')} onClick={zoomIn} - icon={<PlusIcon className="icon-sm" />} + icon={<PlusIcon className="size-3.5" />} className="rounded-l-md border-r-0" /> - <ControlButton - tooltipContent={t('c:zoom_out')} - onClick={zoomOut} - icon={<MinusIcon className="icon-sm" />} - className="border-r-0" - /> + <ControlButton tooltipContent={t('c:zoom_out')} onClick={zoomOut} icon={<MinusIcon className="size-3.5" />} className="border-r-0" /> <ControlButton tooltipContent={t('c:rotate_right')} onClick={rotateRight} - icon={<RotateCwSquareIcon className="icon-sm" />} + icon={<RotateCwSquareIcon className="size-3.5" />} className="border-r-0" /> @@ -106,41 +64,25 @@ export function ReactPanZoom({ setPanState(!panState); onPanStateToggle(panState); }} - icon={panState ? <HandGrabIcon className="icon-sm" /> : <HandIcon className="icon-sm" />} + icon={panState ? <HandGrabIcon className="size-3.5" /> : <HandIcon className="size-3.5" />} className="border-r-0" /> )} - <ControlButton - tooltipContent={t('c:reset')} - onClick={resetAll} - icon={<RefreshCwIcon className="icon-sm" />} - className="rounded-r-md" - /> + <ControlButton tooltipContent={t('c:reset')} onClick={reset} icon={<RefreshCwIcon className="size-3.5" />} className="rounded-r-md" /> </div> )} - <ImageViewer - className={cn('flex h-full w-full items-center justify-center', backdropDismiss && 'pointer-events-none')} - zoom={zoom} - setZoom={setZoom} - enablePan={panState} - pandx={dx} - pandy={dy} - onPan={onPan} - rotation={rotation} - > - <img - style={{ transform: `rotate(${rotation * 90}deg)` }} - className={cn( - imageClassName, - 'object-contain', - backdropDismiss ? 'pointer-events-auto max-h-full max-w-full' : 'h-full w-full', - )} - src={image} - alt={alt} - /> - </ImageViewer> + <div className={cn('flex size-full items-center justify-center', backdropDismiss && 'pointer-events-none')} {...panProps}> + <div className="flex size-full items-center justify-center" style={layerStyle}> + <img + style={{ transform: `rotate(${rotation * 90}deg)` }} + className={cn('object-contain', backdropDismiss ? 'pointer-events-auto max-h-full max-w-full' : 'size-full', imageClassName)} + src={image} + alt={alt} + /> + </div> + </div> </> ); } diff --git a/frontend/src/modules/attachment/render/pdf.tsx b/frontend/src/modules/attachment/render/pdf.tsx index a36efe290..fa22ad00f 100644 --- a/frontend/src/modules/attachment/render/pdf.tsx +++ b/frontend/src/modules/attachment/render/pdf.tsx @@ -31,7 +31,9 @@ export function RenderPDF({ file, className, fitMode = 'width' }: RenderPDFProps const node = containerRef.current; if (!node) return; - const update = (width: number, height: number) => setContainer({ width, height }); + // An unchanged size, such as the observer's first callback repeating the measurement below, skips the render. + const update = (width: number, height: number) => + setContainer((prev) => (prev.width === width && prev.height === height ? prev : { width, height })); update(node.clientWidth, node.clientHeight); diff --git a/frontend/src/modules/attachment/render/use-pan-zoom.ts b/frontend/src/modules/attachment/render/use-pan-zoom.ts new file mode 100644 index 000000000..06044c48f --- /dev/null +++ b/frontend/src/modules/attachment/render/use-pan-zoom.ts @@ -0,0 +1,128 @@ +import type React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { clampZoom, WHEEL_ZOOM_SENSITIVITY, ZOOM_STEP } from '~/modules/attachment/render/image-zoom'; + +type Offset = { x: number; y: number }; + +const blockPageWheel = (e: Event) => e.preventDefault(); + +/** The viewport carries the offset as custom properties that `layerStyle` reads, so a drag moves the layer without a render. */ +const writeOffset = (viewport: HTMLElement, { x, y }: Offset) => { + viewport.style.setProperty('--pan-x', `${x}`); + viewport.style.setProperty('--pan-y', `${y}`); +}; + +/** + * Zoom, pan and rotation of the dialog image viewer. Spread `panProps` on the viewport and put `layerStyle` on + * the layer inside it that holds the image. Zoom is anchored at the centre; a drag pans only while `panEnabled`. + */ +export function usePanZoom(panEnabled: boolean) { + const [zoom, setZoomState] = useState(1); + const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 }); + const [rotation, setRotation] = useState(0); + const [isPanning, setIsPanning] = useState(false); + // Pointer position, committed offset and viewport at the start of the current drag, plus its latest offset + const drag = useRef<{ x: number; y: number; dx: number; dy: number; viewport: HTMLElement; offset: Offset | null } | null>(null); + // Removes the window mouseup listener of the current mouse drag + const stopWindowRelease = useRef<(() => void) | null>(null); + // Pending frames: moves and wheel deltas between frames collapse into one write each + const panFrame = useRef(0); + const wheelFrame = useRef(0); + const wheelFactor = useRef(1); + + // Every input (buttons, wheel, trackpad pinch) goes through the same clamp. + const setZoom = (next: number) => setZoomState(clampZoom(next)); + + useEffect( + () => () => { + document.removeEventListener('wheel', blockPageWheel); + stopWindowRelease.current?.(); + cancelAnimationFrame(panFrame.current); + cancelAnimationFrame(wheelFrame.current); + }, + [], + ); + + const panEnd = () => { + stopWindowRelease.current?.(); + stopWindowRelease.current = null; + cancelAnimationFrame(panFrame.current); + panFrame.current = 0; + const current = drag.current; + drag.current = null; + // Write the last move as well, since its frame was cancelled: the DOM must match the offset committed here. + if (current?.offset) { + writeOffset(current.viewport, current.offset); + setOffset(current.offset); + } + setIsPanning(false); + }; + + const panStart = (pageX: number, pageY: number, e: React.MouseEvent | React.TouchEvent) => { + if (!panEnabled) return; + drag.current = { x: pageX, y: pageY, dx: offset.x, dy: offset.y, viewport: e.currentTarget as HTMLElement, offset: null }; + setIsPanning(true); + // A mouse button released outside the viewport ends the drag too; touch delivers touchend to its target. + if (!('touches' in e)) { + stopWindowRelease.current?.(); + window.addEventListener('mouseup', panEnd, { once: true }); + stopWindowRelease.current = () => window.removeEventListener('mouseup', panEnd); + } + // Keeps the drag from reaching the carousel + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + e.preventDefault(); + }; + + const panMove = (pageX: number, pageY: number) => { + const current = drag.current; + if (!current) return; + current.offset = { x: current.dx + pageX - current.x, y: current.dy + pageY - current.y }; + if (panFrame.current) return; + panFrame.current = requestAnimationFrame(() => { + panFrame.current = 0; + if (drag.current?.offset) writeOffset(drag.current.viewport, drag.current.offset); + }); + }; + + const panProps = { + style: { userSelect: 'none', cursor: isPanning ? 'move' : undefined, '--pan-x': offset.x, '--pan-y': offset.y } as const, + onMouseDown: (e: React.MouseEvent) => panStart(e.pageX, e.pageY, e), + onMouseMove: (e: React.MouseEvent) => panMove(e.pageX, e.pageY), + onMouseUp: panEnd, + onTouchStart: (e: React.TouchEvent) => panStart(e.touches[0].pageX, e.touches[0].pageY, e), + onTouchMove: (e: React.TouchEvent) => panMove(e.touches[0].pageX, e.touches[0].pageY), + onTouchEnd: panEnd, + // A trackpad pinch arrives as a ctrlKey wheel with small deltas: the exponential step keeps pinch and mouse + // wheel continuous, and the clamp sets the floor and ceiling. Steps multiply, so a frame's deltas apply as one. + onWheel: (e: React.WheelEvent) => { + const deltaY = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * 16 : e.deltaY; + wheelFactor.current *= Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY); + if (wheelFrame.current) return; + wheelFrame.current = requestAnimationFrame(() => { + wheelFrame.current = 0; + const factor = wheelFactor.current; + wheelFactor.current = 1; + setZoomState((prev) => clampZoom(prev * factor)); + }); + }, + // React wheel listeners are passive, so page scrolling is blocked from the document while hovering + onMouseEnter: () => document.addEventListener('wheel', blockPageWheel, { passive: false }), + onMouseLeave: () => document.removeEventListener('wheel', blockPageWheel), + }; + + return { + rotation, + panProps, + layerStyle: { transform: `matrix(${zoom},0,0,${zoom},var(--pan-x,0),var(--pan-y,0))` }, + zoomIn: () => setZoom(zoom + ZOOM_STEP), + zoomOut: () => setZoom(zoom - ZOOM_STEP), + rotateRight: () => setRotation((prev) => (prev + 1) % 4), + // The image fits its container via CSS (object-contain), so zoom 1 is the natural fit. + reset: () => { + setOffset({ x: 0, y: 0 }); + setZoom(1); + setRotation(0); + }, + }; +} diff --git a/frontend/src/modules/attachment/search-params-schemas.ts b/frontend/src/modules/attachment/search-params-schemas.ts index b3861b699..62534c35c 100644 --- a/frontend/src/modules/attachment/search-params-schemas.ts +++ b/frontend/src/modules/attachment/search-params-schemas.ts @@ -6,7 +6,4 @@ export const attachmentsSearchDefaults = { q: '', sort: 'createdAt', order: 'des export const attachmentsRouteSearchParamsSchema = zGetAttachmentsQuery .pick({ q: true, sort: true, order: true }) - .extend({ - attachmentDialogId: z.string().optional(), - groupId: z.string().optional(), - }); + .extend({ attachmentDialogId: z.string().optional(), groupId: z.string().optional() }); diff --git a/frontend/src/modules/attachment/table/attachment-cells.tsx b/frontend/src/modules/attachment/table/attachment-cells.tsx index 562ca9e3b..4e0c3313e 100644 --- a/frontend/src/modules/attachment/table/attachment-cells.tsx +++ b/frontend/src/modules/attachment/table/attachment-cells.tsx @@ -1,25 +1,20 @@ import { useNavigate } from '@tanstack/react-router'; -import i18n from 'i18next'; -import { CircleAlertIcon, CloudOffIcon, CloudUploadIcon, DownloadIcon, LoaderIcon, TrashIcon } from 'lucide-react'; +import { CircleAlertIcon, CloudOffIcon, CloudUploadIcon, DownloadIcon, LoaderIcon } from 'lucide-react'; import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import useDownloader from 'react-use-downloader'; import type { Attachment } from 'sdk'; -import { DeleteAttachments } from '~/modules/attachment/delete-attachments'; import { openAttachmentDialogSearch } from '~/modules/attachment/dialog/params'; import { getCloudUrl } from '~/modules/attachment/file-url'; import { useAttachmentUrl } from '~/modules/attachment/hooks/use-attachment-url'; import { useBlobUploadStatus } from '~/modules/attachment/hooks/use-blob-upload-status'; import { attachmentStorage } from '~/modules/attachment/offline/storage-service'; -import type { EllipsisOption } from '~/modules/common/data-table/table-ellipsis'; -import { TableEllipsis } from '~/modules/common/data-table/table-ellipsis'; -import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; -import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; import { MediaThumbnail } from '~/modules/common/media-thumbnail'; -import { PopConfirm } from '~/modules/common/popconfirm'; import { Spinner } from '~/modules/common/spinner'; import { toaster } from '~/modules/common/toaster/toaster'; import { Button } from '~/modules/ui/button'; +import { cn } from '~/utils/cn'; +import { tw } from '~/utils/tw'; interface ThumbnailCellProps { row: Attachment; @@ -29,23 +24,15 @@ interface ThumbnailCellProps { export function ThumbnailCell({ row, tabIndex }: ThumbnailCellProps) { const { id, filename, contentType, groupId } = row; const navigate = useNavigate(); - const setTriggerRef = useDialoger((state) => state.setTriggerRef); const cellRef = useRef<HTMLButtonElement | null>(null); - const wrapClass = 'relative flex space-x-2 items-center justify-center w-full h-full'; + const wrapClass = tw('relative flex size-full items-center justify-center gap-2'); // Table cells prefer the tiny thumbnail; non-image types have none and fall back to the mid-size preview. const { url } = useAttachmentUrl(row, { preferredVariant: 'thumbnail' }); const handleClick = () => { - setTriggerRef(id, cellRef); - - navigate({ - to: '.', - replace: false, - resetScroll: false, - search: openAttachmentDialogSearch(id, groupId), - }); + navigate({ to: '.', replace: false, resetScroll: false, search: openAttachmentDialogSearch(id, groupId) }); }; const preview = <MediaThumbnail name={filename} url={url} contentType={contentType} />; @@ -86,7 +73,7 @@ function SyncStatusBadge({ attachmentId }: { attachmentId: string }) { return ( <div - className={`absolute -right-0.5 -bottom-0.5 rounded-full p-0.5 ${isFailed ? 'bg-destructive' : 'bg-muted-foreground'}`} + className={cn('absolute -right-0.5 -bottom-0.5 rounded-full p-0.5', isFailed ? 'bg-destructive' : 'bg-muted-foreground')} data-tooltip="true" data-tooltip-content={tooltip} > @@ -145,35 +132,3 @@ export function DownloadCell({ row, tabIndex }: DownloadCellProps) { </Button> ); } - -interface EllipsisCellProps { - row: Attachment; - tabIndex: number; - /** Row-resolved delete permission ('own' already collapsed by the column hook). */ - canDelete: boolean; -} - -export function EllipsisCell({ row, tabIndex, canDelete }: EllipsisCellProps) { - // Delete is the only option; without it there is no menu to offer. - if (!canDelete) return null; - - const ellipsisOptions: EllipsisOption<Attachment>[] = [ - { - label: i18n.t('c:delete'), - icon: TrashIcon, - onSelect: (row) => { - const { update, remove } = useDropdowner.getState(); - - update({ - content: ( - <PopConfirm title={i18n.t('c:delete_confirm.text', { name: row.name })}> - <DeleteAttachments attachments={[row]} callback={remove} onCancel={remove} /> - </PopConfirm> - ), - }); - }, - }, - ]; - - return <TableEllipsis row={row} tabIndex={tabIndex} options={ellipsisOptions} />; -} diff --git a/frontend/src/modules/attachment/table/attachments-bar.tsx b/frontend/src/modules/attachment/table/attachments-bar.tsx index f91ee8448..0832d4dbc 100644 --- a/frontend/src/modules/attachment/table/attachments-bar.tsx +++ b/frontend/src/modules/attachment/table/attachments-bar.tsx @@ -8,16 +8,10 @@ import type { AttachmentsTableProps } from '~/modules/attachment/table/attachmen import { useAttachmentsUploadDialog } from '~/modules/attachment/table/use-attachments-upload-dialog'; import type { AttachmentsRouteSearchParams } from '~/modules/attachment/types'; import { AlertBanner } from '~/modules/common/alerter/alert-banner'; -import { ColumnsView } from '~/modules/common/data-table/columns-view'; import { TableBarButton } from '~/modules/common/data-table/table-bar-button'; -import { TableBarContainer } from '~/modules/common/data-table/table-bar-container'; -import { TableCount } from '~/modules/common/data-table/table-count'; -import { FilterBarActions, FilterBarSearch, TableFilterBar } from '~/modules/common/data-table/table-filter-bar'; -import { TableSearch } from '~/modules/common/data-table/table-search'; +import { TableBarShell, useTableBarFilters } from '~/modules/common/data-table/table-bar-shell'; import type { BaseTableBarProps } from '~/modules/common/data-table/types'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; -import { FocusView } from '~/modules/common/focus-view'; -import { SelectionActionBar } from '~/modules/common/selection-action-bar'; import { useResolveCan } from '~/modules/entities/use-resolve-can'; import { useListQueryTotal } from '~/query/basic/use-list-query-total'; @@ -42,10 +36,7 @@ export function AttachmentsTableBar({ const isOrganization = channel.entityType === 'organization'; const organizationId = !isOrganization && 'organizationId' in channel ? String(channel.organizationId) : channel.id; const publicAt = 'publicAt' in channel && typeof channel.publicAt === 'string' ? channel.publicAt : null; - const placement = { - ...(isOrganization ? {} : { [appConfig.entityIdColumnKeys[channel.entityType]]: channel.id }), - publicAt, - }; + const placement = { ...(!isOrganization && { [appConfig.entityIdColumnKeys[channel.entityType]]: channel.id }), publicAt }; const { open } = useAttachmentsUploadDialog(channel.tenantId, organizationId, placement); const resolveCan = useResolveCan(); @@ -53,29 +44,13 @@ export function AttachmentsTableBar({ const total = useListQueryTotal(queryKey); - const { q } = searchVars; - - const isFiltered = !!q; - const showUpload = canUpload && !isFiltered; + const barFilters = useTableBarFilters({ searchVars, setSearch, clearSelection, reset: { q: '' } }); // Bulk delete acts only on rows this user may delete; the badge shows that count when it differs from the selection. const deletable = selected.filter((row) => - resolveCan(channel.can?.attachment?.delete, row.createdBy, { - row: hierarchy.resolveDeepestAncestorId('attachment', row), - channel: channel.id, - }), + resolveCan(channel.can?.attachment?.delete, row.createdBy, { row: hierarchy.resolveDeepestAncestorId('attachment', row), channel: channel.id }), ); - const onSearch = (searchString: string) => { - clearSelection(); - setSearch({ q: searchString }); - }; - - const onResetFilters = () => { - setSearch({ q: '' }); - clearSelection(); - }; - const openDeleteDialog = () => { createDialog(<DeleteAttachments dialog attachments={deletable} callback={clearSelection} />, { id: 'delete-attachments', @@ -90,26 +65,18 @@ export function AttachmentsTableBar({ }; return ( - <> - <TableBarContainer searchVars={searchVars}> - <TableFilterBar onResetFilters={onResetFilters} isFiltered={isFiltered}> - <FilterBarActions> - {showUpload && <TableBarButton icon={UploadIcon} label="c:upload" onClick={() => open()} />} - <TableCount count={total} label="c:attachment" isFiltered={isFiltered} onResetFilters={onResetFilters} /> - </FilterBarActions> - <div className="sm:grow" /> - <FilterBarSearch> - <TableSearch name="attachmentSearch" value={q} setQuery={onSearch} allowOfflineSearch={true} /> - </FilterBarSearch> - </TableFilterBar> - - <ColumnsView className="max-lg:hidden" columns={columns} setColumns={setColumns} /> - - {!isSheet && <FocusView iconOnly />} - </TableBarContainer> - - <SelectionActionBar count={selected.length} onClear={clearSelection}> - {deletable.length > 0 && ( + <TableBarShell + {...barFilters} + {...{ searchVars, total, columns, setColumns }} + label="c:attachment" + searchName="attachmentSearch" + allowOfflineSearch + actions={canUpload && <TableBarButton icon={UploadIcon} label="c:upload" onClick={() => open()} />} + focusView={!isSheet} + selection={{ + count: selected.length, + onClear: clearSelection, + children: deletable.length > 0 && ( <TableBarButton ref={deleteButtonRef} variant="destructive" @@ -119,14 +86,15 @@ export function AttachmentsTableBar({ icon={TrashIcon} label="c:delete" /> - )} - </SelectionActionBar> - - {!!total && ( - <AlertBanner id="edit_attachment" variant="plain" className="mb-4" icon={InfoIcon} animate> - {t('c:edit_attachment.text')} - </AlertBanner> - )} - </> + ), + }} + after={ + !!total && ( + <AlertBanner id="edit_attachment" variant="plain" className="mb-4" icon={InfoIcon} animate> + {t('c:edit_attachment.text')} + </AlertBanner> + ) + } + /> ); } diff --git a/frontend/src/modules/attachment/table/attachments-columns.tsx b/frontend/src/modules/attachment/table/attachments-columns.tsx index a6a49ffae..13511ea04 100644 --- a/frontend/src/modules/attachment/table/attachments-columns.tsx +++ b/frontend/src/modules/attachment/table/attachments-columns.tsx @@ -1,13 +1,18 @@ -import { UserIcon } from 'lucide-react'; +import i18n from 'i18next'; +import { TrashIcon, UserIcon } from 'lucide-react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { Attachment } from 'sdk'; import { hierarchy, resolveCan, seenWindowMs } from 'shared'; -import { DownloadCell, EllipsisCell, ThumbnailCell } from '~/modules/attachment/table/attachment-cells'; +import { DeleteAttachments } from '~/modules/attachment/delete-attachments'; +import { DownloadCell, ThumbnailCell } from '~/modules/attachment/table/attachment-cells'; import { DescriptionCell, openDescriptionSheetFromCell } from '~/modules/attachment/table/description-cell'; import { EditCellInput, externalEditorOptions, RenderExternalEditor } from '~/modules/common/data-grid/cell-renderers'; import { CheckboxColumn } from '~/modules/common/data-table/checkbox-column'; +import { dateColumn, ellipsisColumn } from '~/modules/common/data-table/columns'; import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; +import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; +import { openPopConfirm } from '~/modules/common/popconfirm'; import type { EnrichedChannel } from '~/modules/entities/types'; import { SeenMark } from '~/modules/seen/seen-mark'; import { UserCell } from '~/modules/user/user-cell'; @@ -56,12 +61,7 @@ export const useColumns = (channel: EnrichedChannel, isSheet: boolean) => { minWidth: 180, renderCell: ({ row }) => ( <> - <SeenMark - productId={row.id} - tenantId={channel.tenantId} - organizationId={channel.id} - productType="attachment" - /> + <SeenMark productId={row.id} tenantId={channel.tenantId} organizationId={channel.id} productType="attachment" /> <span className="truncate font-medium">{row.name || '-'}</span> </> ), @@ -95,22 +95,28 @@ export const useColumns = (channel: EnrichedChannel, isSheet: boolean) => { width: 32, renderCell: ({ row, tabIndex }) => <DownloadCell row={row} tabIndex={tabIndex} />, }, - { - key: 'ellipsis', - name: '', - maxBreakpoint: 'sm', - width: 32, - renderCell: ({ row, tabIndex }) => ( - <EllipsisCell - row={row} - tabIndex={tabIndex} - canDelete={resolveCan(deleteState, row.createdBy?.id ?? null, userId, { - row: hierarchy.resolveDeepestAncestorId('attachment', row), - channel: channelId, - })} - /> - ), - }, + // Delete is the only row action, so a row the user cannot delete gets no menu. + ellipsisColumn<Attachment>((row) => { + const canDelete = resolveCan(deleteState, row.createdBy?.id ?? null, userId, { + row: hierarchy.resolveDeepestAncestorId('attachment', row), + channel: channelId, + }); + if (!canDelete) return []; + + return [ + { + label: i18n.t('c:delete'), + icon: TrashIcon, + onSelect: (row) => { + const { remove } = useDropdowner.getState(); + openPopConfirm( + i18n.t('c:delete_confirm.text', { name: row.name }), + <DeleteAttachments attachments={[row]} callback={remove} onCancel={remove} />, + ); + }, + }, + ]; + }, 'sm'), { key: 'filename', name: t('c:filename'), @@ -118,9 +124,7 @@ export const useColumns = (channel: EnrichedChannel, isSheet: boolean) => { resizable: true, minWidth: 140, renderCell: ({ row }) => ( - <span className="truncate underline-offset-4 group-hover:underline"> - {row.filename || <span className="text-muted">-</span>} - </span> + <span className="truncate underline-offset-4 group-hover:underline">{row.filename || <span className="text-muted">-</span>}</span> ), }, { @@ -129,9 +133,7 @@ export const useColumns = (channel: EnrichedChannel, isSheet: boolean) => { minBreakpoint: 'md', width: 100, renderCell: ({ row }) => ( - <div className="group relative inline-flex h-full w-full items-center gap-1 opacity-50"> - {formatBytes(row.size)} - </div> + <div className="group relative inline-flex size-full items-center gap-1 text-muted-foreground/70">{formatBytes(row.size)}</div> ), }, { @@ -145,35 +147,24 @@ export const useColumns = (channel: EnrichedChannel, isSheet: boolean) => { return ( <span - className="inline-flex h-full w-full items-center" + className="inline-flex size-full items-center" data-tooltip={outsideSeenWindow ? 'true' : undefined} data-tooltip-content={outsideSeenWindow ? t('c:views_retention_hint') : undefined} > <UserIcon className="mr-2 opacity-50" /> - <span className={cn(outsideSeenWindow && 'text-muted-foreground/60')}>{row.viewCount ?? 0}</span> + <span className={cn(outsideSeenWindow && 'text-muted-foreground/70')}>{row.viewCount ?? 0}</span> </span> ); }, }, - { - key: 'createdAt', - name: t('c:created_at'), - sortable: true, - sortDescendingFirst: true, - hidden: isSheet, - minBreakpoint: 'md', - minWidth: 120, - placeholderValue: '-', - renderCell: ({ row }) => dateShort(row.createdAt), - }, + dateColumn('createdAt', { name: t('c:created_at'), hidden: isSheet }), { key: 'createdBy', name: t('c:created_by'), hidden: true, minWidth: 160, placeholderValue: '-', - renderCell: ({ row, tabIndex }) => - row.createdBy && <UserCell compactable user={row.createdBy} tabIndex={tabIndex} />, + renderCell: ({ row, tabIndex }) => row.createdBy && <UserCell compactable user={row.createdBy} tabIndex={tabIndex} />, }, { key: 'updatedAt', @@ -189,8 +180,7 @@ export const useColumns = (channel: EnrichedChannel, isSheet: boolean) => { hidden: true, width: 160, placeholderValue: '-', - renderCell: ({ row, tabIndex }) => - row.updatedBy && <UserCell compactable user={row.updatedBy} tabIndex={tabIndex} />, + renderCell: ({ row, tabIndex }) => row.updatedBy && <UserCell compactable user={row.updatedBy} tabIndex={tabIndex} />, }, ], [canUpdate, deleteState, channelId, userId, isSheet], diff --git a/frontend/src/modules/attachment/table/attachments-table.tsx b/frontend/src/modules/attachment/table/attachments-table.tsx index 1f331a49e..e8f7d6f51 100644 --- a/frontend/src/modules/attachment/table/attachments-table.tsx +++ b/frontend/src/modules/attachment/table/attachments-table.tsx @@ -5,11 +5,7 @@ import { useTranslation } from 'react-i18next'; import type { Attachment } from 'sdk'; import { appConfig } from 'shared'; import { useSearchParams } from '~/hooks/use-search-params'; -import { - attachmentsCanonicalOptions, - attachmentsListQueryOptions, - useAttachmentUpdateMutation, -} from '~/modules/attachment/query'; +import { attachmentsCanonicalOptions, attachmentsListQueryOptions, useAttachmentUpdateMutation } from '~/modules/attachment/query'; import { attachmentsSearchDefaults } from '~/modules/attachment/search-params-schemas'; import { AttachmentsTableBar } from '~/modules/attachment/table/attachments-bar'; import { useColumns } from '~/modules/attachment/table/attachments-columns'; @@ -19,6 +15,7 @@ import type { RowsChangeData } from '~/modules/common/data-grid'; import { DataTable } from '~/modules/common/data-table/data-table'; import { useSortColumns } from '~/modules/common/data-table/sort-columns'; import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; +import { useRowSelection } from '~/modules/common/data-table/use-row-selection'; import type { EnrichedChannel } from '~/modules/entities/types'; import { isDefaultListView } from '~/query/basic/create-query-keys'; @@ -55,15 +52,10 @@ function AttachmentsTable({ channel, canUpload, isSheet = false }: AttachmentsTa const { q, sort, order } = search; const limit = LIMIT; - const [selected, setSelected] = useState<Attachment[]>([]); const columnsFromHook = useColumns(channel, isSheet); const [hiddenOverrides, setHiddenOverrides] = useState<Record<string, boolean>>({}); const columns = useMemo( - () => - columnsFromHook.map((col) => ({ - ...col, - hidden: hiddenOverrides[col.key] ?? col.hidden, - })), + () => columnsFromHook.map((col) => ({ ...col, hidden: hiddenOverrides[col.key] ?? col.hidden })), [columnsFromHook, hiddenOverrides], ); const setColumns: React.Dispatch<React.SetStateAction<ColumnOrColumnGroup<Attachment>[]>> = (updater) => { @@ -81,24 +73,10 @@ function AttachmentsTable({ channel, canUpload, isSheet = false }: AttachmentsTa // Default view (no search, default sort) reads the canonical org query that SyncService prefetches; any other filter uses the infinite query. const isDefaultView = isDefaultListView({ q, sort, order }, attachmentsSearchDefaults); - const canonicalOptions = attachmentsCanonicalOptions({ - tenantId: channel.tenantId, - organizationId: channel.id, - }); - const canonical = useQuery({ - ...canonicalOptions, - enabled: isDefaultView, - select: selectDefaultViewRows, - }); + const canonicalOptions = attachmentsCanonicalOptions({ tenantId: channel.tenantId, organizationId: channel.id }); + const canonical = useQuery({ ...canonicalOptions, enabled: isDefaultView, select: selectDefaultViewRows }); - const queryOptions = attachmentsListQueryOptions({ - tenantId: channel.tenantId, - organizationId: channel.id, - q, - sort, - order, - limit, - }); + const queryOptions = attachmentsListQueryOptions({ tenantId: channel.tenantId, organizationId: channel.id, q, sort, order, limit }); const filtered = useInfiniteQuery({ ...queryOptions, enabled: !isDefaultView, @@ -107,6 +85,7 @@ function AttachmentsTable({ channel, canUpload, isSheet = false }: AttachmentsTa }); const { data: rows, isLoading, isFetching, error } = isDefaultView ? canonical : filtered; + const { selected, selectedRowIds, onSelectedRowsChange, clearSelection } = useRowSelection(rows); const hasNextPage = isDefaultView ? false : filtered.hasNextPage; const onRowsChange = (changedRows: Attachment[], { indexes, column }: RowsChangeData<Attachment>) => { @@ -123,22 +102,10 @@ function AttachmentsTable({ channel, canUpload, isSheet = false }: AttachmentsTa await filtered.fetchNextPage(); }; - const onSelectedRowsChange = (value: Set<string>) => { - if (rows) setSelected(rows.filter((row) => value.has(row.id))); - }; - - const selectedRowIds = useMemo(() => new Set(selected.map((s) => s.id)), [selected]); - const NoRowsComponent = ( - <ContentPlaceholder - icon={PaperclipIcon} - title="c:no_resource_yet" - titleProps={{ resource: t('c:attachment_other').toLowerCase() }} - /> + <ContentPlaceholder icon={PaperclipIcon} title="c:no_resource_yet" titleProps={{ resource: t('c:attachment_other').toLowerCase() }} /> ); - const clearSelection = () => setSelected([]); - return ( <> <AttachmentsTableBar diff --git a/frontend/src/modules/attachment/table/description-cell.tsx b/frontend/src/modules/attachment/table/description-cell.tsx index f0ff4e406..7139d2677 100644 --- a/frontend/src/modules/attachment/table/description-cell.tsx +++ b/frontend/src/modules/attachment/table/description-cell.tsx @@ -26,11 +26,10 @@ export function DescriptionCell({ row, editable }: DescriptionCellProps) { const onDoubleClick = editable ? undefined - : (event: MouseEvent<HTMLSpanElement>) => - openDescriptionSheetFromCell(row.id, event.currentTarget.closest<HTMLElement>('[role="gridcell"]')); + : (event: MouseEvent<HTMLSpanElement>) => openDescriptionSheetFromCell(row.id, event.currentTarget.closest<HTMLElement>('[role="gridcell"]')); return ( - <span className="flex h-full w-full items-center font-light" onDoubleClick={onDoubleClick}> + <span className="flex size-full items-center font-light" onDoubleClick={onDoubleClick}> <span className="truncate">{text}</span> </span> ); diff --git a/frontend/src/modules/attachment/table/use-attachments-upload-dialog.tsx b/frontend/src/modules/attachment/table/use-attachments-upload-dialog.tsx index 9aee55929..edf71a2f9 100644 --- a/frontend/src/modules/attachment/table/use-attachments-upload-dialog.tsx +++ b/frontend/src/modules/attachment/table/use-attachments-upload-dialog.tsx @@ -33,25 +33,17 @@ export const useAttachmentsUploadDialog = ( createAttachments.mutate(attachments); }; + const item = t('c:attachment_other').toLowerCase(); useUploader.getState().create({ id: 'upload-attachment', personalUpload: false, organizationId, templateId: 'attachment', - restrictions: { - maxNumberOfFiles, - maxTotalFileSize, - allowedFileTypes: ['*/*'], - }, + restrictions: { maxNumberOfFiles, maxTotalFileSize, allowedFileTypes: ['*/*'] }, plugins: ['webcam', 'image-editor', 'screen-capture', 'audio', 'url'], statusEventHandler: { onComplete }, - title: t('c:upload_item', { - item: t('c:attachment_other').toLowerCase(), - }), - description: t('c:upload_multiple.text', { - item: t('c:attachment_other').toLowerCase(), - count: maxNumberOfFiles, - }), + title: t('c:upload_item', { item }), + description: t('c:upload_multiple.text', { item, count: maxNumberOfFiles }), }); }; diff --git a/frontend/src/modules/attachment/tests/attachment-replay.test.ts b/frontend/src/modules/attachment/tests/attachment-replay.test.ts index e26a0f33f..9fa1c32d4 100644 --- a/frontend/src/modules/attachment/tests/attachment-replay.test.ts +++ b/frontend/src/modules/attachment/tests/attachment-replay.test.ts @@ -9,19 +9,9 @@ const { createAttachments, updateAttachment, deleteAttachments } = vi.hoisted(() deleteAttachments: vi.fn(async (_req: Req) => undefined), })); -vi.mock('sdk', () => ({ - createAttachments, - updateAttachment, - deleteAttachments, - getAttachment: vi.fn(), - getAttachments: vi.fn(), -})); +vi.mock('sdk', () => ({ createAttachments, updateAttachment, deleteAttachments, getAttachment: vi.fn(), getAttachments: vi.fn() })); -import { - createAttachmentsMutationFn, - deleteAttachmentsMutationFn, - updateAttachmentMutationFn, -} from '~/modules/attachment/query-mutations'; +import { createAttachmentsMutationFn, deleteAttachmentsMutationFn, updateAttachmentMutationFn } from '~/modules/attachment/query-mutations'; const ctx = { tenantId: 'ten-1', organizationId: 'org-1' }; @@ -71,11 +61,7 @@ describe('attachment offline-replay mutation functions', () => { it('replay reuses the stx persisted in variables: same mutationId and HLCs, no restamp (D4)', async () => { updateAttachment.mockClear(); - const persistedStx = { - mutationId: 'original-mutation-id', - sourceId: 'tab-1', - fieldTimestamps: { name: '1710500000123:0001:abcde' }, - }; + const persistedStx = { mutationId: 'original-mutation-id', sourceId: 'tab-1', fieldTimestamps: { name: '1710500000123:0001:abcde' } }; await updateAttachmentMutationFn({ ...ctx, id: 'att-1', ops: { name: 'Renamed' }, stx: persistedStx }); // Idempotency + intent-time LWW: the replayed request is byte-identical to the original. diff --git a/frontend/src/modules/attachment/tests/blob-upload-status.test.tsx b/frontend/src/modules/attachment/tests/blob-upload-status.test.tsx new file mode 100644 index 000000000..5b3fc053c --- /dev/null +++ b/frontend/src/modules/attachment/tests/blob-upload-status.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import 'fake-indexeddb/auto'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type AttachmentBlob, attachmentsDb } from '../offline/attachments-db'; + +vi.mock('shared', async () => ({ appConfig: (await import('./test-setup')).mockAttachmentAppConfig })); +// The real owner-change source loads every per-user store; this file keeps one database bound throughout. +vi.mock('~/query/local-user-storage', () => ({ subscribeOwnerChange: () => () => {} })); + +import { bindLocalUserDb } from '~/query/local-user-db'; +import { useBlobUploadStatus } from '../hooks/use-blob-upload-status'; + +bindLocalUserDb('test-user'); + +type Info = ReturnType<typeof useBlobUploadStatus>; + +/** Latest info and render count per probed attachment id. */ +const seen = new Map<string, { info: Info; renders: number }>(); + +function Probe({ id }: { id: string }) { + const info = useBlobUploadStatus(id); + seen.set(id, { info, renders: (seen.get(id)?.renders ?? 0) + 1 }); + return null; +} + +// The hook never reads the bytes, and jsdom's Blob does not survive fake-indexeddb's structured clone. +function makeBlob(id: string, overrides: Partial<AttachmentBlob> = {}): AttachmentBlob { + const [attachmentId, variant] = id.split(':') as [string, AttachmentBlob['variant']]; + return { + id, + attachmentId, + variant, + organizationId: 'org-1', + blob: {} as Blob, + size: 4, + contentType: 'image/png', + source: 'download', + uploadStatus: 'uploaded', + storedAt: new Date(), + ...overrides, + }; +} + +let root: Root | undefined; + +function render(ids: string[]) { + root = createRoot(document.createElement('div')); + root.render(ids.map((id) => <Probe key={id} id={id} />)); +} + +const infoOf = (id: string) => seen.get(id)?.info; +const rendersOf = (id: string) => seen.get(id)?.renders ?? 0; + +describe('useBlobUploadStatus', () => { + beforeEach(async () => { + await attachmentsDb.blobs.clear(); + }); + + afterEach(async () => { + root?.unmount(); + root = undefined; + seen.clear(); + await attachmentsDb.blobs.clear(); + }); + + it('reads the raw blob status, else the first blob in key order, and defaults without a blob', async () => { + await attachmentsDb.blobs.bulkAdd([ + makeBlob('a1:raw', { source: 'upload', uploadStatus: 'pending' }), + makeBlob('a2:original'), + makeBlob('a2:thumbnail'), + makeBlob('a3:original'), + makeBlob('a3:raw', { source: 'upload', uploadStatus: 'failed', lastError: 'boom' }), + makeBlob('a4:converted', { source: 'upload', uploadStatus: 'local-only' }), + makeBlob('a4:preview'), + ]); + + render(['a1', 'a2', 'a3', 'a4', 'a5']); + await vi.waitFor(() => expect(infoOf('a1')?.isPending).toBe(true)); + + expect(infoOf('a1')).toMatchObject({ hasLocalBlob: true, isUploaded: false, isPending: true, lastError: null }); + expect(infoOf('a2')).toMatchObject({ hasLocalBlob: true, isUploaded: true, lastError: null }); + expect(infoOf('a3')).toMatchObject({ hasLocalBlob: true, isUploaded: false, isFailed: true, lastError: 'boom' }); + expect(infoOf('a4')).toMatchObject({ hasLocalBlob: true, isUploaded: false, isLocalOnly: true }); + expect(infoOf('a5')).toMatchObject({ hasLocalBlob: false, isUploaded: true, lastError: null }); + // A row whose info stays the default never renders again. + expect(rendersOf('a5')).toBe(1); + }); + + it('rerenders only the rows whose status changed', async () => { + await attachmentsDb.blobs.bulkAdd([ + makeBlob('a1:raw', { source: 'upload', uploadStatus: 'pending' }), + makeBlob('a2:raw', { source: 'upload', uploadStatus: 'failed', lastError: 'boom' }), + ]); + + render(['a1', 'a2', 'a3']); + await vi.waitFor(() => expect(infoOf('a1')?.isPending).toBe(true)); + const a2Renders = rendersOf('a2'); + + await attachmentsDb.blobs.update('a1:raw', { uploadStatus: 'uploading' }); + await vi.waitFor(() => expect(infoOf('a1')?.isUploading).toBe(true)); + await attachmentsDb.blobs.add(makeBlob('a3:original')); + await vi.waitFor(() => expect(infoOf('a3')?.hasLocalBlob).toBe(true)); + + expect(rendersOf('a2')).toBe(a2Renders); + }); + + it('drops an attachment once its blobs are deleted', async () => { + await attachmentsDb.blobs.add(makeBlob('a1:raw', { source: 'upload', uploadStatus: 'failed', lastError: 'boom' })); + + render(['a1']); + await vi.waitFor(() => expect(infoOf('a1')?.isFailed).toBe(true)); + + await attachmentsDb.blobs.where('attachmentId').equals('a1').delete(); + await vi.waitFor(() => expect(infoOf('a1')).toMatchObject({ hasLocalBlob: false, isUploaded: true, isFailed: false, lastError: null })); + }); +}); diff --git a/frontend/src/modules/attachment/tests/download-queue.test.ts b/frontend/src/modules/attachment/tests/download-queue.test.ts index fdcb83a23..27e11ae8c 100644 --- a/frontend/src/modules/attachment/tests/download-queue.test.ts +++ b/frontend/src/modules/attachment/tests/download-queue.test.ts @@ -3,14 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { attachmentsDb } from '../offline/attachments-db'; // Mock external deps -vi.mock('shared', async () => ({ - appConfig: (await import('./test-setup')).mockAttachmentAppConfig, -})); +vi.mock('shared', async () => ({ appConfig: (await import('./test-setup')).mockAttachmentAppConfig })); vi.mock('../offline/storage-service', () => ({ - attachmentStorage: { - getStoredVariants: vi.fn().mockResolvedValue([]), - }, + attachmentStorage: { getStoredVariants: vi.fn().mockResolvedValue([]) }, })); import { bindLocalUserDb } from '~/query/local-user-db'; diff --git a/frontend/src/modules/attachment/tests/download-service.test.ts b/frontend/src/modules/attachment/tests/download-service.test.ts index 5f7eb7ce7..232c8e98c 100644 --- a/frontend/src/modules/attachment/tests/download-service.test.ts +++ b/frontend/src/modules/attachment/tests/download-service.test.ts @@ -3,13 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { attachmentsDb } from '../offline/attachments-db'; // Mock external deps before imports -vi.mock('shared', async () => ({ - appConfig: (await import('./test-setup')).mockAttachmentAppConfig, -})); +vi.mock('shared', async () => ({ appConfig: (await import('./test-setup')).mockAttachmentAppConfig })); -vi.mock('@tanstack/react-query', () => ({ - onlineManager: { isOnline: () => true }, -})); +vi.mock('@tanstack/react-query', () => ({ onlineManager: { isOnline: () => true } })); vi.mock('../offline/storage-service', () => ({ attachmentStorage: { @@ -33,15 +29,10 @@ vi.mock('../query', () => ({ attachmentQueryKeys: { list: { base: ['attachment', 'list'] }, delete: ['attachment', 'delete'] }, })); -vi.mock('~/query/basic/flatten', () => ({ - flattenInfiniteData: vi.fn().mockReturnValue([]), -})); +vi.mock('~/query/basic/flatten', () => ({ flattenInfiniteData: vi.fn().mockReturnValue([]) })); vi.mock('~/query/query-client', () => ({ - queryClient: { - getQueryCache: () => ({ subscribe: vi.fn() }), - getMutationCache: () => ({ subscribe: vi.fn() }), - }, + queryClient: { getQueryCache: () => ({ subscribe: vi.fn() }), getMutationCache: () => ({ subscribe: vi.fn() }) }, })); vi.mock('~/query/local-user-storage', () => ({ @@ -166,13 +157,7 @@ describe('downloadService: auth fail-fast (401/403)', () => { it('marks failed and stops fetching remaining variants on 403', async () => { vi.mocked(findAttachmentInCache).mockReturnValue( - makeAttachment({ - keys: { - original: 'files/orig.png', - preview: 'files/thumb.png', - converted: 'files/conv.png', - }, - }), + makeAttachment({ keys: { original: 'files/orig.png', preview: 'files/thumb.png', converted: 'files/conv.png' } }), ); const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 403 })); @@ -192,13 +177,7 @@ describe('downloadService: auth fail-fast (401/403)', () => { it('marks failed and stops fetching remaining variants on 401', async () => { vi.mocked(findAttachmentInCache).mockReturnValue( - makeAttachment({ - keys: { - original: 'files/orig.png', - preview: 'files/thumb.png', - converted: 'files/conv.png', - }, - }), + makeAttachment({ keys: { original: 'files/orig.png', preview: 'files/thumb.png', converted: 'files/conv.png' } }), ); const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 })); diff --git a/frontend/src/modules/attachment/tests/file-url.test.ts b/frontend/src/modules/attachment/tests/file-url.test.ts index fa4445379..0090f7357 100644 --- a/frontend/src/modules/attachment/tests/file-url.test.ts +++ b/frontend/src/modules/attachment/tests/file-url.test.ts @@ -5,18 +5,11 @@ vi.mock('shared', () => ({ })); const getPresignedUrlBatched = vi.fn().mockResolvedValue('https://signed.example/url'); -vi.mock('../presign-batch', () => ({ - getPresignedUrlBatched: (...args: unknown[]) => getPresignedUrlBatched(...args), -})); +vi.mock('../presign-batch', () => ({ getPresignedUrlBatched: (...args: unknown[]) => getPresignedUrlBatched(...args) })); const { getCloudUrl } = await import('../file-url'); -const baseAttachment = { - id: 'att-1', - tenantId: 'tenant-1', - organizationId: 'org-1', - keys: { original: 'org/attachments/original/a.jpg' }, -}; +const baseAttachment = { id: 'att-1', tenantId: 'tenant-1', organizationId: 'org-1', keys: { original: 'org/attachments/original/a.jpg' } }; describe('getCloudUrl public/private branch', () => { beforeEach(() => { diff --git a/frontend/src/modules/attachment/tests/parse-uploaded.test.ts b/frontend/src/modules/attachment/tests/parse-uploaded.test.ts index 09d3ba9e4..d4f6c00cd 100644 --- a/frontend/src/modules/attachment/tests/parse-uploaded.test.ts +++ b/frontend/src/modules/attachment/tests/parse-uploaded.test.ts @@ -65,9 +65,7 @@ describe('parseUploadedAttachments', () => { it('correlates converted and thumbnail variants back to the original by upload id', () => { const result = makeResult({ ':original': [makeOriginal()], - converted_image: [ - { original_id: 'upload-1', url: 'files/holiday.webp', mime: 'image/webp' } as unknown as UploadedFile, - ], + converted_image: [{ original_id: 'upload-1', url: 'files/holiday.webp', mime: 'image/webp' } as unknown as UploadedFile], thumb_image: [{ original_id: 'upload-1', url: 'files/holiday-thumb.png' } as unknown as UploadedFile], }); diff --git a/frontend/src/modules/attachment/tests/presign-batch.test.ts b/frontend/src/modules/attachment/tests/presign-batch.test.ts index d114bc41f..cbb6dd1be 100644 --- a/frontend/src/modules/attachment/tests/presign-batch.test.ts +++ b/frontend/src/modules/attachment/tests/presign-batch.test.ts @@ -1,29 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const getPresignedUrls = vi.fn(); -vi.mock('sdk/sdk.gen', () => ({ - getPresignedUrls: (...args: unknown[]) => getPresignedUrls(...args), -})); +vi.mock('sdk/sdk.gen', () => ({ getPresignedUrls: (...args: unknown[]) => getPresignedUrls(...args) })); vi.mock('~/query/local-user-storage', () => ({ subscribeOwnerChange: () => () => {}, })); const isOnline = vi.fn(() => true); -vi.mock('@tanstack/react-query', () => ({ - onlineManager: { isOnline: () => isOnline() }, -})); +vi.mock('@tanstack/react-query', () => ({ onlineManager: { isOnline: () => isOnline() } })); const { getPresignedUrlBatched, PresignRejectedError, resetPresignBatch } = await import('../presign-batch'); /** Echo-sign every requested pair, mirroring the server's happy path. */ function signAllRequested() { - getPresignedUrls.mockImplementation( - async ({ body }: { body: { items: { attachmentId: string; variant: string }[] } }) => ({ - data: body.items.map((item) => ({ ...item, url: `https://signed.example/${item.attachmentId}/${item.variant}` })), - rejectedIds: [], - }), - ); + getPresignedUrls.mockImplementation(async ({ body }: { body: { items: { attachmentId: string; variant: string }[] } }) => ({ + data: body.items.map((item) => ({ ...item, url: `https://signed.example/${item.attachmentId}/${item.variant}` })), + rejectedIds: [], + })); } const request = (id: string, variant: 'original' | 'thumbnail' | 'converted' = 'original') => diff --git a/frontend/src/modules/attachment/tests/resolve-block-ref.test.ts b/frontend/src/modules/attachment/tests/resolve-block-ref.test.ts index cbcf6fba0..9e5408a70 100644 --- a/frontend/src/modules/attachment/tests/resolve-block-ref.test.ts +++ b/frontend/src/modules/attachment/tests/resolve-block-ref.test.ts @@ -2,9 +2,7 @@ import { appConfig } from 'shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const getPresignedUrlBatched = vi.fn(async (attachmentId: string) => `https://signed.example.test/${attachmentId}`); -vi.mock('~/modules/attachment/presign-batch', () => ({ - getPresignedUrlBatched: (attachmentId: string) => getPresignedUrlBatched(attachmentId), -})); +vi.mock('~/modules/attachment/presign-batch', () => ({ getPresignedUrlBatched: (attachmentId: string) => getPresignedUrlBatched(attachmentId) })); vi.mock('~/modules/attachment/offline/storage-service', () => ({ attachmentStorage: { getSharedBlobUrl: async () => null, createBlobUrlWithVariant: async () => null }, })); diff --git a/frontend/src/modules/attachment/tests/storage-service.test.ts b/frontend/src/modules/attachment/tests/storage-service.test.ts index c08b80598..4e40f2630 100644 --- a/frontend/src/modules/attachment/tests/storage-service.test.ts +++ b/frontend/src/modules/attachment/tests/storage-service.test.ts @@ -2,9 +2,7 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AttachmentBlob, attachmentsDb } from '../offline/attachments-db'; -vi.mock('shared', async () => ({ - appConfig: (await import('./test-setup')).mockAttachmentAppConfig, -})); +vi.mock('shared', async () => ({ appConfig: (await import('./test-setup')).mockAttachmentAppConfig })); import { bindLocalUserDb } from '~/query/local-user-db'; import { attachmentStorage } from '../offline/storage-service'; diff --git a/frontend/src/modules/attachment/tests/test-setup.ts b/frontend/src/modules/attachment/tests/test-setup.ts index 872a4af63..6b050d489 100644 --- a/frontend/src/modules/attachment/tests/test-setup.ts +++ b/frontend/src/modules/attachment/tests/test-setup.ts @@ -18,16 +18,7 @@ export const mockAttachmentAppConfig = { /** Factory for DownloadQueueEntry test data */ export function makeQueueEntry(overrides: Partial<DownloadQueueEntry> = {}): DownloadQueueEntry { - return { - id: 'att-1', - organizationId: 'org-1', - priority: 1, - status: 'pending', - skipReason: null, - queuedAt: new Date(), - attempts: 0, - ...overrides, - }; + return { id: 'att-1', organizationId: 'org-1', priority: 1, status: 'pending', skipReason: null, queuedAt: new Date(), attempts: 0, ...overrides }; } /** Factory for attachment-like objects */ diff --git a/frontend/src/modules/attachment/tests/upload-retry.test.ts b/frontend/src/modules/attachment/tests/upload-retry.test.ts index 8b5f80557..25a11d127 100644 --- a/frontend/src/modules/attachment/tests/upload-retry.test.ts +++ b/frontend/src/modules/attachment/tests/upload-retry.test.ts @@ -24,9 +24,7 @@ describe('isUploadCandidate (failed-upload retry policy, D7)', () => { }); it('a failed blob without bookkeeping (legacy rows) is retried', () => { - expect(isUploadCandidate({ uploadStatus: 'failed', uploadAttempts: undefined, nextRetryAt: null }, 3, NOW)).toBe( - true, - ); + expect(isUploadCandidate({ uploadStatus: 'failed', uploadAttempts: undefined, nextRetryAt: null }, 3, NOW)).toBe(true); }); it('other statuses (uploaded, local-only, uploading) never retry', () => { diff --git a/frontend/src/modules/auth/auth-email-button.tsx b/frontend/src/modules/auth/auth-email-button.tsx index 9a2d3690a..9e7c9947c 100644 --- a/frontend/src/modules/auth/auth-email-button.tsx +++ b/frontend/src/modules/auth/auth-email-button.tsx @@ -1,5 +1,6 @@ import { ChevronDownIcon } from 'lucide-react'; import { Button } from '~/modules/ui/button'; +import { cn } from '~/utils/cn'; interface AuthEmailButtonProps { email: string; @@ -14,10 +15,10 @@ export function AuthEmailButton({ email, onClick, disabled, className = '' }: Au variant="ghost" onClick={onClick} disabled={disabled} - className={`group mx-auto flex max-w-full truncate bg-foreground/10 font-normal sm:text-lg ${className}`} + className={cn('group mx-auto flex max-w-full truncate bg-foreground/10 font-normal sm:text-lg', className)} > <span className="truncate">{email}</span> - <ChevronDownIcon className="ml-1 group-disabled:hidden" /> + <ChevronDownIcon className="group-disabled:hidden" /> </Button> ); } diff --git a/frontend/src/modules/auth/auth-error-page.tsx b/frontend/src/modules/auth/auth-error-page.tsx index 0fdb92bcb..2a5aa7e99 100644 --- a/frontend/src/modules/auth/auth-error-page.tsx +++ b/frontend/src/modules/auth/auth-error-page.tsx @@ -26,7 +26,7 @@ export function AuthErrorPage() { variant={resendTokenId ? 'plain' : 'default'} render={<Link to="/auth/authenticate" search={resumeTokenId ? { tokenId: resumeTokenId } : {}} replace />} > - <LogInIcon className="mr-2" /> + <LogInIcon /> {t('c:sign_in')} </Button> </ErrorNotice> diff --git a/frontend/src/modules/auth/auth-layout.tsx b/frontend/src/modules/auth/auth-layout.tsx index e036b14fc..4a5e1afac 100644 --- a/frontend/src/modules/auth/auth-layout.tsx +++ b/frontend/src/modules/auth/auth-layout.tsx @@ -10,9 +10,7 @@ const BgAnimation = lazyNamed(() => import('~/modules/common/bg-animation/bg-ani export function AuthLayout() { const { hasStarted, hasWaited } = useMountedState(); - const { location, resolvedLocation } = useRouterState(); - const pathname = (resolvedLocation ?? location).pathname; - const isSignInPage = pathname === '/auth/authenticate'; + const isSignInPage = useRouterState({ select: (s) => (s.resolvedLocation ?? s.location).pathname === '/auth/authenticate' }); const authFooterLinks: FooterLinkProps[] = [{ id: 'about', href: appConfig.aboutUrl }]; diff --git a/frontend/src/modules/auth/authenticate-page.tsx b/frontend/src/modules/auth/authenticate-page.tsx index 98a2c3bf3..e6ff53642 100644 --- a/frontend/src/modules/auth/authenticate-page.tsx +++ b/frontend/src/modules/auth/authenticate-page.tsx @@ -8,15 +8,7 @@ import { appConfig } from 'shared'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore } from '~/modules/auth/auth-store'; import { OAuthProviders } from '~/modules/auth/oauth-providers'; -import { - AcceptInvitationStep, - CheckEmailStep, - InviteOnlyStep, - MagicLinkSentStep, - SignInStep, - SignUpStep, - WaitlistStep, -} from '~/modules/auth/steps'; +import { AcceptInvitationStep, CheckEmailStep, InviteOnlyStep, MagicLinkSentStep, SignInStep, SignUpStep, WaitlistStep } from '~/modules/auth/steps'; import { useGetTokenData } from '~/modules/auth/use-get-token-data'; import { Spinner } from '~/modules/common/spinner'; import { toaster } from '~/modules/common/toaster/toaster'; @@ -56,11 +48,7 @@ export function AuthenticatePage() { const { data: signedInUser } = useQuery({ ...meQueryOptions(), enabled: false }); // A signed-in visitor gets this page's own notice for a spent token, so the global toast stays quiet for them. - const { - data: tokenData, - isLoading, - isError: isTokenError, - } = useGetTokenData('invitation', tokenId, !!tokenId, !!signedInUser); + const { data: tokenData, isLoading, isError: isTokenError } = useGetTokenData('invitation', tokenId, !!tokenId, !!signedInUser); const { data: healthData, @@ -69,8 +57,7 @@ export function AuthenticatePage() { } = useQuery({ queryKey: ['auth', 'health'], // Combine the query signal with a hard timeout so an unreachable backend fails deterministically. - queryFn: ({ signal }) => - getAuthHealth({ signal: AbortSignal.any([signal, AbortSignal.timeout(HEALTH_TIMEOUT_MS)]) }), + queryFn: ({ signal }) => getAuthHealth({ signal: AbortSignal.any([signal, AbortSignal.timeout(HEALTH_TIMEOUT_MS)]) }), staleTime: 0, refetchOnMount: 'always', retry: false, @@ -118,7 +105,7 @@ export function AuthenticatePage() { if (isLoading || isHealthLoading || signedIn) { return ( <> - <Spinner className="h-10 w-10" /> + <Spinner className="size-10" /> {showSlowWarning && ( <Alert variant="warning"> <TriangleAlertIcon /> diff --git a/frontend/src/modules/auth/confirm-sign-in-page.tsx b/frontend/src/modules/auth/confirm-sign-in-page.tsx index d8972790b..d09c1bddf 100644 --- a/frontend/src/modules/auth/confirm-sign-in-page.tsx +++ b/frontend/src/modules/auth/confirm-sign-in-page.tsx @@ -25,7 +25,7 @@ export function ConfirmSignInPage() { const { data, isLoading, isError } = useQuery(pendingMagicLinkQueryOptions); - if (isLoading) return <Spinner className="h-10 w-10" />; + if (isLoading) return <Spinner className="size-10" />; if (isError || !data) { return ( @@ -33,7 +33,7 @@ export function ConfirmSignInPage() { <h1 className="text-2xl">{t('c:confirm_sign_in_expired')}</h1> <p className="my-4">{t('c:confirm_sign_in_expired.text')}</p> <Button render={<Link to="/auth/authenticate" replace />}> - <LogInIcon className="mr-2" /> + <LogInIcon /> {t('c:sign_in')} </Button> </div> @@ -46,7 +46,7 @@ export function ConfirmSignInPage() { <p className="my-4">{t('c:confirm_sign_in.text', { email: data.email })}</p> <form method="post" action={`${appConfig.backendAuthUrl}/magic/confirm`}> <Button type="submit" className="w-full"> - <LogInIcon className="mr-2" /> + <LogInIcon /> {t('c:continue_as', { email: data.email })} </Button> </form> diff --git a/frontend/src/modules/auth/email-verification-page.tsx b/frontend/src/modules/auth/email-verification-page.tsx index af89cc0c3..5cb8b87b5 100644 --- a/frontend/src/modules/auth/email-verification-page.tsx +++ b/frontend/src/modules/auth/email-verification-page.tsx @@ -9,14 +9,12 @@ export function EmailVerificationPage() { const { reason } = useParams({ from: '/_public/auth/email-verification/$reason' }); const { provider } = useSearch({ from: '/_public/auth/email-verification/$reason' }); + const reasonText = t(`c:request_verification.${reason}` as TKey, { providerName: provider }); + return ( <div className="text-center"> <h1 className="text-2xl">{t('c:almost_there')}</h1> - <p className="my-4"> - {t('c:request_verification.text', { - reason: t(`c:request_verification.${reason}` as TKey, { providerName: provider }), - })} - </p> + <p className="my-4">{t('c:request_verification.text', { reason: reasonText })}</p> {reason === 'signup' && <LegalNotice mode="verify" />} </div> diff --git a/frontend/src/modules/auth/legal-notice.tsx b/frontend/src/modules/auth/legal-notice.tsx index f4b467f48..201a3bff8 100644 --- a/frontend/src/modules/auth/legal-notice.tsx +++ b/frontend/src/modules/auth/legal-notice.tsx @@ -7,6 +7,7 @@ import { LegalText } from '~/modules/auth/legal/legal-text'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { Spinner } from '~/modules/common/spinner'; import { Button } from '~/modules/ui/button'; +import { tw } from '~/utils/tw'; /** Legal dialog body. Owns the current subject so cross-links can swap terms <-> privacy in place, without navigating to /legal. */ function LegalDialog({ initialSubject }: { initialSubject: LegalSubject }) { @@ -21,7 +22,7 @@ function LegalDialog({ initialSubject }: { initialSubject: LegalSubject }) { return ( <LegalDialogNavProvider value={setSubject}> - <Suspense fallback={<Spinner className="mt-10 h-10 w-10" />}> + <Suspense fallback={<Spinner className="mt-10 size-10" />}> <LegalText subject={subject} /> </Suspense> </LegalDialogNavProvider> @@ -45,7 +46,7 @@ export function LegalNotice({ email = '', mode = 'signup' }: LegalNoticeProps) { id: 'legal', triggerRef, title: t(legalConfig[legalSubject].label), - className: 'md:max-w-4xl p-6', + className: tw('p-6 md:max-w-4xl'), outsideScroll: true, drawerOnMobile: false, }); @@ -53,27 +54,14 @@ export function LegalNotice({ email = '', mode = 'signup' }: LegalNoticeProps) { return ( <p className="space-x-1 text-center"> - {mode === 'signup' && - (email ? <span>{t('c:legal_notice_email.text', { email })}</span> : <span>{t('c:legal_notice.text')}</span>)} + {mode === 'signup' && <span>{email ? t('c:legal_notice_email.text', { email }) : t('c:legal_notice.text')}</span>} {mode === 'waitlist' && <span>{t('c:legal_notice_waitlist.text', { email })}</span>} {mode === 'verify' && <span>{t('c:request_verification.legal_notice')}</span>} - <Button - ref={termsButtonRef} - type="button" - variant="link" - className="h-auto p-0 text-base" - onClick={openDialog('terms', termsButtonRef)} - > + <Button ref={termsButtonRef} type="button" variant="link" className="h-auto p-0 text-base" onClick={openDialog('terms', termsButtonRef)}> {t('c:terms').toLocaleLowerCase()} </Button> <span>&</span> - <Button - ref={privacyButtonRef} - type="button" - variant="link" - className="h-auto p-0 text-base" - onClick={openDialog('privacy', privacyButtonRef)} - > + <Button ref={privacyButtonRef} type="button" variant="link" className="h-auto p-0 text-base" onClick={openDialog('privacy', privacyButtonRef)}> {t('c:privacy_policy').toLocaleLowerCase()} </Button> <span>of {appConfig.company.name}.</span> diff --git a/frontend/src/modules/auth/legal/legal-config.ts b/frontend/src/modules/auth/legal/legal-config.ts index b2132c2c3..916c40cb4 100644 --- a/frontend/src/modules/auth/legal/legal-config.ts +++ b/frontend/src/modules/auth/legal/legal-config.ts @@ -60,20 +60,12 @@ export const collectedData: CollectedDataCategory[] = [ { label: 'User-generated content', description: 'Content you or your organization members create, upload or share through the Service, including:', - items: [ - 'Tasks, projects and workspace data', - 'Attachments (images, files, videos)', - 'Profile information (name, avatar, biography)', - ], + items: ['Tasks, projects and workspace data', 'Attachments (images, files, videos)', 'Profile information (name, avatar, biography)'], }, { label: 'Automatically collected data', description: 'When you interact with the Service we automatically collect:', - items: [ - 'Browser type and version', - 'Anonymized count of pages and data requested', - 'Anonymized IP addresses (for rate limiting and security)', - ], + items: ['Browser type and version', 'Anonymized count of pages and data requested', 'Anonymized IP addresses (for rate limiting and security)'], }, ]; @@ -89,11 +81,7 @@ export const subprocessors: Subprocessor[] = [ categoriesOfPersonalData: ['Contact data', 'Identifiers', 'Customer content and files'], purposes: ['Service hosting and data storage', 'Backup and availability', 'Content delivery'], country: 'France', - dpa: { - signed: true, - effectiveDate: '2022-07-01', - url: 'https://www.scaleway.com/en/terms-and-conditions/data-processing-agreement/', - }, + dpa: { signed: true, effectiveDate: '2022-07-01', url: 'https://www.scaleway.com/en/terms-and-conditions/data-processing-agreement/' }, }, { slug: 'brevo', @@ -105,11 +93,7 @@ export const subprocessors: Subprocessor[] = [ categoriesOfPersonalData: ['Email addresses', 'Names', 'Email content'], purposes: ['Transactional email delivery', 'Email deliverability tracking'], country: 'France', - dpa: { - signed: true, - effectiveDate: '2023-01-01', - url: 'https://www.brevo.com/legal/termsofuse/#data-processing-agreement', - }, + dpa: { signed: true, effectiveDate: '2023-01-01', url: 'https://www.brevo.com/legal/termsofuse/#data-processing-agreement' }, }, { slug: 'transloadit', @@ -121,11 +105,7 @@ export const subprocessors: Subprocessor[] = [ categoriesOfPersonalData: ['Uploaded files', 'File metadata'], purposes: ['File transformation and validation', 'Image processing'], country: 'Germany', - dpa: { - signed: true, - effectiveDate: '2023-01-01', - url: 'https://transloadit.com/legal/dpa/', - }, + dpa: { signed: true, effectiveDate: '2023-01-01', url: 'https://transloadit.com/legal/dpa/' }, optional: true, }, { @@ -138,11 +118,7 @@ export const subprocessors: Subprocessor[] = [ categoriesOfPersonalData: ['Email addresses', 'Support messages', 'Browser and page context'], purposes: ['Customer support', 'Bug reporting and feedback'], country: 'Austria', - dpa: { - signed: true, - effectiveDate: '2023-01-01', - url: 'https://gleap.io/privacy-policy/', - }, + dpa: { signed: true, effectiveDate: '2023-01-01', url: 'https://gleap.io/privacy-policy/' }, optional: true, }, ]; diff --git a/frontend/src/modules/auth/legal/legal-contact.tsx b/frontend/src/modules/auth/legal/legal-contact.tsx index ccc9b8975..c5a1d606e 100644 --- a/frontend/src/modules/auth/legal/legal-contact.tsx +++ b/frontend/src/modules/auth/legal/legal-contact.tsx @@ -13,13 +13,13 @@ export function LegalContact({ addressOnly = false, className }: { addressOnly?: const bankAccount = appConfig.company.bankAccount; return ( - <div className={cn('flex', className)}> + <div className={cn('not-prose flex', className)}> <span className="mr-6 flex flex-col items-center"> <BuildingIcon className="mt-1 shrink-0" /> <span className="mt-1 w-px grow bg-border" /> </span> - <ul className="m-0! list-none pl-0"> - <li className="mt-0! mb-2"> + <ul> + <li className="mb-2"> <strong>{companyFull}</strong> </li> <li>{streetAddress}</li> diff --git a/frontend/src/modules/auth/legal/legal-cross-link.tsx b/frontend/src/modules/auth/legal/legal-cross-link.tsx index a22ba1f3f..f628f9bf8 100644 --- a/frontend/src/modules/auth/legal/legal-cross-link.tsx +++ b/frontend/src/modules/auth/legal/legal-cross-link.tsx @@ -12,11 +12,7 @@ export function LegalCrossLink({ subject, children }: { subject: LegalSubject; c if (navigateInDialog) { return ( - <button - type="button" - className="cursor-pointer font-medium text-primary underline" - onClick={() => navigateInDialog(subject)} - > + <button type="button" className="cursor-pointer font-medium text-primary underline" onClick={() => navigateInDialog(subject)}> {children} </button> ); diff --git a/frontend/src/modules/auth/legal/legal-types.ts b/frontend/src/modules/auth/legal/legal-types.ts index b053f50a6..fe0b63fff 100644 --- a/frontend/src/modules/auth/legal/legal-types.ts +++ b/frontend/src/modules/auth/legal/legal-types.ts @@ -29,11 +29,7 @@ export interface Subprocessor { categoriesOfPersonalData: string[]; purposes: string[]; country: string; - dpa: { - signed: boolean; - effectiveDate: string; - url: string; - }; + dpa: { signed: boolean; effectiveDate: string; url: string }; optional?: boolean; } diff --git a/frontend/src/modules/auth/legal/privacy-text.tsx b/frontend/src/modules/auth/legal/privacy-text.tsx index d9dc8254b..99596b00c 100644 --- a/frontend/src/modules/auth/legal/privacy-text.tsx +++ b/frontend/src/modules/auth/legal/privacy-text.tsx @@ -26,9 +26,8 @@ function PrivacyText() { <LegalSection id={s('introduction').id} label={s('introduction').label}> <p> - {company} ("we", "us", "our") operates {appName} (the "Service"). This Privacy Policy explains how we collect, - use and protect your information. By using the Service you accept this policy and our{' '} - <LegalCrossLink subject="terms">Terms of Use</LegalCrossLink>. + {company} ("we", "us", "our") operates {appName} (the "Service"). This Privacy Policy explains how we collect, use and protect your + information. By using the Service you accept this policy and our <LegalCrossLink subject="terms">Terms of Use</LegalCrossLink>. </p> </LegalSection> @@ -62,8 +61,7 @@ function PrivacyText() { <p>We do not rent or sell personal information. We may share data only in these circumstances:</p> <ul className="my-2"> <li> - <strong>Within your organization</strong>: content you contribute is visible to other members of your - organization. + <strong>Within your organization</strong>: content you contribute is visible to other members of your organization. </li> <li> <strong>Subprocessors</strong>: third-party services that process data on our behalf (listed below). @@ -78,25 +76,21 @@ function PrivacyText() { </LegalSection> <LegalSection id={s('cookies').id} label={s('cookies').label}> - <p> - We use essential cookies for authentication and session management only. We do not use third-party advertising - or tracking cookies. - </p> + <p>We use essential cookies for authentication and session management only. We do not use third-party advertising or tracking cookies.</p> </LegalSection> <LegalSection id={s('data-retention').id} label={s('data-retention').label}> <p> - Account data is retained for as long as your account is active. When you delete your account, personal data is - removed from the Service immediately and permanently deleted from our database within 90 days. Your - organization may retain certain data for legal compliance purposes until they request its removal. + Account data is retained for as long as your account is active. When you delete your account, personal data is removed from the Service + immediately and permanently deleted from our database within 90 days. Your organization may retain certain data for legal compliance + purposes until they request its removal. </p> </LegalSection> <LegalSection id={s('security').id} label={s('security').label}> <p> - We implement industry-standard security measures including encryption in transit (TLS), permission-based - access controls and secure coding practices. However, no method of transmission or storage is 100% secure, and - we cannot guarantee absolute security. Contact us at{' '} + We implement industry-standard security measures including encryption in transit (TLS), permission-based access controls and secure coding + practices. However, no method of transmission or storage is 100% secure, and we cannot guarantee absolute security. Contact us at{' '} <a href={`mailto:${supportEmail}`} target="_blank" rel="noreferrer"> {supportEmail} </a>{' '} @@ -125,9 +119,8 @@ function PrivacyText() { <LegalSection id={s('changes').id} label={s('changes').label}> <p> - We may update this policy from time to time. For material changes we will provide at least two (2) weeks - advance notice via email or an announcement on the Service. Continued use after the notice period constitutes - acceptance. + We may update this policy from time to time. For material changes we will provide at least two (2) weeks advance notice via email or an + announcement on the Service. Continued use after the notice period constitutes acceptance. </p> </LegalSection> diff --git a/frontend/src/modules/auth/legal/terms-text.tsx b/frontend/src/modules/auth/legal/terms-text.tsx index 140bf6e6a..bc3eea0a6 100644 --- a/frontend/src/modules/auth/legal/terms-text.tsx +++ b/frontend/src/modules/auth/legal/terms-text.tsx @@ -25,8 +25,8 @@ function TermsText() { <LegalSection id={s('introduction').id} label={s('introduction').label}> <p> - Welcome to {appName}, operated by {company} ({'"'}we{'"'}, {'"'}us{'"'}, {'"'}our{'"'}). These Terms of Use ( - {'"'}Terms{'"'}) govern your access to and use of the Service available at{' '} + Welcome to {appName}, operated by {company} ({'"'}we{'"'}, {'"'}us{'"'}, {'"'}our{'"'}). These Terms of Use ({'"'}Terms{'"'}) govern your + access to and use of the Service available at{' '} <a href={frontendUrl} target="_blank" rel="noreferrer"> {frontendUrl} </a> @@ -37,29 +37,26 @@ function TermsText() { <LegalSection id={s('agreement').id} label={s('agreement').label}> <p> By accessing or using the Service you agree to be bound by these Terms and our{' '} - <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink>. These Terms form a legally binding - agreement between you and {company}. If you do not agree, do not use the Service. + <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink>. These Terms form a legally binding agreement between you and {company}. + If you do not agree, do not use the Service. </p> <p> - We may update these Terms from time to time. We will notify you of material changes via email or an - announcement on the Service. Changes to pricing or features on the{' '} - <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink> page are always communicated in advance. + We may update these Terms from time to time. We will notify you of material changes via email or an announcement on the Service. Changes to + pricing or features on the <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink> page are always communicated in advance. </p> </LegalSection> <LegalSection id={s('privacy').id} label={s('privacy').label}> <p> - Your use of the Service is also governed by our{' '} - <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink>, which describes how we collect, use and - protect your data. + Your use of the Service is also governed by our <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink>, which describes how we + collect, use and protect your data. </p> </LegalSection> <LegalSection id={s('accounts').id} label={s('accounts').label}> <p> - You must provide accurate and complete registration information. You may not use a name you do not have the - right to use or impersonate another person. Your organization may assign a username on your behalf. You may - not transfer your account without our written consent. + You must provide accurate and complete registration information. You may not use a name you do not have the right to use or impersonate + another person. Your organization may assign a username on your behalf. You may not transfer your account without our written consent. </p> <p>By creating an account you represent that:</p> <ul className="my-2"> @@ -84,38 +81,37 @@ function TermsText() { <h4 className="font-medium">Responsibility</h4> <p> - You are responsible for all content you submit and must ensure you have the necessary rights. We are not - liable for user-generated content. Do not share highly sensitive personal information through the Service. + You are responsible for all content you submit and must ensure you have the necessary rights. We are not liable for user-generated content. + Do not share highly sensitive personal information through the Service. </p> </LegalSection> <LegalSection id={s('intellectual-property').id} label={s('intellectual-property').label}> <p> - The Service and its original content, features and functionality are owned by {company} and protected by - copyright, trademark and other intellectual property laws. + The Service and its original content, features and functionality are owned by {company} and protected by copyright, trademark and other + intellectual property laws. </p> <p> - You retain ownership of content you create. By submitting content to the Service, you grant us a limited - license to store, display and distribute it as necessary to provide the Service. + You retain ownership of content you create. By submitting content to the Service, you grant us a limited license to store, display and + distribute it as necessary to provide the Service. </p> </LegalSection> <LegalSection id={s('service-changes').id} label={s('service-changes').label}> <p> - The Service evolves continuously. We may change, suspend or discontinue any part of the Service, introduce new - features or impose limits. We will try to give notice of material adverse changes. + The Service evolves continuously. We may change, suspend or discontinue any part of the Service, introduce new features or impose limits. We + will try to give notice of material adverse changes. </p> </LegalSection> <LegalSection id={s('termination').id} label={s('termination').label}> <p> - You may delete your account at any time. See our{' '} - <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink> for how we handle your data after - termination. + You may delete your account at any time. See our <LegalCrossLink subject="privacy">Privacy Policy</LegalCrossLink> for how we handle your + data after termination. </p> <p> - We may terminate or suspend your access for any reason, including breach of these Terms. We will try to - provide advance notice so you can retrieve important data, except where impractical, illegal or unsafe. + We may terminate or suspend your access for any reason, including breach of these Terms. We will try to provide advance notice so you can + retrieve important data, except where impractical, illegal or unsafe. </p> <p> If you deleted your account by mistake, contact us at{' '} @@ -125,31 +121,30 @@ function TermsText() { . We will try to help but cannot guarantee recovery. </p> <p> - Provisions that by their nature should survive termination (payment obligations, liability limitations, - intellectual property, dispute resolution) will survive. + Provisions that by their nature should survive termination (payment obligations, liability limitations, intellectual property, dispute + resolution) will survive. </p> </LegalSection> <LegalSection id={s('disclaimers').id} label={s('disclaimers').label}> <p> - The Service is provided {'"'}as is{'"'} and {'"'}as available{'"'} without warranties of any kind, express or - implied, including merchantability, fitness for a particular purpose or non-infringement. We do not guarantee - that the Service will be uninterrupted or error-free. + The Service is provided {'"'}as is{'"'} and {'"'}as available{'"'} without warranties of any kind, express or implied, including + merchantability, fitness for a particular purpose or non-infringement. We do not guarantee that the Service will be uninterrupted or + error-free. </p> </LegalSection> <LegalSection id={s('liability').id} label={s('liability').label}> <p> - To the fullest extent permitted by law, {company} shall not be liable for any indirect, special, incidental or - consequential damages. Our total liability shall not exceed the greater of $100 or the amounts you paid us in - the 12 months preceding the claim. + To the fullest extent permitted by law, {company} shall not be liable for any indirect, special, incidental or consequential damages. Our + total liability shall not exceed the greater of $100 or the amounts you paid us in the 12 months preceding the claim. </p> </LegalSection> <LegalSection id={s('indemnification').id} label={s('indemnification').label}> <p> - You agree to indemnify and hold {company}, its affiliates, officers, agents, employees and partners harmless - from any claims, damages or expenses arising from your use of the Service or violation of these Terms. + You agree to indemnify and hold {company}, its affiliates, officers, agents, employees and partners harmless from any claims, damages or + expenses arising from your use of the Service or violation of these Terms. </p> </LegalSection> @@ -160,9 +155,9 @@ function TermsText() { <LegalSection id={s('general').id} label={s('general').label}> <p> These Terms, together with our Privacy Policy, constitute the entire agreement between you and {company} - regarding the Service. If any provision is found unenforceable, the remaining provisions will continue in full - force. Our failure to enforce any right or provision does not constitute a waiver. You may not assign your - rights under these Terms without our consent. We may assign ours at any time. + regarding the Service. If any provision is found unenforceable, the remaining provisions will continue in full force. Our failure to enforce + any right or provision does not constitute a waiver. You may not assign your rights under these Terms without our consent. We may assign + ours at any time. </p> </LegalSection> </div> diff --git a/frontend/src/modules/auth/mfa-page.tsx b/frontend/src/modules/auth/mfa-page.tsx index 90744c7c0..01ac126ed 100644 --- a/frontend/src/modules/auth/mfa-page.tsx +++ b/frontend/src/modules/auth/mfa-page.tsx @@ -32,7 +32,7 @@ export function MfaPage() { } }; - if (signedIn) return <Spinner className="h-10 w-10" />; + if (signedIn) return <Spinner className="size-10" />; if (!lastUser?.email) { navigate({ to: '/auth/authenticate', replace: true }); diff --git a/frontend/src/modules/auth/oauth-consent-page.tsx b/frontend/src/modules/auth/oauth-consent-page.tsx index 13fb50849..0892051cf 100644 --- a/frontend/src/modules/auth/oauth-consent-page.tsx +++ b/frontend/src/modules/auth/oauth-consent-page.tsx @@ -39,26 +39,16 @@ export function OAuthConsentPage() { // An expired or unknown interaction is an answer, not a wait. if (error && !unauthenticated) return <ErrorNotice error={error as ErrorNoticeError} boundary="public" />; - if (isPending || !data) return <Spinner className="h-10 w-10" />; + if (isPending || !data) return <Spinner className="size-10" />; const { client, scopes, refusal, target } = data; return ( <div className="flex flex-col gap-6"> <div className="flex flex-col items-center gap-3 text-center"> - {client.logoUri && ( - <img - src={client.logoUri} - alt="" - className="h-12 w-12 rounded-md" - loading="lazy" - referrerPolicy="no-referrer" - /> - )} + {client.logoUri && <img src={client.logoUri} alt="" className="size-12 rounded-md" loading="lazy" referrerPolicy="no-referrer" />} <h1 className="text-2xl">{t('c:oauth_consent_header', { name: client.name })}</h1> - <p className="text-muted-foreground text-sm"> - {t('c:oauth_consent.text', { name: client.name, appName: appConfig.name })} - </p> + <p className="text-muted-foreground text-sm">{t('c:oauth_consent.text', { name: client.name, appName: appConfig.name })}</p> {target.tenant && ( <p className="text-sm"> {target.organization @@ -75,9 +65,7 @@ export function OAuthConsentPage() { {refusal ? ( <p className="text-center text-destructive text-sm">{t(refusalLabels[refusal])}</p> ) : ( - <p className="text-center text-muted-foreground text-xs"> - {t('c:oauth_consent_user.text', { name: data.user.name })} - </p> + <p className="text-center text-muted-foreground text-xs">{t('c:oauth_consent_user.text', { name: data.user.name })}</p> )} <div className="flex justify-center gap-3"> diff --git a/frontend/src/modules/auth/oauth-consent-query.ts b/frontend/src/modules/auth/oauth-consent-query.ts index 3494b6840..529029cbe 100644 --- a/frontend/src/modules/auth/oauth-consent-query.ts +++ b/frontend/src/modules/auth/oauth-consent-query.ts @@ -3,10 +3,7 @@ import type { ApiError } from '~/lib/api'; import { decideConsent, getConsentDetails } from '~/lib/oauth-interaction'; import { withStepUp } from '~/modules/auth/step-up'; -export const consentKeys = { - details: (uid: string) => ['oauth-consent', uid] as const, - decide: ['oauth-consent', 'decide'] as const, -}; +export const consentKeys = { details: (uid: string) => ['oauth-consent', uid] as const, decide: ['oauth-consent', 'decide'] as const }; /** Bound to one interaction, gone when it is answered: never persisted, never retried. */ export const consentDetailsQueryOptions = (uid: string) => diff --git a/frontend/src/modules/auth/oauth-providers.tsx b/frontend/src/modules/auth/oauth-providers.tsx index 8f2d72b7f..d9c988803 100644 --- a/frontend/src/modules/auth/oauth-providers.tsx +++ b/frontend/src/modules/auth/oauth-providers.tsx @@ -65,11 +65,9 @@ export function OAuthProviders({ authStep = 'signIn' }: { authStep: AuthStep }) if (appConfig.enabledOAuthProviders.length < 1) return null; return ( - <div data-mode={mode} className="group flex flex-col space-y-2"> + <div data-mode={mode} className="group flex flex-col gap-2"> {appConfig.enabledOAuthProviders.map((provider) => { - const providerData = mapOAuthProviders.find( - (p): p is OAuthProvider & { id: typeof provider } => p.id === provider, - ); + const providerData = mapOAuthProviders.find((p): p is OAuthProvider & { id: typeof provider } => p.id === provider); if (!providerData) return null; @@ -79,14 +77,13 @@ export function OAuthProviders({ authStep = 'signIn' }: { authStep: AuthStep }) key={provider} type="button" variant="plain" - className="gap-1" onClick={() => authenticateWithProvider(providerData.id)} > <img data-provider={provider} src={`/static/auth/${provider}-icon.svg`} alt={provider} - className="mr-1 size-4 data-[provider=github]:group-data-[mode=dark]:invert" + className="size-4 data-[provider=github]:group-data-[mode=dark]:invert" loading="lazy" /> <span> diff --git a/frontend/src/modules/auth/passkey-credentials.ts b/frontend/src/modules/auth/passkey-credentials.ts index 61b954590..11f231e2d 100644 --- a/frontend/src/modules/auth/passkey-credentials.ts +++ b/frontend/src/modules/auth/passkey-credentials.ts @@ -19,35 +19,20 @@ const relyingPartyId = appConfig.mode === 'development' ? 'localhost' : appConfi export const isConditionalMediationAvailable = (): Promise<boolean> => browserSupportsWebAuthnAutofill(); /** Cancellable passkey autofill over discoverable passkeys: the passkey the user picks names the account. */ -export const startConditionalMediation = async ( - onCredential: (data: ConditionalMediationResult) => void, - signal: AbortSignal, -) => { +export const startConditionalMediation = async (onCredential: (data: ConditionalMediationResult) => void, signal: AbortSignal) => { const { challenge } = await getChallenge({ type: 'authentication' }); - const optionsJSON: PublicKeyCredentialRequestOptionsJSON = { - challenge, - rpId: relyingPartyId, - userVerification: 'required', - allowCredentials: [], - }; + const optionsJSON: PublicKeyCredentialRequestOptionsJSON = { challenge, rpId: relyingPartyId, userVerification: 'required', allowCredentials: [] }; // The ceremony is managed by @simplewebauthn's singleton abort service; forward external aborts signal.addEventListener('abort', () => WebAuthnAbortService.cancelCeremony(), { once: true }); - const assertion = await startAuthentication({ - optionsJSON, - useBrowserAutofill: true, - verifyBrowserAutofillInput: false, - }); + const assertion = await startAuthentication({ optionsJSON, useBrowserAutofill: true, verifyBrowserAutofillInput: false }); onCredential({ assertion, type: 'authentication' }); }; -export type ConditionalMediationResult = { - assertion: AuthenticationResponseJSON; - type: 'authentication'; -}; +export type ConditionalMediationResult = { assertion: AuthenticationResponseJSON; type: 'authentication' }; /** Runs WebAuthn registration and returns the attestation as base64url JSON for the backend. */ export const getPasskeyRegistrationCredential = async () => { @@ -59,32 +44,19 @@ export const getPasskeyRegistrationCredential = async () => { const email = getCurrentUser().email; const generatedName = generatePasskeyName(); - const nameOnDevice = isDevelopment - ? `${email} (${generatedName}) for ${appConfig.name}` - : `${email} (${generatedName})`; + const nameOnDevice = isDevelopment ? `${email} (${generatedName}) for ${appConfig.name}` : `${email} (${generatedName})`; const attestation = await startRegistration({ optionsJSON: { challenge, - user: { - id: userHandle, - name: nameOnDevice, - displayName: nameOnDevice, - }, - rp: { - id: relyingPartyId, - name: appConfig.name, - }, + user: { id: userHandle, name: nameOnDevice, displayName: nameOnDevice }, + rp: { id: relyingPartyId, name: appConfig.name }, pubKeyCredParams: [ { type: 'public-key', alg: -7 }, // ES256 { type: 'public-key', alg: -257 }, // RS256 ], attestation: 'none', - authenticatorSelection: { - authenticatorAttachment: 'platform', - residentKey: 'required', - userVerification: 'required', - }, + authenticatorSelection: { authenticatorAttachment: 'platform', residentKey: 'required', userVerification: 'required' }, }, }); @@ -95,18 +67,12 @@ export const getPasskeyRegistrationCredential = async () => { * Returns the passkey verify credential (assertion plus the challenge type). Only an MFA challenge lists the account's * passkeys; otherwise the browser offers its discoverable ones. */ -export const getPasskeyVerifyCredential = async (query: { - type: Exclude<PasskeyCredentialProps['type'], 'registration'>; -}) => { +export const getPasskeyVerifyCredential = async (query: { type: Exclude<PasskeyCredentialProps['type'], 'registration'> }) => { const { challenge, credentialIds } = await getChallenge(query); + const allowCredentials = credentialIds.map(toAllowCredential); const assertion = await startAuthentication({ - optionsJSON: { - challenge, - rpId: relyingPartyId, - userVerification: 'required', - allowCredentials: credentialIds.map(toAllowCredential), - }, + optionsJSON: { challenge, rpId: relyingPartyId, userVerification: 'required', allowCredentials }, }); return { assertion, ...query }; @@ -116,21 +82,14 @@ export const getPasskeyVerifyCredential = async (query: { export const getPasskeyStepUpCredential = async () => { const { challenge, credentialIds } = await getStepUpPasskeyChallenge(); + const allowCredentials = credentialIds.map(toAllowCredential); + return startAuthentication({ - optionsJSON: { - challenge, - rpId: relyingPartyId, - userVerification: 'required', - allowCredentials: credentialIds.map(toAllowCredential), - }, + optionsJSON: { challenge, rpId: relyingPartyId, userVerification: 'required', allowCredentials }, }); }; -const toAllowCredential = (id: string) => ({ - id, - type: 'public-key' as const, - transports: ['internal' as const], -}); +const toAllowCredential = (id: string) => ({ id, type: 'public-key' as const, transports: ['internal' as const] }); const getChallenge = async (body: PasskeyCredentialProps) => { // Fetch a base64url challenge from BE; it doubles as the WebAuthn JSON options value diff --git a/frontend/src/modules/auth/passkey-strategy.tsx b/frontend/src/modules/auth/passkey-strategy.tsx index cb0012e09..ed0e942bd 100644 --- a/frontend/src/modules/auth/passkey-strategy.tsx +++ b/frontend/src/modules/auth/passkey-strategy.tsx @@ -41,13 +41,8 @@ export function PasskeyStrategy({ type }: PasskeyStrategyProps) { }); return ( - <div data-mode={mode} className="group flex flex-col space-y-2"> - <Button - type="button" - variant={type === 'mfa' ? 'default' : 'plain'} - onClick={() => passkeyAuth()} - className="w-full gap-1.5 truncate" - > + <div data-mode={mode} className="group flex flex-col gap-2"> + <Button type="button" variant={type === 'mfa' ? 'default' : 'plain'} onClick={() => passkeyAuth()} className="w-full gap-1.5 truncate"> <FingerprintPatternIcon /> <span className="truncate"> {t('c:sign_in')} {t('c:with').toLowerCase()} {t('c:passkey').toLowerCase()} diff --git a/frontend/src/modules/auth/redirect-path.ts b/frontend/src/modules/auth/redirect-path.ts index 990b198c3..117e2940c 100644 --- a/frontend/src/modules/auth/redirect-path.ts +++ b/frontend/src/modules/auth/redirect-path.ts @@ -1,5 +1,4 @@ import { toSafeRedirectPath } from 'shared/utils/safe-redirect-path'; /** A redirect target that stays on this origin (the shared redirect rules), or undefined. */ -export const safeRedirectPath = (value: unknown) => - toSafeRedirectPath(value, { origin: window.location.origin }) ?? undefined; +export const safeRedirectPath = (value: unknown) => toSafeRedirectPath(value, { origin: window.location.origin }) ?? undefined; diff --git a/frontend/src/modules/auth/search-params-schemas.ts b/frontend/src/modules/auth/search-params-schemas.ts index da5748ab8..a934a1441 100644 --- a/frontend/src/modules/auth/search-params-schemas.ts +++ b/frontend/src/modules/auth/search-params-schemas.ts @@ -12,9 +12,7 @@ export const authenticateRouteSearchParamsSchema = z.object({ fromRoot: z.boolean().optional(), }); -export const authErrorRouteSearchParamsSchema = z - .object({ tokenId: z.string().optional() }) - .extend(errorSearchSchema.shape); +export const authErrorRouteSearchParamsSchema = z.object({ tokenId: z.string().optional() }).extend(errorSearchSchema.shape); /** The authorization server's interaction id, carried through sign-in and back. */ export const consentRouteSearchParamsSchema = z.object({ uid: z.string() }); diff --git a/frontend/src/modules/auth/step-up-dialog.test.tsx b/frontend/src/modules/auth/step-up-dialog.test.tsx index b4be77306..551c57b38 100644 --- a/frontend/src/modules/auth/step-up-dialog.test.tsx +++ b/frontend/src/modules/auth/step-up-dialog.test.tsx @@ -103,13 +103,9 @@ describe('step-up dialog', () => { openStepUpDialog(['sign_in']).catch(() => {}); const container = document.createElement('div'); root = createRoot(container); - await act(async () => - root?.render(<QueryClientProvider client={new QueryClient()}>{seen.dialog}</QueryClientProvider>), - ); + await act(async () => root?.render(<QueryClientProvider client={new QueryClient()}>{seen.dialog}</QueryClientProvider>)); - const signInAgain = [...container.querySelectorAll('button')].find((button) => - button.textContent?.includes('c:sign_in_again'), - ); + const signInAgain = [...container.querySelectorAll('button')].find((button) => button.textContent?.includes('c:sign_in_again')); await act(async () => signInAgain?.click()); const signIn = `navigated to /auth/authenticate?redirect=${encodeURIComponent('/settings/security?tab=mfa')}`; @@ -117,9 +113,7 @@ describe('step-up dialog', () => { const ended = seen.calls.indexOf('session ended'); expect(ended).toBeGreaterThan(-1); // What needs the session goes while it lasts. - expect(seen.calls.slice(0, ended)).toEqual( - expect.arrayContaining(['push subscription dropped', 'seen marks sent']), - ); + expect(seen.calls.slice(0, ended)).toEqual(expect.arrayContaining(['push subscription dropped', 'seen marks sent'])); // The same person signs in again: their local database stays. expect(seen.calls).toContain('client state cleared (wipe: false)'); }); diff --git a/frontend/src/modules/auth/step-up-dialog.tsx b/frontend/src/modules/auth/step-up-dialog.tsx index 8b4a25033..59fc36f6e 100644 --- a/frontend/src/modules/auth/step-up-dialog.tsx +++ b/frontend/src/modules/auth/step-up-dialog.tsx @@ -85,8 +85,7 @@ function StepUpDialog({ methods, onStepUp }: StepUpDialogProps) { } // An impersonation never steps up: the admin acts as the user, not on how the account is protected. - if (methods.length === 0) - return <p className="text-muted-foreground text-sm">{t('error:impersonation_forbidden.text')}</p>; + if (methods.length === 0) return <p className="text-muted-foreground text-sm">{t('error:impersonation_forbidden.text')}</p>; return ( <div className="flex flex-col gap-2"> diff --git a/frontend/src/modules/auth/step-up-retry.ts b/frontend/src/modules/auth/step-up-retry.ts index 20f305032..8beb1c250 100644 --- a/frontend/src/modules/auth/step-up-retry.ts +++ b/frontend/src/modules/auth/step-up-retry.ts @@ -12,8 +12,7 @@ export class StepUpDismissed extends Error { } /** Whether the server refused an action until the user proves it's them again. */ -export const isStepUpRequired = (error: unknown): error is ApiError => - error instanceof ApiError && error.type === 'step_up_required'; +export const isStepUpRequired = (error: unknown): error is ApiError => error instanceof ApiError && error.type === 'step_up_required'; const methodsOf = (error: ApiError): StepUpMethod[] => { const methods = error.meta?.methods; @@ -25,10 +24,7 @@ const methodsOf = (error: ApiError): StepUpMethod[] => { * user can offer, and once it resolves the action runs one more time. Every other failure, and a rejected `stepUp`, * reaches the caller. */ -export const retryAfterStepUp = async <T>( - action: () => Promise<T>, - stepUp: (methods: StepUpMethod[]) => Promise<void>, -): Promise<T> => { +export const retryAfterStepUp = async <T>(action: () => Promise<T>, stepUp: (methods: StepUpMethod[]) => Promise<void>): Promise<T> => { try { return await action(); } catch (error) { diff --git a/frontend/src/modules/auth/step-up.ts b/frontend/src/modules/auth/step-up.ts index f32b99f73..2915312ea 100644 --- a/frontend/src/modules/auth/step-up.ts +++ b/frontend/src/modules/auth/step-up.ts @@ -2,8 +2,7 @@ import { getStepUp } from 'sdk'; import { retryAfterStepUp, type StepUpMethod } from '~/modules/auth/step-up-retry'; /** The re-auth dialog, loaded on first use so the query modules that step up stay free of UI imports. */ -export const openStepUpDialog = async (methods: StepUpMethod[]) => - (await import('~/modules/auth/step-up-dialog')).openStepUpDialog(methods); +export const openStepUpDialog = async (methods: StepUpMethod[]) => (await import('~/modules/auth/step-up-dialog')).openStepUpDialog(methods); /** * Runs an account-security action; when the server asks the user to prove it's them first, opens the re-auth dialog diff --git a/frontend/src/modules/auth/steps/accept-invitation.tsx b/frontend/src/modules/auth/steps/accept-invitation.tsx index fe62177d6..b041b0368 100644 --- a/frontend/src/modules/auth/steps/accept-invitation.tsx +++ b/frontend/src/modules/auth/steps/accept-invitation.tsx @@ -3,10 +3,11 @@ import { CheckIcon, TriangleAlertIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { appConfig } from 'shared'; import type { TokenData } from '~/modules/auth/types'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; import { useAcceptInvitationTokenMutation } from '~/modules/me/query'; import type { MeUser } from '~/modules/me/types'; import { Alert, AlertDescription, AlertTitle } from '~/modules/ui/alert'; -import { Button, SubmitButton } from '~/modules/ui/button'; +import { Button } from '~/modules/ui/button'; interface Props { tokenData: TokenData; @@ -60,19 +61,12 @@ export function AcceptInvitationStep({ tokenData, user }: Props) { <Alert variant="warning"> <TriangleAlertIcon /> <AlertTitle>{t('c:invite_other_address')}</AlertTitle> - <AlertDescription> - {t('c:invite_other_address.text', { invitedEmail, accountEmail: user.email })} - </AlertDescription> + <AlertDescription>{t('c:invite_other_address.text', { invitedEmail, accountEmail: user.email })}</AlertDescription> </Alert> )} <div className="flex flex-col gap-2"> - <SubmitButton - loading={isPending} - icon={<CheckIcon />} - className="w-full" - onClick={() => accept(undefined, { onSuccess: leave })} - > + <SubmitButton loading={isPending} icon={<CheckIcon />} className="w-full" onClick={() => accept(undefined, { onSuccess: leave })}> {t('c:accept')} </SubmitButton> <Button variant="plain" className="w-full" disabled={isPending} onClick={leave}> diff --git a/frontend/src/modules/auth/steps/check-email.tsx b/frontend/src/modules/auth/steps/check-email.tsx index 7d362fb2c..b871441dd 100644 --- a/frontend/src/modules/auth/steps/check-email.tsx +++ b/frontend/src/modules/auth/steps/check-email.tsx @@ -9,7 +9,7 @@ import { appConfig } from 'shared'; import type { z } from 'zod'; import type { ApiError } from '~/lib/api'; import { useAuthStore } from '~/modules/auth/auth-store'; -import { SubmitButton } from '~/modules/ui/button'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '~/modules/ui/field'; import { Input } from '~/modules/ui/input'; import { defaultOnInvalid } from '~/utils/form-on-invalid'; @@ -29,10 +29,7 @@ export function CheckEmailStep() { const isMobile = window.innerWidth < 640; const title = appConfig.has.selfRegistration ? t('c:sign_in_or_up') : t('c:sign_in'); - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { email: '' }, - }); + const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { email: '' } }); // A browser that never signed in to the address gets the step that does not say whether it has an account. const { mutate: _checkEmail, isPending } = useMutation<CheckEmailResponse, ApiError, CheckEmailData['body']>({ @@ -55,7 +52,7 @@ export function CheckEmailStep() { <h1 className="mt-4 pb-2 text-center text-2xl">{title}</h1> {emailEnabled && ( - <form onSubmit={form.handleSubmit(onSubmit, defaultOnInvalid)} className="space-y-4"> + <form onSubmit={form.handleSubmit(onSubmit, defaultOnInvalid)} className="flex flex-col gap-4"> <FormField control={form.control} name="email" @@ -63,14 +60,7 @@ export function CheckEmailStep() { // Custom css due to html injection by browser extensions <FormItem className="gap-0"> <FormControl> - <Input - {...field} - className="h-12" - type="email" - autoFocus={!isMobile} - autoComplete="email" - placeholder={t('c:email')} - /> + <Input {...field} className="h-12" type="email" autoFocus={!isMobile} autoComplete="email" placeholder={t('c:email')} /> </FormControl> <FormMessage className="mt-2" /> </FormItem> @@ -78,7 +68,7 @@ export function CheckEmailStep() { /> <SubmitButton loading={isPending} className="w-full"> {t('c:continue')} - <ArrowRightIcon className="ml-2" /> + <ArrowRightIcon /> </SubmitButton> </form> )} diff --git a/frontend/src/modules/auth/steps/magic-link-sent.tsx b/frontend/src/modules/auth/steps/magic-link-sent.tsx index a6a487376..609845b5a 100644 --- a/frontend/src/modules/auth/steps/magic-link-sent.tsx +++ b/frontend/src/modules/auth/steps/magic-link-sent.tsx @@ -17,13 +17,11 @@ export function MagicLinkSentStep() { <SuccessCheckmark className="absolute top-[60px] left-1/2 ml-4" /> <MailIcon strokeWidth={1} className="mx-auto size-30 text-foreground" /> <h1 className="text-2xl">{t('c:magic_link_check_email')}</h1> - <p className=""> - {t(isSignup ? 'c:magic_link_check_email.signup.text' : 'c:magic_link_check_email.text', { email })} - </p> + <p>{t(isSignup ? 'c:magic_link_check_email.signup.text' : 'c:magic_link_check_email.text', { email })}</p> {!isSignup && ( <div className="mt-2 flex flex-col gap-2"> <Button type="button" variant="plain" onClick={resetSteps}> - <ArrowLeftIcon className="mr-2" /> + <ArrowLeftIcon /> {t('c:try_another_way')} </Button> </div> diff --git a/frontend/src/modules/auth/steps/sign-in.tsx b/frontend/src/modules/auth/steps/sign-in.tsx index 2011360d4..403f4ee77 100644 --- a/frontend/src/modules/auth/steps/sign-in.tsx +++ b/frontend/src/modules/auth/steps/sign-in.tsx @@ -16,8 +16,9 @@ import type { ConditionalMediationResult } from '~/modules/auth/passkey-credenti import { isConditionalMediationAvailable, startConditionalMediation } from '~/modules/auth/passkey-credentials'; import { PasskeyStrategy } from '~/modules/auth/passkey-strategy'; import { invitationResumePath, useNavigateAfterAuth } from '~/modules/auth/use-post-auth-redirect'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; import { toaster } from '~/modules/common/toaster/toaster'; -import { Button, SubmitButton } from '~/modules/ui/button'; +import { Button } from '~/modules/ui/button'; import { Form, FormControl, FormField, FormItem } from '~/modules/ui/field'; import { Input } from '~/modules/ui/input'; import { useUserStore } from '~/modules/user/user-store'; @@ -32,18 +33,17 @@ type FormValues = z.infer<typeof formSchema>; export function SignInStep() { const { t } = useTranslation(); - const { email, resetSteps, restrictedMode, setStep, setSignedIn, setMagicLinkMode, inviteOtherAccount } = - useAuthStore( - useShallow((state) => ({ - email: state.email, - resetSteps: state.resetSteps, - restrictedMode: state.restrictedMode, - setStep: state.setStep, - setSignedIn: state.setSignedIn, - setMagicLinkMode: state.setMagicLinkMode, - inviteOtherAccount: state.inviteOtherAccount, - })), - ); + const { email, resetSteps, restrictedMode, setStep, setSignedIn, setMagicLinkMode, inviteOtherAccount } = useAuthStore( + useShallow((state) => ({ + email: state.email, + resetSteps: state.resetSteps, + restrictedMode: state.restrictedMode, + setStep: state.setStep, + setSignedIn: state.setSignedIn, + setMagicLinkMode: state.setMagicLinkMode, + inviteOtherAccount: state.inviteOtherAccount, + })), + ); const lastUser = useUserStore((state) => state.lastUser); const clearUserStore = useUserStore((state) => state.reset); @@ -54,10 +54,7 @@ export function SignInStep() { const abortRef = useRef<AbortController | null>(null); const [conditionalMediationSupported, setConditionalMediationSupported] = useState(false); - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { email }, - }); + const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { email } }); useEffect(() => { if (!enabledStrategies.includes('passkey')) return; @@ -95,10 +92,7 @@ export function SignInStep() { const { mutate: sendMagic, isPending: isSending } = useMutation({ // An invitation in hand: the magic link returns here, so it can be confirmed as the account signed in to. - mutationFn: () => - sendMagicLink({ - body: { email: form.getValues('email'), redirect: tokenId ? invitationResumePath(tokenId) : redirect }, - }), + mutationFn: () => sendMagicLink({ body: { email: form.getValues('email'), redirect: tokenId ? invitationResumePath(tokenId) : redirect } }), onSuccess: () => { setMagicLinkMode('signin'); setStep('magicLinkSent', form.getValues('email')); @@ -137,17 +131,12 @@ export function SignInStep() { ) : ( <h1 className="text-center text-2xl"> {getTitle()} <br /> - <AuthEmailButton - email={email} - onClick={resetAuth} - disabled={!!tokenId && !inviteOtherAccount} - className="mt-2" - /> + <AuthEmailButton email={email} onClick={resetAuth} disabled={!!tokenId && !inviteOtherAccount} className="mt-2" /> </h1> )} {(emailEnabled || isMagicLinkEnabled) && ( - <form onSubmit={form.handleSubmit(onSubmit, defaultOnInvalid)} className="mt-0! flex flex-col gap-4"> + <form onSubmit={form.handleSubmit(onSubmit, defaultOnInvalid)} className="flex flex-col gap-4"> <FormField control={form.control} name="email" @@ -168,7 +157,7 @@ export function SignInStep() { )} /> - <SubmitButton loading={isMagicLinkEnabled && isSending} className="w-full gap-2"> + <SubmitButton loading={isMagicLinkEnabled && isSending} className="w-full"> {isMagicLinkEnabled ? ( <> <MailIcon /> @@ -177,7 +166,7 @@ export function SignInStep() { ) : ( <> {t('c:sign_in')} - <ArrowRightIcon className="ml-2" /> + <ArrowRightIcon /> </> )} </SubmitButton> diff --git a/frontend/src/modules/auth/steps/sign-up.tsx b/frontend/src/modules/auth/steps/sign-up.tsx index 12790fbe4..1eaeacf48 100644 --- a/frontend/src/modules/auth/steps/sign-up.tsx +++ b/frontend/src/modules/auth/steps/sign-up.tsx @@ -14,8 +14,9 @@ import { useAuthStore } from '~/modules/auth/auth-store'; import { LegalNotice } from '~/modules/auth/legal-notice'; import type { TokenData } from '~/modules/auth/types'; import { invitationResumePath } from '~/modules/auth/use-post-auth-redirect'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; import { toaster } from '~/modules/common/toaster/toaster'; -import { Button, SubmitButton } from '~/modules/ui/button'; +import { Button } from '~/modules/ui/button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '~/modules/ui/field'; import { Input } from '~/modules/ui/input'; import { defaultOnInvalid } from '~/utils/form-on-invalid'; @@ -30,35 +31,30 @@ type FormValues = z.infer<typeof formSchema>; export function SignUpStep({ tokenData }: { tokenData?: TokenData }) { const { t } = useTranslation(); - const { email, resetSteps, restrictedMode, setStep, setMagicLinkMode, inviteOtherAccount, setInviteOtherAccount } = - useAuthStore( - useShallow((state) => ({ - email: state.email, - resetSteps: state.resetSteps, - restrictedMode: state.restrictedMode, - setStep: state.setStep, - setMagicLinkMode: state.setMagicLinkMode, - inviteOtherAccount: state.inviteOtherAccount, - setInviteOtherAccount: state.setInviteOtherAccount, - })), - ); + const { email, resetSteps, restrictedMode, setStep, setMagicLinkMode, inviteOtherAccount, setInviteOtherAccount } = useAuthStore( + useShallow((state) => ({ + email: state.email, + resetSteps: state.resetSteps, + restrictedMode: state.restrictedMode, + setStep: state.setStep, + setMagicLinkMode: state.setMagicLinkMode, + inviteOtherAccount: state.inviteOtherAccount, + setInviteOtherAccount: state.setInviteOtherAccount, + })), + ); const { redirect, tokenId } = useSearch({ strict: false }); const isMobile = window.innerWidth < 640; - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { email }, - }); + const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { email } }); const { mutate: sendMagic, isPending } = useMutation({ mutationFn: () => { const signUpEmail = form.getValues('email') || email; // Signing up on another address than the invited one: the invitation is not claimed at sign-up, so return to confirm it. const resumeInvitation = tokenId && tokenData && signUpEmail !== tokenData.email; - return sendMagicLink({ - body: { email: signUpEmail, redirect: resumeInvitation ? invitationResumePath(tokenId) : redirect }, - }); + const redirectTo = resumeInvitation ? invitationResumePath(tokenId) : redirect; + return sendMagicLink({ body: { email: signUpEmail, redirect: redirectTo } }); }, onSuccess: () => { setMagicLinkMode('signup'); @@ -90,7 +86,7 @@ export function SignUpStep({ tokenData }: { tokenData?: TokenData }) { <LegalNotice email={email || form.getValues('email')} mode="signup" /> {(emailEnabled || isMagicLinkEnabled) && ( - <form onSubmit={form.handleSubmit(onSubmit, defaultOnInvalid)} className="mt-0! flex flex-col gap-4"> + <form onSubmit={form.handleSubmit(onSubmit, defaultOnInvalid)} className="flex flex-col gap-4"> {restrictedMode && ( <FormField control={form.control} @@ -98,14 +94,7 @@ export function SignUpStep({ tokenData }: { tokenData?: TokenData }) { render={({ field }) => ( <FormItem className="-mb-2 gap-0"> <FormControl> - <Input - {...field} - type="email" - className="h-12" - autoFocus={!isMobile} - autoComplete="email" - placeholder={t('c:email')} - /> + <Input {...field} type="email" className="h-12" autoFocus={!isMobile} autoComplete="email" placeholder={t('c:email')} /> </FormControl> <FormMessage className="mt-2" /> </FormItem> diff --git a/frontend/src/modules/auth/steps/waitlist.tsx b/frontend/src/modules/auth/steps/waitlist.tsx index 398e65298..5673911ac 100644 --- a/frontend/src/modules/auth/steps/waitlist.tsx +++ b/frontend/src/modules/auth/steps/waitlist.tsx @@ -14,7 +14,7 @@ export function WaitlistStep() { return ( <> <div className="text-center text-2xl"> - <h1 className="text-xxl">{t('c:request_access')}</h1> + <h1>{t('c:request_access')}</h1> {email.length > 0 && <AuthEmailButton email={email} onClick={resetSteps} className="mt-2" />} </div> <LegalNotice email={email} mode="waitlist" /> diff --git a/frontend/src/modules/auth/totp-strategy.tsx b/frontend/src/modules/auth/totp-strategy.tsx index 43f9a334f..ae5438384 100644 --- a/frontend/src/modules/auth/totp-strategy.tsx +++ b/frontend/src/modules/auth/totp-strategy.tsx @@ -18,11 +18,7 @@ export function TotpStrategy({ isActive, setIsActive }: { isActive: boolean; set const triggerRef = useRef<HTMLButtonElement | null>(null); - const { mutate: totpSignIn } = useMutation< - SignInWithTotpResponse, - ApiError | Error, - NonNullable<SignInWithTotpData['body']> - >({ + const { mutate: totpSignIn } = useMutation<SignInWithTotpResponse, ApiError | Error, NonNullable<SignInWithTotpData['body']>>({ mutationFn: async (body) => await signInWithTotp({ body }), onSuccess: () => { useAuthStore.getState().setSignedIn(true); @@ -32,15 +28,9 @@ export function TotpStrategy({ isActive, setIsActive }: { isActive: boolean; set }); return ( - <div data-mode={mode} className="group flex flex-col space-y-2"> + <div data-mode={mode} className="group flex flex-col gap-2"> {!isActive && ( - <Button - ref={triggerRef} - type="button" - onClick={() => setIsActive(true)} - variant="plain" - className="w-full gap-1.5 truncate" - > + <Button ref={triggerRef} type="button" onClick={() => setIsActive(true)} variant="plain" className="w-full gap-1.5 truncate"> <SmartphoneIcon /> <span className="truncate"> {t('c:sign_in')} {t('c:with').toLowerCase()} {t('c:authenticator_app').toLowerCase()} @@ -48,9 +38,7 @@ export function TotpStrategy({ isActive, setIsActive }: { isActive: boolean; set </Button> )} - {isActive && ( - <TotpConfirmationForm onSubmit={totpSignIn} onCancel={() => setIsActive(false)} label={t('c:totp_verify')} /> - )} + {isActive && <TotpConfirmationForm onSubmit={totpSignIn} onCancel={() => setIsActive(false)} label={t('c:totp_verify')} />} </div> ); } diff --git a/frontend/src/modules/auth/totp-verify-code-form.tsx b/frontend/src/modules/auth/totp-verify-code-form.tsx index a3af4f3de..febf8f7fd 100644 --- a/frontend/src/modules/auth/totp-verify-code-form.tsx +++ b/frontend/src/modules/auth/totp-verify-code-form.tsx @@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next'; import { zCreateTotpBody } from 'sdk/zod.gen'; import { appConfig } from 'shared'; import type z from 'zod'; -import { Button, SubmitButton } from '~/modules/ui/button'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/field'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; +import { Button } from '~/modules/ui/button'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/field'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '~/modules/ui/totp'; import { defaultOnInvalid } from '~/utils/form-on-invalid'; @@ -22,10 +23,7 @@ interface Props { export function TotpConfirmationForm({ onSubmit, onCancel, label, isPending }: Props) { const { t } = useTranslation(); - const form = useForm<FormValues>({ - resolver: zodResolver(formSchema), - defaultValues: { code: '' }, - }); + const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { code: '' } }); const { isValid, isDirty } = useFormState({ control: form.control }); @@ -44,32 +42,31 @@ export function TotpConfirmationForm({ onSubmit, onCancel, label, isPending }: P <FormField control={form.control} name="code" - render={({ field: { value, ...rest } }) => ( + render={({ field: { value, onChange, onBlur, name, ref } }) => ( <FormItem name="code" className="mb-6"> {label && <FormLabel className="mb-1 justify-center text-center">{label}</FormLabel>} - <FormControl> - <InputOTP - value={value || ''} - {...rest} - autoFocus - disabled={isPending} - inputMode="numeric" - containerClassName="justify-center" - maxLength={appConfig.totp.digits} - > - <InputOTPGroup> - {Array.from({ length: appConfig.totp.digits }).map((_, index) => ( - <InputOTPSlot - // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length OTP slots, never reordered. - key={index} - inputMode="numeric" - index={index} - className="bg-background text-lg sm:h-12 sm:w-10" - /> - ))} - </InputOTPGroup> - </InputOTP> - </FormControl> + {/* OTPField reads the surrounding Field itself, so it binds to the form value directly. */} + <InputOTP + name={name} + value={value || ''} + onValueChange={onChange} + onBlur={onBlur} + disabled={isPending} + length={appConfig.totp.digits} + className="justify-center" + > + <InputOTPGroup> + {Array.from({ length: appConfig.totp.digits }).map((_, index) => ( + <InputOTPSlot + // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length OTP slots, never reordered. + key={index} + ref={index === 0 ? ref : undefined} + autoFocus={index === 0} + className="text-lg sm:h-12 sm:w-10" + /> + ))} + </InputOTPGroup> + </InputOTP> <FormMessage /> </FormItem> )} diff --git a/frontend/src/modules/auth/use-post-auth-redirect.test.ts b/frontend/src/modules/auth/use-post-auth-redirect.test.ts index 8d005e878..6669a5a99 100644 --- a/frontend/src/modules/auth/use-post-auth-redirect.test.ts +++ b/frontend/src/modules/auth/use-post-auth-redirect.test.ts @@ -8,9 +8,7 @@ const { authenticateRouteSearchParamsSchema } = await import('~/modules/auth/sea describe('resolvePostAuthRedirect', () => { it('follows a same-origin deep link with its query and hash (positive control)', () => { - expect(resolvePostAuthRedirect('/acme/organization/members?q=a%26b#row')).toBe( - '/acme/organization/members?q=a%26b#row', - ); + expect(resolvePostAuthRedirect('/acme/organization/members?q=a%26b#row')).toBe('/acme/organization/members?q=a%26b#row'); }); it('prefers the invitation resume path when a token is in hand', () => { diff --git a/frontend/src/modules/common/alerter/alert-store.ts b/frontend/src/modules/common/alerter/alert-store.ts index 389c97d55..98a0b1609 100644 --- a/frontend/src/modules/common/alerter/alert-store.ts +++ b/frontend/src/modules/common/alerter/alert-store.ts @@ -9,12 +9,7 @@ import { awaitRecovery, forceOnline } from '~/query/offline/connectivity'; export type AlertKeys = 'offline' | 'backend_not_ready' | 'maintenance' | 'auth_unavailable'; // Alerts with higher priority should not be overwritten by lower ones -const alertPriority: Record<AlertKeys, number> = { - maintenance: 3, - backend_not_ready: 2, - auth_unavailable: 1, - offline: 0, -}; +const alertPriority: Record<AlertKeys, number> = { maintenance: 3, backend_not_ready: 2, auth_unavailable: 1, offline: 0 }; interface AlertStoreState { alertsSeen: string[]; // Seen alert IDs (to prevent duplicate notifications) @@ -33,10 +28,7 @@ const cancelRecovery = () => { recoveryController = null; }; -const initStore: Pick<AlertStoreState, 'alertsSeen' | 'downAlert'> = { - downAlert: appConfig.maintenance ? 'maintenance' : null, - alertsSeen: [], -}; +const initStore: Pick<AlertStoreState, 'alertsSeen' | 'downAlert'> = { downAlert: appConfig.maintenance ? 'maintenance' : null, alertsSeen: [] }; /** * Store for app-wide alerts and UI specific alerts in `alertsSeen`. @@ -96,10 +88,7 @@ export const useAlertStore = create<AlertStoreState>()( version: 1, name: 'alerts', skipHydration: true, - partialize: (state) => ({ - alertsSeen: state.alertsSeen, - downAlert: state.downAlert, - }), + partialize: (state) => ({ alertsSeen: state.alertsSeen, downAlert: state.downAlert }), storage: createJSONStorage(() => idbKvStorage('alerts')), }, ), diff --git a/frontend/src/modules/common/alerter/down-alert.tsx b/frontend/src/modules/common/alerter/down-alert.tsx index c50d61e28..8d8c040f8 100644 --- a/frontend/src/modules/common/alerter/down-alert.tsx +++ b/frontend/src/modules/common/alerter/down-alert.tsx @@ -26,12 +26,8 @@ const downAlertConfig = { className="max-sm:hidden" i18nKey={i18nKey} components={{ - site_anchor: ( - <button type="button" className="font-semibold underline underline-offset-2" onClick={dismissAlert} /> - ), - retry_anchor: ( - <button type="button" className="font-semibold underline underline-offset-2" onClick={retry} /> - ), + site_anchor: <button type="button" className="font-semibold underline underline-offset-2" onClick={dismissAlert} />, + retry_anchor: <button type="button" className="font-semibold underline underline-offset-2" onClick={retry} />, }} /> ); diff --git a/frontend/src/modules/common/animated-arrow.tsx b/frontend/src/modules/common/animated-arrow.tsx index b128e28eb..844816cfc 100644 --- a/frontend/src/modules/common/animated-arrow.tsx +++ b/frontend/src/modules/common/animated-arrow.tsx @@ -1,14 +1,6 @@ export function AnimatedArrow() { return ( - <svg - className="-mr-1 ml-1.5 stroke-[1.5px]" - fill="none" - stroke="currentColor" - width="12" - height="12" - viewBox="0 0 10 10" - aria-hidden="true" - > + <svg className="-mr-1 ml-1.5 stroke-[1.5px]" fill="none" stroke="currentColor" width="12" height="12" viewBox="0 0 10 10" aria-hidden="true"> <path className="opacity-0 transition group-hover:opacity-100" d="M0 5h7" /> <path className="transition group-hover:translate-x-0.75" d="M1 1l4 4-4 4" /> </svg> diff --git a/frontend/src/modules/common/app/app-content.tsx b/frontend/src/modules/common/app/app-content.tsx index 36fe8008d..658934a40 100644 --- a/frontend/src/modules/common/app/app-content.tsx +++ b/frontend/src/modules/common/app/app-content.tsx @@ -22,7 +22,7 @@ export function AppContent() { id="app-content" className="relative flex min-h-svh min-w-0 flex-1 flex-col max-sm:min-h-[calc(100svh-4rem-env(safe-area-inset-bottom,0px))]" > - <main id="app-content-inner" className="flex flex-1 flex-col focus:outline-none" aria-label="Main Content"> + <main id="app-content-inner" className="focus-view-scope flex flex-1 flex-col focus:outline-hidden" aria-label="Main Content"> <FocusTarget target="content" /> <Alerter mode="app" /> <Outlet /> diff --git a/frontend/src/modules/common/app/app-footer.tsx b/frontend/src/modules/common/app/app-footer.tsx index c68baf58b..33e379e23 100644 --- a/frontend/src/modules/common/app/app-footer.tsx +++ b/frontend/src/modules/common/app/app-footer.tsx @@ -66,11 +66,7 @@ export function AppFooter({ className = '' }) { <div className="flex items-center gap-4"> <UserLanguage /> <div className="mr-1 opacity-20 first:hidden">|</div> - <Link - to="/about" - draggable={false} - className="focus-effect rounded-md transition-transform hover:scale-105 active:translate-y-[.05rem]" - > + <Link to="/about" draggable={false} className="focus-effect active:press rounded-md transition-transform hover:scale-105"> <Logo height={25} /> </Link> <div className="ml-1 opacity-20">|</div> diff --git a/frontend/src/modules/common/app/app-layout.tsx b/frontend/src/modules/common/app/app-layout.tsx index 48f520d2d..20ec261c7 100644 --- a/frontend/src/modules/common/app/app-layout.tsx +++ b/frontend/src/modules/common/app/app-layout.tsx @@ -17,10 +17,7 @@ import { lazyNamed } from '~/utils/lazy-named'; // Renders null until an upload is queued. // Both render null until something opens them, so each loads with that interaction. -const AttachmentDialogHandler = lazyNamed( - () => import('~/modules/attachment/dialog/attachment-dialog-handler'), - 'AttachmentDialogHandler', -); +const AttachmentDialogHandler = lazyNamed(() => import('~/modules/attachment/dialog/attachment-dialog-handler'), 'AttachmentDialogHandler'); const Uploader = lazyNamed(() => import('~/modules/common/uploader/uploader'), 'Uploader'); function AppLayout() { diff --git a/frontend/src/modules/common/app/app-router.tsx b/frontend/src/modules/common/app/app-router.tsx index 4dd596e87..9bc67ce36 100644 --- a/frontend/src/modules/common/app/app-router.tsx +++ b/frontend/src/modules/common/app/app-router.tsx @@ -1,18 +1,19 @@ -import { onlineManager, useIsFetching, useIsRestoring } from '@tanstack/react-query'; +import { useIsRestoring } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; +import { useOnlineManager } from '~/hooks/use-online-manager'; import { PullToRefresh } from '~/modules/common/pull-to-refresh'; -import { Spinner } from '~/modules/common/spinner'; +import { PageSpinner } from '~/modules/common/spinner'; import { queryClient } from '~/query/query-client'; import { router } from '~/routes/router'; /** Waits for the react-query cache to hydrate, so offline router loaders can read getQueryData. */ export function AppRouter() { const isRestoring = useIsRestoring(); - const isOnline = onlineManager.isOnline(); - const fetchingCount = useIsFetching(); + // Subscribed, so pull-to-refresh comes back when the connection does + const isOnline = useOnlineManager(); if (isRestoring && !isOnline) { - return <Spinner className="mt-[45vh] h-12 w-12" />; + return <PageSpinner className="size-12" />; } const handleRefresh = async () => { @@ -22,11 +23,7 @@ export function AppRouter() { return ( <> - <PullToRefresh - onRefresh={() => handleRefresh()} - isFetching={fetchingCount > 0} - isDisabled={isRestoring || !isOnline} - /> + <PullToRefresh onRefresh={() => handleRefresh()} isDisabled={isRestoring || !isOnline} /> <RouterProvider router={router} /> </> ); diff --git a/frontend/src/modules/common/bg-animation/animation.js b/frontend/src/modules/common/bg-animation/animation.js index ee1acafd4..7ea904d39 100644 --- a/frontend/src/modules/common/bg-animation/animation.js +++ b/frontend/src/modules/common/bg-animation/animation.js @@ -211,18 +211,13 @@ const maxCircleRadius = 0.15; const touchDistance = minCircleRadius; // Calculations -const rand = (min_or_max, max) => - min_or_max ? (max ? min_or_max + (max - min_or_max) * Math.random() : min_or_max * Math.random()) : Math.random(); +const rand = (min_or_max, max) => (min_or_max ? (max ? min_or_max + (max - min_or_max) * Math.random() : min_or_max * Math.random()) : Math.random()); const normalize = (v) => { const mag = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); return v.map((e) => e / mag); }; const rand_dir = () => - normalize([ - rand(-1, 1) + rand(-1, 1) + rand(-1, 1), - rand(-1, 1) + rand(-1, 1) + rand(-1, 1), - rand(-1, 1) + rand(-1, 1) + rand(-1, 1), - ]); + normalize([rand(-1, 1) + rand(-1, 1) + rand(-1, 1), rand(-1, 1) + rand(-1, 1) + rand(-1, 1), rand(-1, 1) + rand(-1, 1) + rand(-1, 1)]); const cross = (v1, v2) => [v1[1] * v2[2] - v1[2] * v2[1], v1[2] * v2[0] - v1[0] * v2[2], v1[0] * v2[1] - v1[1] * v2[0]]; const distortion_dot_dir_1 = rand_dir(); const distortion_dot_dir_2 = normalize(cross(distortion_dot_dir_1, rand_dir())); diff --git a/frontend/src/modules/common/bg-animation/bg-animation.tsx b/frontend/src/modules/common/bg-animation/bg-animation.tsx index 477676dc0..4ae456857 100644 --- a/frontend/src/modules/common/bg-animation/bg-animation.tsx +++ b/frontend/src/modules/common/bg-animation/bg-animation.tsx @@ -1,4 +1,4 @@ /** Placeholder background animation component for auth pages. */ export function BgAnimation() { - return <div className="fixed top-0 left-0 h-full w-full bg-loading-placeholder" />; + return <div className="fixed top-0 left-0 h-full w-full" />; } diff --git a/frontend/src/modules/common/blocknote/blocknote-config.ts b/frontend/src/modules/common/blocknote/blocknote-config.ts index 13a2cab1c..8ea30bd94 100644 --- a/frontend/src/modules/common/blocknote/blocknote-config.ts +++ b/frontend/src/modules/common/blocknote/blocknote-config.ts @@ -1,17 +1,8 @@ -import { - BlockNoteSchema, - createCodeBlockSpec, - type Dictionary, - defaultBlockSpecs, - defaultStyleSpecs, -} from '@blocknote/core'; +import { BlockNoteSchema, createCodeBlockSpec, type Dictionary, defaultBlockSpecs, defaultStyleSpecs } from '@blocknote/core'; import type { DefaultSuggestionItem } from '@blocknote/core/extensions'; import { blockTypeSelectItems, type DefaultReactSuggestionItem, getDefaultReactSlashMenuItems } from '@blocknote/react'; import { codeBlockConfig, withAttachmentRef } from 'shared/utils/blocknote-schema-configs'; -import { - checklistItemBlock, - getChecklistSlashItem, -} from '~/modules/common/blocknote/custom-elements/checklist/checklist-item-block'; +import { checklistItemBlock, getChecklistSlashItem } from '~/modules/common/blocknote/custom-elements/checklist/checklist-item-block'; import { MentionSchema } from '~/modules/common/blocknote/custom-elements/mention/mention'; import { getSlashNotifySlashItem, notifyBlock } from '~/modules/common/blocknote/custom-elements/notify/notify-block'; import { baseBlockNoteTypeToKeys } from '~/modules/common/blocknote/type-to-keys'; @@ -21,7 +12,6 @@ import type { CustomBlockTypes, CustomFormatToolBarConfig, SlashIndexedItems, - TitleLevel, } from '~/modules/common/blocknote/types'; // Drop color inline styles so pasted content cannot carry colors that render invisible against the app theme. @@ -52,9 +42,7 @@ const withImageBox = (spec: typeof defaultBlockSpecs.image) => { type ToExternalHTML = NonNullable<typeof spec.implementation.toExternalHTML>; // render/toExternalHTML read a `this` context (blockContentDOMAttributes, propSchema), so keep them methods and forward it. const baseRender = spec.implementation.render as (...args: Parameters<Render>) => ReturnType<Render>; - const baseToExternalHTML = spec.implementation.toExternalHTML as - | ((...args: Parameters<ToExternalHTML>) => ReturnType<ToExternalHTML>) - | undefined; + const baseToExternalHTML = spec.implementation.toExternalHTML as ((...args: Parameters<ToExternalHTML>) => ReturnType<ToExternalHTML>) | undefined; return { ...spec, @@ -90,52 +78,28 @@ export const customSchema = BlockNoteSchema.create({ styleSpecs: safeStyleSpecs inlineContentSpecs: { mention: MentionSchema }, }); -export const customBlockTypeSwitchItems: CustomBlockTypes[] = [ - 'heading', - 'paragraph', - 'bulletListItem', - 'numberedListItem', - 'checklistItem', -]; +export const customBlockTypeSwitchItems: CustomBlockTypes[] = ['heading', 'paragraph', 'bulletListItem', 'numberedListItem', 'checklistItem']; export const getSideMenuItems = (dict: Dictionary) => [...blockTypeSelectItems(dict)]; // Indexed items (max 9 for quick number-based selection) -export const customSlashIndexedItems: SlashIndexedItems = [ - 'image', - 'video', - 'file', - 'bulletListItem', - 'numberedListItem', - 'checklistItem', - 'notify', -]; +export const customSlashIndexedItems: SlashIndexedItems = ['image', 'video', 'file', 'bulletListItem', 'numberedListItem', 'checklistItem', 'notify']; export const getSlashMenuItems = ( editor: CustomBlockNoteEditor, allowedTypes: CustomBlockTypes[], headingLevels: NonNullable<CommonBlockNoteProps['headingLevels']>, - // Forced-title mode: levels at or above the title are reserved for block 0 - titleLevel?: TitleLevel, ): DefaultReactSuggestionItem[] => { - const baseItems = [ - ...getDefaultReactSlashMenuItems(editor), - getSlashNotifySlashItem(editor), - getChecklistSlashItem(editor), - ]; + const baseItems = [...getDefaultReactSlashMenuItems(editor), getSlashNotifySlashItem(editor), getChecklistSlashItem(editor)]; const { heading, ...restTypeToKeys } = { ...baseBlockNoteTypeToKeys }; const filteredHeading = heading.filter((key) => { const match = key.match(/(?:_)?(\d)$/); const level = match ? Number.parseInt(match[1], 10) : 1; - if (titleLevel !== undefined && level <= titleLevel) return false; return headingLevels.includes(level as (typeof headingLevels)[number]); }); - const allowedTypeToKeys = { - ...restTypeToKeys, - heading: filteredHeading, - }; + const allowedTypeToKeys = { ...restTypeToKeys, heading: filteredHeading }; const filteredTypeToKeys = Object.fromEntries( Object.entries(allowedTypeToKeys).filter(([type]) => allowedTypes.includes(type as CustomBlockTypes)), diff --git a/frontend/src/modules/common/blocknote/blocknote-editor.tsx b/frontend/src/modules/common/blocknote/blocknote-editor.tsx index 7dc812c87..131d2447f 100644 --- a/frontend/src/modules/common/blocknote/blocknote-editor.tsx +++ b/frontend/src/modules/common/blocknote/blocknote-editor.tsx @@ -8,14 +8,12 @@ import type { FilePanelProps } from '@blocknote/react'; import { FilePanelController, GridSuggestionMenuController, useCreateBlockNote } from '@blocknote/react'; import { BlockNoteView } from '@blocknote/shadcn'; import { type MouseEventHandler, type RefObject, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; import { appConfig, type ProductEntityType } from 'shared'; import type { WebsocketProvider } from 'y-websocket'; import type { XmlFragment } from 'yjs'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { customSchema } from '~/modules/common/blocknote/blocknote-config'; import { checkedExtension } from '~/modules/common/blocknote/custom-elements/checklist/checklist-extension'; -import { forcedTitleExtension } from '~/modules/common/blocknote/custom-elements/forced-title/forced-title-extension'; import { Mention } from '~/modules/common/blocknote/custom-elements/mention/mention-menu'; import { FilePanelBridge } from '~/modules/common/blocknote/custom-file-panel/file-panel-bridge'; import { useUploadHost } from '~/modules/common/blocknote/custom-file-panel/upload-host'; @@ -43,6 +41,7 @@ import type { } from '~/modules/common/blocknote/types'; import { useUIStore } from '~/modules/ui/ui-store'; import { getRouter } from '~/routes/-router-instance'; +import { cn } from '~/utils/cn'; /** Yjs connection plus entity identity for SSE suppression; passing this bundle switches the editor into collaborative mode. */ export interface CollaborationBundle { @@ -93,7 +92,7 @@ function BlockNote({ emojis = true, excludeBlockTypes, excludeFileBlockTypes, - forcedTitle = false, + titlePlaceholder, extensions, members, // for mentions filePanel, @@ -110,11 +109,8 @@ function BlockNote({ onFocus, onBeforeLoad, }: BlockNoteProps) { - const { t } = useTranslation(); const mode = useUIStore((state) => state.mode); const isMobile = useBreakpointBelow('sm'); - // Forced-title mode: `true` pins block 0 at level 1; `{ level }` overrides for nested surfaces - const titleLevel = forcedTitle ? (typeof forcedTitle === 'object' ? forcedTitle.level : 1) : undefined; // Set only when an ancestor hoists the upload dialog outside this (possibly remounting) editor. const uploadHost = useUploadHost(); @@ -123,9 +119,7 @@ function BlockNote({ const defaultAllowedBlockTypes = Object.keys(customSchema.blockSpecs) as CustomBlockTypes[]; const allowedBlockTypes = defaultAllowedBlockTypes.filter( - (type) => - !excludeBlockTypes?.includes(type as CustomBlockRegularTypes) && - !excludeFileBlockTypes?.includes(type as CustomBlockFileTypes), + (type) => !excludeBlockTypes?.includes(type as CustomBlockRegularTypes) && !excludeFileBlockTypes?.includes(type as CustomBlockFileTypes), ); // Parse initial content once at creation time so the undo history starts clean @@ -140,12 +134,7 @@ function BlockNote({ trailingBlock, dictionary: getDictionary(), // Caller extensions come first: BlockNote keeps the first extension per key and drops later duplicates. - extensions: [ - ...(extensions ?? []), - ...(titleLevel ? [forcedTitleExtension({ level: titleLevel })] : []), - checkedExtension(), - syntaxHighlighter, - ], + extensions: [...(extensions ?? []), checkedExtension(), syntaxHighlighter], resolveFileUrl: createResolveFileUrl({ baseFilePanelProps }), }; @@ -153,11 +142,7 @@ function BlockNote({ collaboration ? withCollaboration({ ...baseOptions, - collaboration: { - fragment: collaboration.fragment, - user: collaboration.user, - provider: collaboration.provider, - }, + collaboration: { fragment: collaboration.fragment, user: collaboration.user, provider: collaboration.provider }, }) : baseOptions, ); @@ -171,12 +156,7 @@ function BlockNote({ // Must match the collapsed summary source in deriveDescriptionProps. const doc = editor.document as CustomBlock[]; const summaryBlock = - doc.find( - (b) => - b.type !== 'checklistItem' && - Array.isArray(b.content) && - b.content.some((c) => 'text' in c && !!c.text.trim()), - ) ?? doc[0]; + doc.find((b) => b.type !== 'checklistItem' && Array.isArray(b.content) && b.content.some((c) => 'text' in c && !!c.text.trim())) ?? doc[0]; if (summaryBlock) editor.setTextCursorPosition(summaryBlock, 'end'); }, placeCursorAtPoint: (clientX, clientY) => { @@ -207,9 +187,7 @@ function BlockNote({ useYjsUndoManagerFix(editor, collaborative); - useYjsSseSuppression( - collaboration ? { entityType: collaboration.entityType, entityId: collaboration.entityId } : null, - ); + useYjsSseSuppression(collaboration ? { entityType: collaboration.entityType, entityId: collaboration.entityId } : null); const checkUntrustedMedia = useUntrustedMediaWarning({ organizationId: baseFilePanelProps?.organizationId }); @@ -232,12 +210,7 @@ function BlockNote({ handleUpdateData(editor); }; - const handleKeyDown = useEditorKeyboard({ - editor, - onEscapeClick, - onEnterClick, - commit: commitDocument, - }); + const handleKeyDown = useEditorKeyboard({ editor, onEscapeClick, onEnterClick, commit: commitDocument }); // A host dismissed by an outside press (a sheet) unmounts the editor while it still has focus, so // no blur fires; the cleanup commits what blur would have. Standalone only: the relay owns @@ -292,9 +265,9 @@ function BlockNote({ editable={editable} autoFocus={autoFocus} ref={blockNoteRef} - className={`${dense ? 'bn-dense' : ''} ${titleLevel ? 'bn-forced-title' : ''} ${className}`} - // Forced-title placeholder text rides a CSS var so it stays translatable (styles.css) - {...(titleLevel && { style: { '--bn-title-placeholder': `"${t('c:title')}"` } as React.CSSProperties })} + className={cn(dense && 'bn-dense', titlePlaceholder && 'bn-title-placeholder', className)} + // The block-0 title placeholder rides a CSS var: BlockNote's own placeholders are per block type (styles.css) + {...(titlePlaceholder && { style: { '--bn-title-placeholder': JSON.stringify(titlePlaceholder) } as React.CSSProperties })} data-color-scheme={mode} shadCNComponents={shadCNComponents} sideMenu={false} @@ -308,27 +281,11 @@ function BlockNote({ onBlur={handleBlur} {...(commitOnEveryChange && { onChange: handleUpdateData })} > - {slashMenu && ( - <CustomSlashMenu - editor={editor} - allowedTypes={allowedBlockTypes} - headingLevels={headingLevels} - titleLevel={titleLevel} - /> - )} + {slashMenu && <CustomSlashMenu editor={editor} allowedTypes={allowedBlockTypes} headingLevels={headingLevels} />} - {!isMobile && formattingToolbar && ( - <CustomFormattingToolbar headingLevels={headingLevels} titleLevel={titleLevel} /> - )} + {!isMobile && formattingToolbar && <CustomFormattingToolbar headingLevels={headingLevels} />} - {sideMenu && ( - <CustomSideMenu - editor={editor} - allowedTypes={allowedBlockTypes} - headingLevels={headingLevels} - titleLevel={titleLevel} - /> - )} + {sideMenu && <CustomSideMenu editor={editor} allowedTypes={allowedBlockTypes} headingLevels={headingLevels} />} {/* To avoid rendering "0" */} {members?.length ? <Mention members={members} editor={editor} /> : null} diff --git a/frontend/src/modules/common/blocknote/collaborative-blocknote.tsx b/frontend/src/modules/common/blocknote/collaborative-blocknote.tsx index a4b0104fb..da2b7896f 100644 --- a/frontend/src/modules/common/blocknote/collaborative-blocknote.tsx +++ b/frontend/src/modules/common/blocknote/collaborative-blocknote.tsx @@ -15,10 +15,7 @@ import { getRandomColor } from '~/utils/random-color'; // BlockNote's props are a union (filePanel variants), so Omit must distribute over it type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never; -type PassthroughProps = DistributiveOmit< - ComponentProps<typeof BlockNote>, - 'collaboration' | 'defaultValue' | 'updateData' | 'onBeforeLoad' | 'id' ->; +type PassthroughProps = DistributiveOmit<ComponentProps<typeof BlockNote>, 'collaboration' | 'defaultValue' | 'updateData' | 'onBeforeLoad' | 'id'>; type CollaborativeBlockNoteProps = PassthroughProps & { entityType: ProductEntityType; @@ -60,13 +57,7 @@ export function CollaborativeBlockNote({ const isOnline = useOnlineManager(); const wantsCollaboration = appConfig.services.yjs.enabled && !!appConfig.yjsUrl && isOnline && canEdit; // The token names this entity only; the relay closes the socket when it expires, and the refreshed token reconnects it. - const { token: yjsToken, refused } = useYjsToken({ - entityType, - entityId, - tenantId, - organizationId, - enabled: wantsCollaboration, - }); + const { token: yjsToken, refused } = useYjsToken({ entityType, entityId, tenantId, organizationId, enabled: wantsCollaboration }); const canCollaborate = wantsCollaboration && !!yjsToken; // Once collaborative, hold the connection across an offline blip: releasing it lets the grace period destroy the shared doc under a mounted editor. @@ -121,7 +112,7 @@ export function CollaborativeBlockNote({ // A reseeded document syncs afresh: the editor comes back on the new fragment once it did. const rebuilding = collaborative && !wsReady; - if (waitingForSync || rebuilding) return waitingFallback ?? <Spinner className="my-8 h-6 w-6 opacity-50" />; + if (waitingForSync || rebuilding) return waitingFallback ?? <Spinner className="my-8 size-6 opacity-50" />; const uploadHostProps = blockNoteProps.baseFilePanelProps; @@ -156,9 +147,5 @@ export function CollaborativeBlockNote({ ); // The upload dialog renders above the editor so it survives an editor remount. - return uploadHostProps ? ( - <UploadHostProvider baseFilePanelProps={uploadHostProps}>{editor}</UploadHostProvider> - ) : ( - editor - ); + return uploadHostProps ? <UploadHostProvider baseFilePanelProps={uploadHostProps}>{editor}</UploadHostProvider> : editor; } diff --git a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-enter.test.ts b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-enter.test.ts index d8295ab2f..4f1bff5f8 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-enter.test.ts +++ b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-enter.test.ts @@ -4,8 +4,7 @@ import { customSchema } from '~/modules/common/blocknote/blocknote-config'; import { checkedExtension } from '~/modules/common/blocknote/custom-elements/checklist/checklist-extension'; import { handleChecklistItemEnter } from '~/modules/common/blocknote/custom-elements/checklist/checklist-item-block'; -const makeEditor = () => - BlockNoteEditor.create({ schema: customSchema, _headless: true, extensions: [checkedExtension()] }); +const makeEditor = () => BlockNoteEditor.create({ schema: customSchema, _headless: true, extensions: [checkedExtension()] }); describe('handleChecklistItemEnter', () => { // The handler defers caret placement with setTimeout; fake timers keep that DOM-only work off a torn-down headless editor. @@ -14,9 +13,7 @@ describe('handleChecklistItemEnter', () => { it('splits a non-empty checklist item into two items with distinct checkboxIds', () => { const editor = makeEditor(); - editor.replaceBlocks(editor.document, [ - { type: 'checklistItem', props: { checkboxId: 'first' }, content: 'hello' }, - ]); + editor.replaceBlocks(editor.document, [{ type: 'checklistItem', props: { checkboxId: 'first' }, content: 'hello' }]); editor.setTextCursorPosition(editor.document[0], 'end'); const handled = handleChecklistItemEnter(editor); diff --git a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-extension.ts b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-extension.ts index aa8065dbe..9ff41e1bd 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-extension.ts +++ b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-extension.ts @@ -3,11 +3,7 @@ import { createExtension, createStore, type ExtensionOptions } from '@blocknote/ type CheckedExtensionOptions = { persisted?: boolean }; /** Extends BlockNote list items with checklist state. */ -export const checkedExtension = createExtension( - ({ options }: ExtensionOptions<CheckedExtensionOptions | undefined>) => ({ - key: 'checkboxes-state' as const, - store: createStore<{ persisted: boolean }>({ - persisted: options?.persisted ?? false, - }), - }), -); +export const checkedExtension = createExtension(({ options }: ExtensionOptions<CheckedExtensionOptions | undefined>) => ({ + key: 'checkboxes-state' as const, + store: createStore<{ persisted: boolean }>({ persisted: options?.persisted ?? false }), +})); diff --git a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-input-rules.test.ts b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-input-rules.test.ts new file mode 100644 index 000000000..9e41e63bc --- /dev/null +++ b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-input-rules.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { BlockNoteEditor } from '@blocknote/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { customSchema } from '~/modules/common/blocknote/blocknote-config'; +import { checkedExtension } from '~/modules/common/blocknote/custom-elements/checklist/checklist-extension'; + +const mounted: { unmount: () => void }[] = []; + +/** Mounts an editor holding one paragraph with the cursor at its end; input rules and keymaps need a view. */ +const mountWithParagraph = (content: string) => { + const editor = BlockNoteEditor.create({ schema: customSchema, extensions: [checkedExtension()] }); + mounted.push(editor); + editor.mount(document.createElement('div')); + editor.replaceBlocks(editor.document, [{ type: 'paragraph', content }]); + editor.setTextCursorPosition(editor.document[0], 'end'); + return editor; +}; + +/** Types a space after `before`, the way a keystroke reaches the input rules. */ +const typeSpaceAfter = (before: string) => { + const editor = mountWithParagraph(before); + const view = editor.prosemirrorView; + const { from, to } = view.state.selection; + const insert = () => view.state.tr.insertText(' ', from, to); + view.someProp('handleTextInput', (handle) => handle(view, from, to, ' ', insert)); + return editor.document[0]; +}; + +afterEach(() => { + for (const editor of mounted.splice(0)) editor.unmount(); +}); + +describe('checklist input rules', () => { + it('turns "[ ] " into an unchecked checklist item', () => { + const block = typeSpaceAfter('[ ]'); + expect(block.type).toBe('checklistItem'); + expect(block.props).toMatchObject({ checked: false }); + expect((block.props as { checkboxId?: string }).checkboxId).toBeTruthy(); + }); + + it.each(['[x]', '[X]'])('turns "%s " into a checked checklist item', (before) => { + const block = typeSpaceAfter(before); + expect(block.type).toBe('checklistItem'); + expect(block.props).toMatchObject({ checked: true }); + }); + + it('converts the current block with Mod-Shift-9', () => { + const editor = mountWithParagraph('todo'); + const view = editor.prosemirrorView; + // jsdom reports a non-Mac platform, so Mod resolves to Ctrl. + const event = new KeyboardEvent('keydown', { key: '9', ctrlKey: true, shiftKey: true }); + view.someProp('handleKeyDown', (handle) => handle(view, event)); + + expect(editor.document[0].type).toBe('checklistItem'); + expect(JSON.stringify(editor.document[0].content)).toContain('todo'); + }); +}); diff --git a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-block.tsx b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-block.tsx index afbcaaf58..38e0b9f6f 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-block.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-block.tsx @@ -6,6 +6,7 @@ import { checklistItemConfig } from 'shared/utils/blocknote-schema-configs'; import { nanoid } from 'shared/utils/nanoid'; import { ChecklistItemRender } from '~/modules/common/blocknote/custom-elements/checklist/checklist-item-render'; import type { CustomBlockNoteEditor, IconType } from '~/modules/common/blocknote/types'; +import { cn } from '~/utils/cn'; // A CustomBlockNoteEditor annotation would make customSchema reference itself through this block spec (TS2502 circular type). // biome-ignore lint/suspicious/noExplicitAny: schema-agnostic editor type; see note above @@ -47,11 +48,20 @@ export const handleChecklistItemEnter = (editor: AnyBlockNoteEditor): boolean => return true; }; +/** Turns the block at the cursor into a checklist item; returns false for blocks without inline content. */ +const convertToChecklistItem = (editor: AnyBlockNoteEditor): boolean => { + const { block } = editor.getTextCursorPosition(); + if (editor.schema.blockSchema[block.type].content !== 'inline') return false; + editor.updateBlock(block, { type: 'checklistItem', props: { checkboxId: nanoid(12) } }); + return true; +}; + const checklistExtensions = createExtension({ key: 'checklist-item-shortcuts' as const, - keyboardShortcuts: { - Enter: ({ editor }) => handleChecklistItemEnter(editor), - }, + // The default schema keeps BlockNote's own checkListItem, whose shortcuts and input rules match the same keys + // and text; running first makes them create this block. + runsBefore: ['check-list-item-shortcuts'], + keyboardShortcuts: { Enter: ({ editor }) => handleChecklistItemEnter(editor), 'Mod-Shift-9': ({ editor }) => convertToChecklistItem(editor) }, inputRules: [ { find: /^\s?\[\s*]\s$/, @@ -59,7 +69,7 @@ const checklistExtensions = createExtension({ }, { find: /^\s?\[[Xx]]\s$/, - replace: () => ({ type: 'checklistItem' as const, props: { checkboxId: nanoid(12) } }), + replace: () => ({ type: 'checklistItem' as const, props: { checkboxId: nanoid(12), checked: true } }), }, ], }); @@ -76,15 +86,9 @@ export const checklistItemBlock = createReactBlockSpec( return ( <div className="checklist-item" data-checked={isChecked}> <div contentEditable={false} className="checklist-checkbox-wrapper"> - <input - type="checkbox" - checked={isChecked} - readOnly - data-checkbox-id={block.props.checkboxId} - className="checklist-checkbox" - /> + <input type="checkbox" checked={isChecked} readOnly data-checkbox-id={block.props.checkboxId} className="checklist-checkbox" /> </div> - <p className={`checklist-content ${isChecked ? 'checklist-checked' : ''}`} ref={contentRef} /> + <p className={cn('checklist-content', isChecked && 'checklist-checked')} ref={contentRef} /> </div> ); }, @@ -96,10 +100,7 @@ export const getChecklistSlashItem = (editor: CustomBlockNoteEditor) => ({ title: 'Todos', key: 'checklistItem', onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: 'checklistItem' as const, - props: { checkboxId: nanoid(12) }, - }); + insertOrUpdateBlockForSlashMenu(editor, { type: 'checklistItem' as const, props: { checkboxId: nanoid(12) } }); }, aliases: ['checklist', 'checkbox', 'todo', 'task', 'check', 'todos'], group: 'Basic blocks', diff --git a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-render.tsx b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-render.tsx index f593a17ae..cf9258b03 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-render.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-item-render.tsx @@ -4,6 +4,7 @@ import { nanoid } from 'shared/utils/nanoid'; import { checkedExtension } from '~/modules/common/blocknote/custom-elements/checklist/checklist-extension'; import type { checklistItemConfig } from '~/modules/common/blocknote/custom-elements/checklist/checklist-item-block'; import { updateBlockWithoutHistory } from '~/modules/common/blocknote/helpers/blocknote-helpers'; +import { cn } from '~/utils/cn'; type ChecklistItemRenderProps = ReactCustomBlockRenderProps<typeof checklistItemConfig>; @@ -49,7 +50,7 @@ export function ChecklistItemRender({ block, editor, contentRef }: ChecklistItem className="checklist-checkbox" /> </div> - <p className={`checklist-content ${isChecked ? 'checklist-checked' : ''}`} ref={contentRef} /> + <p className={cn('checklist-content', isChecked && 'checklist-checked')} ref={contentRef} /> </div> ); } diff --git a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-styles.css b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-styles.css index 8dc47d0c0..76c327286 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-styles.css +++ b/frontend/src/modules/common/blocknote/custom-elements/checklist/checklist-styles.css @@ -9,7 +9,7 @@ .checklist-checkbox-wrapper { display: flex; align-items: center; - height: 24px; + height: 1.5rem; flex-shrink: 0; } @@ -48,7 +48,7 @@ align-items: center; justify-content: center; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); - background-size: 10px; + background-size: 0.625rem; background-repeat: no-repeat; background-position: center; } diff --git a/frontend/src/modules/common/blocknote/custom-elements/forced-title/forced-title-extension.ts b/frontend/src/modules/common/blocknote/custom-elements/forced-title/forced-title-extension.ts deleted file mode 100644 index fa3d68a99..000000000 --- a/frontend/src/modules/common/blocknote/custom-elements/forced-title/forced-title-extension.ts +++ /dev/null @@ -1,51 +0,0 @@ -// BlockNote has no document-template support (TypeCellOS/BlockNote#2426), so an appendTransaction normalizer pins the first block to a title heading. -import { createExtension, type ExtensionOptions } from '@blocknote/core'; -import { Plugin, PluginKey } from 'prosemirror-state'; -import type { TitleLevel } from '~/modules/common/blocknote/types'; - -const PLUGIN_KEY = new PluginKey('forced-title'); - -type ForcedTitleOptions = { level?: TitleLevel }; - -/** - * Keeps block 0 a heading at the title level, re-normalizing within the same dispatch so appended transactions join the triggering undo step. - * The layout is `doc > blockGroup > blockContainer+`, so the first block's content node always sits at pos 2. - */ -export const forcedTitleExtension = createExtension(({ options }: ExtensionOptions<ForcedTitleOptions | undefined>) => { - const level = options?.level ?? 1; - return { - key: 'forcedTitle' as const, - prosemirrorPlugins: [ - new Plugin({ - key: PLUGIN_KEY, - appendTransaction: (transactions, _oldState, newState) => { - if (!transactions.some((tr) => tr.docChanged)) return null; - // Never "fix" remote Yjs steps locally: every peer would correct and echo, ping-ponging. - if (transactions.every((tr) => tr.getMeta('y-sync$'))) return null; - - const firstContainer = newState.doc.firstChild?.firstChild; - const content = firstContainer?.firstChild; - if (!content) return null; - - const heading = newState.schema.nodes.heading; - if (content.type === heading && content.attrs.level === level) return null; - - const tr = newState.tr; - if (content.type === heading) { - tr.setNodeMarkup(2, heading, { ...content.attrs, level }); - } else if (content.isTextblock) { - // Attrs of other block types are unknown to heading's spec; defaults fill the rest - tr.setNodeMarkup(2, heading, { level }); - } else { - // Non-text first block (e.g. media dragged to the top): insert an empty title above it - const headingNode = heading.createAndFill({ level }); - const container = headingNode && newState.schema.nodes.blockContainer.createAndFill(null, headingNode); - if (!container) return null; - tr.insert(1, container); - } - return tr; - }, - }), - ], - }; -}); diff --git a/frontend/src/modules/common/blocknote/custom-elements/mention/mention-menu.tsx b/frontend/src/modules/common/blocknote/custom-elements/mention/mention-menu.tsx index a2b562a47..580288b1b 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/mention/mention-menu.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/mention/mention-menu.tsx @@ -8,12 +8,7 @@ export function Mention({ members, editor }: { members?: Member[]; editor: Custo return ( <GridSuggestionMenuController triggerCharacter={'@'} - getItems={async () => - getMentionMenuItems(members, editor).map((item) => ({ - ...item, - title: item.id, - })) - } + getItems={async () => getMentionMenuItems(members, editor).map((item) => ({ ...item, title: item.id }))} columns={2} minQueryLength={0} /> diff --git a/frontend/src/modules/common/blocknote/custom-elements/mention/mention.tsx b/frontend/src/modules/common/blocknote/custom-elements/mention/mention.tsx index f89b4b6d4..fd7abf2bc 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/mention/mention.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/mention/mention.tsx @@ -23,24 +23,12 @@ export const MentionSchema = createReactInlineContentSpec(mentionConfig, { }, }); -export const getMentionMenuItems = ( - members: Member[], - editor: CustomBlockNoteEditor, -): DefaultReactGridSuggestionItem[] => { +export const getMentionMenuItems = (members: Member[], editor: CustomBlockNoteEditor): DefaultReactGridSuggestionItem[] => { return members.map((m) => ({ id: m.id, onItemClick: () => { - editor.insertInlineContent([ - { - type: 'mention', - props: { - name: m.name, - id: m.id, - slug: m.slug, - }, - }, - ]); + editor.insertInlineContent([{ type: 'mention', props: { name: m.name, id: m.id, slug: m.slug } }]); }, - icon: <EntityAvatar type="user" id={m.id} name={m.name} url={m.thumbnailUrl} className="h-5 w-5 text-xs" />, + icon: <EntityAvatar type="user" id={m.id} name={m.name} url={m.thumbnailUrl} className="size-5 text-xs" />, })); }; diff --git a/frontend/src/modules/common/blocknote/custom-elements/notify/notify-block.tsx b/frontend/src/modules/common/blocknote/custom-elements/notify/notify-block.tsx index d37315e0e..228514fb3 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/notify/notify-block.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/notify/notify-block.tsx @@ -14,6 +14,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '~/modules/ui/dropdown-menu'; +import { cn } from '~/utils/cn'; export const notifyBlock = createReactBlockSpec(notifyConfig, { render: ({ block, editor, contentRef }) => { @@ -25,10 +26,7 @@ export const notifyBlock = createReactBlockSpec(notifyConfig, { <DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <DropdownMenuTrigger disabled={!editor.isEditable}> <div className={'notify-icon-wrapper'} contentEditable={false}> - <Icon - className={`notify-icon size-8 ${!editor.isEditable && 'cursor-default'}`} - data-notify-icon-type={block.props.type} - /> + <Icon className={cn('notify-icon size-8', !editor.isEditable && 'cursor-default')} data-notify-icon-type={block.props.type} /> </div> </DropdownMenuTrigger> @@ -61,9 +59,7 @@ const insertSlashNotifyItem = (editor: CustomBlockNoteEditor) => ({ title: 'Notify', key: 'notify', onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: 'notify', - }); + insertOrUpdateBlockForSlashMenu(editor, { type: 'notify' }); }, aliases: ['notify', 'notification', 'emphasize', 'warning', 'error', 'info', 'success'], group: 'Custom', diff --git a/frontend/src/modules/common/blocknote/custom-elements/notify/notify-options.tsx b/frontend/src/modules/common/blocknote/custom-elements/notify/notify-options.tsx index 2b37b2455..d1eb86ea9 100644 --- a/frontend/src/modules/common/blocknote/custom-elements/notify/notify-options.tsx +++ b/frontend/src/modules/common/blocknote/custom-elements/notify/notify-options.tsx @@ -1,44 +1,8 @@ import { CircleAlertIcon, CircleCheckIcon, CircleXIcon, InfoIcon } from 'lucide-react'; export const notifyTypes = [ - { - title: 'Warning', - value: 'warning', - icon: CircleAlertIcon, - color: '#e69819', - backgroundColor: { - light: '#fff6e6', - dark: '#805d20', - }, - }, - { - title: 'Error', - value: 'error', - icon: CircleXIcon, - color: '#d80d0d', - backgroundColor: { - light: '#ffe6e6', - dark: '#802020', - }, - }, - { - title: 'Info', - value: 'info', - icon: InfoIcon, - color: '#507aff', - backgroundColor: { - light: '#e6ebff', - dark: '#203380', - }, - }, - { - title: 'Success', - value: 'success', - icon: CircleCheckIcon, - color: '#0bc10b', - backgroundColor: { - light: '#e6ffe6', - dark: '#208020', - }, - }, + { title: 'Warning', value: 'warning', icon: CircleAlertIcon, color: '#e69819', backgroundColor: { light: '#fff6e6', dark: '#805d20' } }, + { title: 'Error', value: 'error', icon: CircleXIcon, color: '#d80d0d', backgroundColor: { light: '#ffe6e6', dark: '#802020' } }, + { title: 'Info', value: 'info', icon: InfoIcon, color: '#507aff', backgroundColor: { light: '#e6ebff', dark: '#203380' } }, + { title: 'Success', value: 'success', icon: CircleCheckIcon, color: '#0bc10b', backgroundColor: { light: '#e6ffe6', dark: '#208020' } }, ] as const; diff --git a/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.test.ts b/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.test.ts index b24669370..d3f1b23ba 100644 --- a/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.test.ts +++ b/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.test.ts @@ -4,11 +4,7 @@ import { describe, expect, it } from 'vitest'; import { storedFileBlockProps } from '~/modules/common/blocknote/custom-file-panel/file-block-props'; import type { UploadedUppyFile } from '~/modules/common/uploader/types'; -const storedImage = { - url: '/system/admin-1/f1.photo.jpg', - original_name: 'photo.png', - user_meta: { attachmentId: 'm-1' }, -}; +const storedImage = { url: '/system/admin-1/f1.photo.jpg', original_name: 'photo.png', user_meta: { attachmentId: 'm-1' } }; // Test mock: an assembly result carries many more fields; the helper reads only these. const results = { image: [storedImage as unknown as UploadedUppyFile<'newsletter'>['image'][number]] }; diff --git a/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.ts b/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.ts index 235e52f9e..54c65d9d5 100644 --- a/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.ts +++ b/frontend/src/modules/common/blocknote/custom-file-panel/file-block-props.ts @@ -11,11 +11,7 @@ export type FileBlockProps = { name: string; url: string; attachmentId?: string; * An attachment as a block: referenced by id, or by cloud key in a public mode whose upload landed in the public bucket * (images the mid-size preview, other types the converted variant, never the full-size file). */ -export const attachmentBlockProps = ( - attachment: Attachment, - isImage: boolean, - mediaMode: BlockNoteMediaMode, -): FileBlockProps => { +export const attachmentBlockProps = (attachment: Attachment, isImage: boolean, mediaMode: BlockNoteMediaMode): FileBlockProps => { const publicKey = isImage ? attachment.keys.preview || attachment.keys.converted || attachment.keys.original : attachment.keys.converted || attachment.keys.original; @@ -24,10 +20,7 @@ export const attachmentBlockProps = ( }; /** Files a template without an attachment row stored: each block keeps the stored key of the exported image. */ -export const storedFileBlockProps = ( - results: Partial<UploadedUppyFile<UploadTemplateId>>, - templateId: UploadTemplateId, -): FileBlockProps[] => { +export const storedFileBlockProps = (results: Partial<UploadedUppyFile<UploadTemplateId>>, templateId: UploadTemplateId): FileBlockProps[] => { const [exported] = uploadTemplates[templateId].use; return (results[exported] ?? []).map((file) => ({ name: file.original_name ?? file.name ?? '', diff --git a/frontend/src/modules/common/blocknote/custom-file-panel/upload-host.tsx b/frontend/src/modules/common/blocknote/custom-file-panel/upload-host.tsx index ec6fafd3f..01e9ef6b8 100644 --- a/frontend/src/modules/common/blocknote/custom-file-panel/upload-host.tsx +++ b/frontend/src/modules/common/blocknote/custom-file-panel/upload-host.tsx @@ -21,13 +21,7 @@ const UploadHostContext = createContext<UploadHostApi | null>(null); export const useUploadHost = () => useContext(UploadHostContext); /** Owns the Uppy dialog outside the editor subtree so a mid-upload remount cannot tear it down; a `FilePanelBridge` feeds it the active block and live editor. */ -export function UploadHostProvider({ - baseFilePanelProps, - children, -}: { - baseFilePanelProps: BaseUppyFilePanelProps; - children: ReactNode; -}) { +export function UploadHostProvider({ baseFilePanelProps, children }: { baseFilePanelProps: BaseUppyFilePanelProps; children: ReactNode }) { const [activeBlockId, setActiveBlockId] = useState<string | null>(null); const [handle, setHandle] = useState<EditorHandle | null>(null); diff --git a/frontend/src/modules/common/blocknote/custom-file-panel/uppy-upload-panel.tsx b/frontend/src/modules/common/blocknote/custom-file-panel/uppy-upload-panel.tsx index 6700b917e..1cc4dfcce 100644 --- a/frontend/src/modules/common/blocknote/custom-file-panel/uppy-upload-panel.tsx +++ b/frontend/src/modules/common/blocknote/custom-file-panel/uppy-upload-panel.tsx @@ -15,10 +15,7 @@ import { isSystemUploadTemplate } from 'shared/utils/upload-visibility'; import { useOnlineManager } from '~/hooks/use-online-manager'; import { parseUploadedAttachments } from '~/modules/attachment/helpers/parse-uploaded'; import { customSchema } from '~/modules/common/blocknote/blocknote-config'; -import { - attachmentBlockProps, - storedFileBlockProps, -} from '~/modules/common/blocknote/custom-file-panel/file-block-props'; +import { attachmentBlockProps, storedFileBlockProps } from '~/modules/common/blocknote/custom-file-panel/file-block-props'; import { focusEditor } from '~/modules/common/blocknote/helpers/focus'; import type { BaseUppyFilePanelProps, CustomBlockNoteEditor } from '~/modules/common/blocknote/types'; import { Spinner } from '~/modules/common/spinner'; @@ -32,22 +29,10 @@ import { useUIStore } from '~/modules/ui/ui-store'; import '~/modules/common/uploader/uppy-styles'; const basicBlockTypes = { - image: { - allowedFileTypes: ['image/*'], - plugins: ['image-editor', 'screen-capture', 'webcam', 'url'], - }, - video: { - allowedFileTypes: ['video/*'], - plugins: ['screen-capture', 'webcam', 'url'], - }, - audio: { - allowedFileTypes: ['audio/*'], - plugins: ['audio', 'screen-capture', 'webcam', 'url'], - }, - file: { - allowedFileTypes: ['*/*'], - plugins: ['screen-capture', 'webcam', 'url'], - }, + image: { allowedFileTypes: ['image/*'], plugins: ['image-editor', 'screen-capture', 'webcam', 'url'] }, + video: { allowedFileTypes: ['video/*'], plugins: ['screen-capture', 'webcam', 'url'] }, + audio: { allowedFileTypes: ['audio/*'], plugins: ['audio', 'screen-capture', 'webcam', 'url'] }, + file: { allowedFileTypes: ['*/*'], plugins: ['screen-capture', 'webcam', 'url'] }, }; /** Read an image blob's intrinsic pixel size by decoding it locally; returns null when it cannot decode. */ @@ -56,10 +41,7 @@ const measureImageBlobSize = (blob: Blob): Promise<{ width: number; height: numb const url = URL.createObjectURL(blob); const image = new Image(); image.onload = () => { - const size = - image.naturalWidth > 0 && image.naturalHeight > 0 - ? { width: image.naturalWidth, height: image.naturalHeight } - : null; + const size = image.naturalWidth > 0 && image.naturalHeight > 0 ? { width: image.naturalWidth, height: image.naturalHeight } : null; URL.revokeObjectURL(url); resolve(size); }; @@ -111,12 +93,7 @@ export function UppyFilePanel({ }, [blockId]); const uppyOptions: CustomUppyOpt = useMemo( - () => ({ - restrictions: { - ...appConfig.uppy.defaultRestrictions, - allowedFileTypes: basicBlockTypes[blockType].allowedFileTypes, - }, - }), + () => ({ restrictions: { ...appConfig.uppy.defaultRestrictions, allowedFileTypes: basicBlockTypes[blockType].allowedFileTypes } }), [blockType], ); diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-change.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-change.tsx index 70587d876..e8d624c3f 100644 --- a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-change.tsx +++ b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-align-change.tsx @@ -12,13 +12,9 @@ export function CustomTextAlignSelect() { return ( <Components.Generic.Menu.Root portalElement={portalElement}> <Components.Generic.Menu.Trigger> - <Components.FormattingToolbar.Button - className="bn-dropdown-button" - label="Text align select" - mainTooltip="Select text align" - > - <MoveHorizontalIcon className="icon-lg" /> - <ChevronDownIcon className="icon-sm" /> + <Components.FormattingToolbar.Button className="bn-dropdown-button" label="Text align select" mainTooltip="Select text align"> + <MoveHorizontalIcon className="size-5" /> + <ChevronDownIcon className="size-3.5" /> </Components.FormattingToolbar.Button> </Components.Generic.Menu.Trigger> <Components.Generic.Menu.Dropdown> diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-block-type-change.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-block-type-change.tsx index 4382236df..8c11b8199 100644 --- a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-block-type-change.tsx +++ b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/custom-block-type-change.tsx @@ -15,13 +15,7 @@ import { customBlockTypeSwitchItems } from '~/modules/common/blocknote/blocknote import { isHeadingMenuItemActive } from '~/modules/common/blocknote/helpers/header-item-select'; import type { CustomBlockNoteMenuProps } from '~/modules/common/blocknote/types'; -export function CustomBlockTypeSelect({ - headingLevels, - titleLevel, -}: { - headingLevels: CustomBlockNoteMenuProps['headingLevels']; - titleLevel?: CustomBlockNoteMenuProps['titleLevel']; -}) { +export function CustomBlockTypeSelect({ headingLevels }: { headingLevels: CustomBlockNoteMenuProps['headingLevels'] }) { const Components = useComponentsContext()!; const dict = useDictionary(); const portalElement = usePortalElement(); @@ -38,8 +32,6 @@ export function CustomBlockTypeSelect({ if (type === 'heading') { if (props?.isToggleable) return false; if (typeof props?.level === 'number') { - // Forced-title mode: body blocks must not rank at or above the title - if (titleLevel !== undefined && props.level <= titleLevel) return false; return headingLevels.includes(props.level as (typeof headingLevels)[number]); } } @@ -50,9 +42,7 @@ export function CustomBlockTypeSelect({ const selectedItem = filteredItems.find( (el) => - el.type === currentBlock.type && - el.props?.level === currentBlock.props.level && - !!el.props?.isToggleable === currentBlock.props.isToggleable, + el.type === currentBlock.type && el.props?.level === currentBlock.props.level && !!el.props?.isToggleable === currentBlock.props.isToggleable, ); const handleItemClick = (item: BlockTypeSelectItem) => { @@ -89,24 +79,14 @@ export function CustomBlockTypeSelect({ return ( <Components.Generic.Menu.Root portalElement={portalElement}> <Components.Generic.Menu.Trigger> - <Components.FormattingToolbar.Button - className="bn-dropdown-button" - label={selectedItem?.name ?? ''} - mainTooltip="Select block type" - > + <Components.FormattingToolbar.Button className="bn-dropdown-button" label={selectedItem?.name ?? ''} mainTooltip="Select block type"> {selectedItem && <selectedItem.icon />} <ChevronDownIcon /> </Components.FormattingToolbar.Button> </Components.Generic.Menu.Trigger> <Components.Generic.Menu.Dropdown className="p-2"> {fullItems.map(({ title, icon, isSelected, onClick }) => ( - <Components.Generic.Menu.Item - className="bn-menu-item" - key={title} - onClick={onClick} - icon={icon} - checked={isSelected} - > + <Components.Generic.Menu.Item className="bn-menu-item" key={title} onClick={onClick} icon={icon} checked={isSelected}> {title} </Components.Generic.Menu.Item> ))} diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/formatting-toolbar.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/formatting-toolbar.tsx index 3f1ed8572..7e33d7dfa 100644 --- a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/formatting-toolbar.tsx +++ b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/formatting-toolbar.tsx @@ -16,18 +16,10 @@ import { FileOpenPreviewButton } from '~/modules/common/blocknote/custom-formatt import type { CustomBlockNoteMenuProps } from '~/modules/common/blocknote/types'; // Extracted as a named component so hooks (useEffect etc.) are valid -function FormattingToolbarContent({ - headingLevels, - titleLevel, -}: { - headingLevels: CustomBlockNoteMenuProps['headingLevels']; - titleLevel?: CustomBlockNoteMenuProps['titleLevel']; -}) { +function FormattingToolbarContent({ headingLevels }: { headingLevels: CustomBlockNoteMenuProps['headingLevels'] }) { return ( <FormattingToolbar> - {customFormattingToolBarConfig.blockTypeSelect && ( - <CustomBlockTypeSelect headingLevels={headingLevels} titleLevel={titleLevel} /> - )} + {customFormattingToolBarConfig.blockTypeSelect && <CustomBlockTypeSelect headingLevels={headingLevels} />} {customFormattingToolBarConfig.blockStyleSelect && ( <> <BasicTextStyleButton basicTextStyle="bold" /> @@ -58,16 +50,6 @@ function FormattingToolbarContent({ ); } -export function CustomFormattingToolbar({ - headingLevels, - titleLevel, -}: { - headingLevels: CustomBlockNoteMenuProps['headingLevels']; - titleLevel?: CustomBlockNoteMenuProps['titleLevel']; -}) { - return ( - <FormattingToolbarController - formattingToolbar={() => <FormattingToolbarContent headingLevels={headingLevels} titleLevel={titleLevel} />} - /> - ); +export function CustomFormattingToolbar({ headingLevels }: { headingLevels: CustomBlockNoteMenuProps['headingLevels'] }) { + return <FormattingToolbarController formattingToolbar={() => <FormattingToolbarContent headingLevels={headingLevels} />} />; } diff --git a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/open-preview-button.tsx b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/open-preview-button.tsx index 1e3c304dc..32cc3bf72 100644 --- a/frontend/src/modules/common/blocknote/custom-formatting-toolbar/open-preview-button.tsx +++ b/frontend/src/modules/common/blocknote/custom-formatting-toolbar/open-preview-button.tsx @@ -14,9 +14,7 @@ export function FileOpenPreviewButton() { const selectedFileBlock = useMemo(() => { if (selectedBlocks.length !== 1) return null; const block = selectedBlocks[0]; - return block.type === 'file' || block.type === 'image' || block.type === 'video' || block.type === 'audio' - ? block - : null; + return block.type === 'file' || block.type === 'image' || block.type === 'video' || block.type === 'audio' ? block : null; }, [selectedBlocks]); if (!selectedFileBlock) return null; @@ -29,7 +27,7 @@ export function FileOpenPreviewButton() { onClick={() => openAttachment(editor, ref, blockUrl)} mainTooltip={'Open attachment preview'} label={'Open attachment preview'} - icon={<ScalingIcon className="icon-sm" />} + icon={<ScalingIcon className="size-3.5" />} /> ); } diff --git a/frontend/src/modules/common/blocknote/custom-side-menu/reset-block-type.tsx b/frontend/src/modules/common/blocknote/custom-side-menu/reset-block-type.tsx index b96d03ecb..42cba0930 100644 --- a/frontend/src/modules/common/blocknote/custom-side-menu/reset-block-type.tsx +++ b/frontend/src/modules/common/blocknote/custom-side-menu/reset-block-type.tsx @@ -4,22 +4,15 @@ import { useComponentsContext, useDictionary, useExtension, useExtensionState } import { customBlockTypeSwitchItems, getSideMenuItems } from '~/modules/common/blocknote/blocknote-config'; import { focusEditor } from '~/modules/common/blocknote/helpers/focus'; import { isHeadingMenuItemActive } from '~/modules/common/blocknote/helpers/header-item-select'; -import type { - CommonBlockNoteProps, - CustomBlockNoteEditor, - CustomBlockTypes, - TitleLevel, -} from '~/modules/common/blocknote/types'; +import type { CommonBlockNoteProps, CustomBlockNoteEditor, CustomBlockTypes } from '~/modules/common/blocknote/types'; interface ResetBlockTypeItemProp { editor: CustomBlockNoteEditor; allowedTypes: CustomBlockTypes[]; headingLevels: NonNullable<CommonBlockNoteProps['headingLevels']>; - /** Forced-title mode: body blocks must not rank at or above the title. */ - titleLevel?: TitleLevel; } -export function ResetBlockTypeItem({ editor, allowedTypes, headingLevels, titleLevel }: ResetBlockTypeItemProp) { +export function ResetBlockTypeItem({ editor, allowedTypes, headingLevels }: ResetBlockTypeItemProp) { const Components = useComponentsContext()!; const dict = useDictionary(); @@ -37,7 +30,6 @@ export function ResetBlockTypeItem({ editor, allowedTypes, headingLevels, titleL if (item.type === 'heading') { if (item.props?.isToggleable) return false; if (typeof item.props?.level === 'number') { - if (titleLevel !== undefined && item.props.level <= titleLevel) return false; return headingLevels.includes(item.props.level as (typeof headingLevels)[number]); } } @@ -52,10 +44,7 @@ export function ResetBlockTypeItem({ editor, allowedTypes, headingLevels, titleL if (existingBlock) editor.updateBlock(existingBlock, { type: 'paragraph' }); } - editor.updateBlock(block, { - type: item.type as Exclude<CustomBlockTypes, 'emoji'>, - props: item.props, - }); + editor.updateBlock(block, { type: item.type as Exclude<CustomBlockTypes, 'emoji'>, props: item.props }); // Refocus the editor so the open side menu does not block the blur update. setTimeout(() => focusEditor(editor, block.id), 0); }; diff --git a/frontend/src/modules/common/blocknote/custom-side-menu/side-menu.tsx b/frontend/src/modules/common/blocknote/custom-side-menu/side-menu.tsx index 5c8c30c31..a7f210024 100644 --- a/frontend/src/modules/common/blocknote/custom-side-menu/side-menu.tsx +++ b/frontend/src/modules/common/blocknote/custom-side-menu/side-menu.tsx @@ -7,18 +7,13 @@ import { ResetBlockTypeItem } from '~/modules/common/blocknote/custom-side-menu/ import type { CustomBlockNoteMenuProps } from '~/modules/common/blocknote/types'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '~/modules/ui/dropdown-menu'; -export function CustomSideMenu({ editor, allowedTypes, headingLevels, titleLevel }: CustomBlockNoteMenuProps) { +export function CustomSideMenu({ editor, allowedTypes, headingLevels }: CustomBlockNoteMenuProps) { return ( <SideMenuController sideMenu={(props) => { const sideMenu = useExtension(SideMenuExtension); - const block = useExtensionState(SideMenuExtension, { - editor, - selector: (state) => state?.block, - }); + const block = useExtensionState(SideMenuExtension, { editor, selector: (state) => state?.block }); if (block === undefined) return null; - // Forced-title mode: the title block gets no drag handle or type menu (TypeCellOS/BlockNote#709). - if (titleLevel !== undefined && block.id === editor.document[0]?.id) return null; return ( <SideMenu {...props}> <DragHandle @@ -28,7 +23,6 @@ export function CustomSideMenu({ editor, allowedTypes, headingLevels, titleLevel editor={editor} allowedTypes={allowedTypes} headingLevels={headingLevels} - titleLevel={titleLevel} /> </SideMenu> ); @@ -45,7 +39,6 @@ function DragHandle({ editor, allowedTypes, headingLevels, - titleLevel, }: { // biome-ignore lint/suspicious/noExplicitAny: BlockNote extension instance type is not exported sideMenu: any; @@ -55,7 +48,6 @@ function DragHandle({ editor: CustomBlockNoteMenuProps['editor']; allowedTypes: CustomBlockNoteMenuProps['allowedTypes']; headingLevels: CustomBlockNoteMenuProps['headingLevels']; - titleLevel: CustomBlockNoteMenuProps['titleLevel']; }) { const portalElement = usePortalElement(); const [menuOpen, setMenuOpen] = useState(false); @@ -89,7 +81,7 @@ function DragHandle({ <button type="button" draggable - className="bn-button cursor-grab text-gray-400" + className="bn-button cursor-grab text-muted-foreground/70" aria-label="Drag handle" onDragStart={handleDragStart} onDragEnd={handleDragEnd} @@ -113,12 +105,7 @@ function DragHandle({ > <DropdownMenuTrigger render={gripButton} /> <DropdownMenuContent container={portalElement} side="left" className="bn-menu-dropdown bn-drag-handle-menu"> - <ResetBlockTypeItem - editor={editor} - allowedTypes={allowedTypes} - headingLevels={headingLevels} - titleLevel={titleLevel} - /> + <ResetBlockTypeItem editor={editor} allowedTypes={allowedTypes} headingLevels={headingLevels} /> </DropdownMenuContent> </DropdownMenu> ); diff --git a/frontend/src/modules/common/blocknote/custom-slash-menu/custom-slash-menu.tsx b/frontend/src/modules/common/blocknote/custom-slash-menu/custom-slash-menu.tsx index 7cd86c762..7bd132201 100644 --- a/frontend/src/modules/common/blocknote/custom-slash-menu/custom-slash-menu.tsx +++ b/frontend/src/modules/common/blocknote/custom-slash-menu/custom-slash-menu.tsx @@ -25,8 +25,7 @@ export function CustomSlashMenuComponent({ const { key: pressedKey } = e; const itemIndex = Number.parseInt(pressedKey, 10) - 1; - if (items.length !== originalItemCount || Number.isNaN(itemIndex) || itemIndex < 0 || itemIndex >= indexedItemCount) - return; + if (items.length !== originalItemCount || Number.isNaN(itemIndex) || itemIndex < 0 || itemIndex >= indexedItemCount) return; const item = items[itemIndex]; if (!item) return; @@ -56,10 +55,14 @@ export function CustomSlashMenuComponent({ }, [selectedIndex]); return ( - <div className="slash-menu" role="listbox" ref={menuRef}> + <div + className="flex h-fit max-h-[40vh] flex-col overflow-y-auto rounded-lg border-[0.05rem] bg-popover p-1 shadow-[0_0.05rem_0.3rem_0_rgb(0_0_0/0.1)]" + role="listbox" + ref={menuRef} + > {items.map((item, index) => ( <div key={item.title}> - {index === indexedItemCount && items.length === originalItemCount && <hr className="slash-menu-separator" />} + {index === indexedItemCount && items.length === originalItemCount && <hr className="my-1" />} <button ref={(el) => { itemRefs.current[index] = el; @@ -67,7 +70,8 @@ export function CustomSlashMenuComponent({ role="option" type="button" aria-selected={selectedIndex === index} - className="slash-menu-item px-2!" + // BlockNote's shadcn theme resets icons without a size-* class from an unlayered rule, which only `!` outranks. + className="flex h-9 min-w-56 items-center justify-between rounded-sm px-2 text-md hover:bg-accent/60 aria-selected:bg-accent [&_svg]:size-4!" onClick={() => onItemClick?.(item)} tabIndex={-1} > @@ -76,7 +80,7 @@ export function CustomSlashMenuComponent({ {item.title} </div> {items.length === originalItemCount && index < indexedItemCount && ( - <span className="slash-menu-item-badge">{index + 1}</span> + <span className="flex min-w-4 items-center py-0.5 pl-1 text-[0.8rem] text-muted-foreground opacity-50">{index + 1}</span> )} </button> </div> diff --git a/frontend/src/modules/common/blocknote/custom-slash-menu/slash-menu.tsx b/frontend/src/modules/common/blocknote/custom-slash-menu/slash-menu.tsx index 1a349df19..a9d076a35 100644 --- a/frontend/src/modules/common/blocknote/custom-slash-menu/slash-menu.tsx +++ b/frontend/src/modules/common/blocknote/custom-slash-menu/slash-menu.tsx @@ -4,8 +4,8 @@ import { getSlashMenuItems } from '~/modules/common/blocknote/blocknote-config'; import { CustomSlashMenuComponent } from '~/modules/common/blocknote/custom-slash-menu/custom-slash-menu'; import type { CustomBlockNoteMenuProps } from '~/modules/common/blocknote/types'; -export function CustomSlashMenu({ editor, allowedTypes, headingLevels, titleLevel }: CustomBlockNoteMenuProps) { - const slashMenuItems = getSlashMenuItems(editor, allowedTypes, headingLevels, titleLevel); +export function CustomSlashMenu({ editor, allowedTypes, headingLevels }: CustomBlockNoteMenuProps) { + const slashMenuItems = getSlashMenuItems(editor, allowedTypes, headingLevels); return ( <SuggestionMenuController diff --git a/frontend/src/modules/common/blocknote/derive-description-props.test.ts b/frontend/src/modules/common/blocknote/derive-description-props.test.ts index 25ecd55de..633f24c6b 100644 --- a/frontend/src/modules/common/blocknote/derive-description-props.test.ts +++ b/frontend/src/modules/common/blocknote/derive-description-props.test.ts @@ -13,9 +13,7 @@ describe('deriveDescriptionCounts', () => { it('counts checklist items and media blocks depth-first through nested children', () => { const description = JSON.stringify([ block('paragraph', {}, [ - block('checklistItem', { checkboxId: 'a', checked: true }, [ - block('checklistItem', { checkboxId: 'b', checked: false }), - ]), + block('checklistItem', { checkboxId: 'a', checked: true }, [block('checklistItem', { checkboxId: 'b', checked: false })]), block('image', { url: 'https://x/img.png' }), ]), block('paragraph'), diff --git a/frontend/src/modules/common/blocknote/derive-description-props.ts b/frontend/src/modules/common/blocknote/derive-description-props.ts index 4bd22476f..eae07b660 100644 --- a/frontend/src/modules/common/blocknote/derive-description-props.ts +++ b/frontend/src/modules/common/blocknote/derive-description-props.ts @@ -1,36 +1,19 @@ -import { - countDescriptionBlocks, - type DescriptionBlock, - type DescriptionCounts, - emptyDescriptionCounts, - findSummarySource, -} from 'shared/utils/derive-description-core'; +import { type DescriptionCounts, deriveDocument, findSummarySource } from 'shared/utils/derive-description-core'; import { blocksToHTML } from '~/modules/common/blocknote/helpers/blocknote-helpers'; -import type { CustomBlock } from '~/modules/common/blocknote/types'; /** Count-based derived properties, including the referenced attachment ids; the walk is shared with the backend. */ export type DerivedDescriptionCounts = DescriptionCounts; -export type DerivedDescriptionProps = DerivedDescriptionCounts & { - summary: string; - summaryLength: number; -}; +export type DerivedDescriptionProps = DerivedDescriptionCounts & { summary: string; summaryLength: number }; /** Synchronous, so it is safe for optimistic updates in onMutate. */ -export const deriveDescriptionCounts = (description: string): DerivedDescriptionCounts => { - try { - return countDescriptionBlocks(JSON.parse(description) as DescriptionBlock[]); - } catch { - return emptyDescriptionCounts(); - } -}; +export const deriveDescriptionCounts = (description: string): DerivedDescriptionCounts => deriveDocument(description).counts; /** Async because the summary needs HTML conversion. */ export const deriveDescriptionProps = async (description: string): Promise<DerivedDescriptionProps> => { - const blocks = JSON.parse(description) as CustomBlock[]; - const counts = countDescriptionBlocks(blocks as DescriptionBlock[]); + const { blocks, counts } = deriveDocument(description); - const { source, summaryLength } = findSummarySource(blocks as DescriptionBlock[]); + const { source, summaryLength } = findSummarySource(blocks); const html = source ? await blocksToHTML(JSON.stringify([source])) : ''; const summary = html.replace(/^<p[^>]*>(.*)<\/p>$/s, '$1'); diff --git a/frontend/src/modules/common/blocknote/full-html.tsx b/frontend/src/modules/common/blocknote/full-html.tsx index cf2a1729c..e119926db 100644 --- a/frontend/src/modules/common/blocknote/full-html.tsx +++ b/frontend/src/modules/common/blocknote/full-html.tsx @@ -11,17 +11,13 @@ import type { CarouselItemData } from '~/modules/attachment/attachments-carousel import { openAttachmentDialog } from '~/modules/attachment/dialog/open-attachment-dialog'; import { resolveBlockNoteFileRef } from '~/modules/attachment/helpers/resolve-url'; import { customSchema } from '~/modules/common/blocknote/blocknote-config'; -import { - findClickedMedia, - getHeadlessEditor, - getParsedContent, -} from '~/modules/common/blocknote/helpers/blocknote-helpers'; +import { findClickedMedia, getHeadlessEditor, getParsedContent } from '~/modules/common/blocknote/helpers/blocknote-helpers'; import type { CustomBlock } from '~/modules/common/blocknote/types'; import { useUIStore } from '~/modules/ui/ui-store'; +import { cn } from '~/utils/cn'; // DOMPurify's default URI policy strips `blob:`, which this render needs for locally cached images; all other schemes keep the default. -const ALLOWED_URI_REGEXP = - /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i; +const ALLOWED_URI_REGEXP = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i; /** * First-pass HTML (unresolved media refs) per organization and document string. Layout-identical to the @@ -119,12 +115,8 @@ async function processBlocks( const resolvedUrl = await resolveUrl(rawUrl); props = { ...props, url: resolvedUrl }; - media.push({ - id: block.id, - url: resolvedUrl, - filename: ('name' in props ? (props.name as string) : '') || '', - contentType: block.type, - }); + const filename = ('name' in props ? (props.name as string) : '') || ''; + media.push({ id: block.id, url: resolvedUrl, filename, contentType: block.type }); } const children = block.children?.length ? await walk(block.children as CustomBlock[]) : block.children; @@ -220,11 +212,7 @@ function BlockNoteFullHtml({ renderState.mediaItems.findIndex(({ url }) => url === media.src), ); - openAttachmentDialog({ - attachmentIndex, - attachments: renderState.mediaItems, - triggerRef: containerRef as React.RefObject<null>, - }); + openAttachmentDialog({ attachmentIndex, attachments: renderState.mediaItems, triggerRef: containerRef as React.RefObject<null> }); }; // Not `.bn-editor`: BlockNote's side-menu plugin scans those nodes and expects editor-only children such as `.bn-block-group`. @@ -233,7 +221,7 @@ function BlockNoteFullHtml({ id={id} ref={containerRef} role="presentation" - className={`bn-container bn-shadcn ${dense ? 'bn-dense' : ''} ${mode === 'dark' ? 'dark' : ''} ${className}`} + className={cn('bn-container bn-shadcn', dense && 'bn-dense', mode === 'dark' && 'dark', className)} data-color-scheme={mode} onClick={handleClick} > diff --git a/frontend/src/modules/common/blocknote/helpers/blocknote-field-is-dirty.ts b/frontend/src/modules/common/blocknote/helpers/blocknote-field-is-dirty.ts index 4e294c6aa..f1c01d7b8 100644 --- a/frontend/src/modules/common/blocknote/helpers/blocknote-field-is-dirty.ts +++ b/frontend/src/modules/common/blocknote/helpers/blocknote-field-is-dirty.ts @@ -9,15 +9,11 @@ export const blocknoteFieldIsDirty = (strBlocks: string): boolean => { const hasTableContent = type === 'table' && - content?.rows?.some((row) => - row.cells.some((cell) => 'content' in cell && Array.isArray(cell.content) && cell.content.length > 0), - ); + content?.rows?.some((row) => row.cells.some((cell) => 'content' in cell && Array.isArray(cell.content) && cell.content.length > 0)); - const hasMediaProps = - (type === 'audio' || type === 'video' || type === 'file' || type === 'image') && typeof props.name === 'string'; + const hasMediaProps = (type === 'audio' || type === 'video' || type === 'file' || type === 'image') && typeof props.name === 'string'; - const hasChildContent = - children?.some((child) => Array.isArray(child.content) && child.content.length > 0) ?? false; + const hasChildContent = children?.some((child) => Array.isArray(child.content) && child.content.length > 0) ?? false; return hasInlineContent || hasTableContent || hasMediaProps || hasChildContent; }); diff --git a/frontend/src/modules/common/blocknote/helpers/blocknote-helpers.ts b/frontend/src/modules/common/blocknote/helpers/blocknote-helpers.ts index 5a803a11d..26c16a12f 100644 --- a/frontend/src/modules/common/blocknote/helpers/blocknote-helpers.ts +++ b/frontend/src/modules/common/blocknote/helpers/blocknote-helpers.ts @@ -7,11 +7,7 @@ import type { CustomBlock } from '~/modules/common/blocknote/types'; let headlessEditor: ReturnType<typeof BlockNoteEditor.create> | null = null; export const getHeadlessEditor = () => { if (!headlessEditor) { - headlessEditor = BlockNoteEditor.create({ - schema: customSchema, - _headless: true, - extensions: [checkedExtension()], - }); + headlessEditor = BlockNoteEditor.create({ schema: customSchema, _headless: true, extensions: [checkedExtension()] }); } return headlessEditor; }; @@ -27,13 +23,9 @@ export const walkBlocks = (blocks: CustomBlock[], visitor: (block: CustomBlock) }; /** Media element for a click in rendered content, or null. `includeWrapped` also matches nested media and file blocks without a preview. */ -export const findClickedMedia = ( - target: HTMLElement, - { includeWrapped = false } = {}, -): { src: string | undefined } | null => { +export const findClickedMedia = (target: HTMLElement, { includeWrapped = false } = {}): { src: string | undefined } | null => { const mediaElement = - target.closest<HTMLElement>('img, video, audio') ?? - (includeWrapped ? target.querySelector<HTMLElement>('img, video, audio') : null); + target.closest<HTMLElement>('img, video, audio') ?? (includeWrapped ? target.querySelector<HTMLElement>('img, video, audio') : null); const insideFileBlock = includeWrapped && !!target.closest('.bn-file-block-content-wrapper'); if (!mediaElement && !insideFileBlock) return null; @@ -65,12 +57,9 @@ export const copyBlocksToClipboard = async (strBlocks: string | null): Promise<b const markdown = editor.blocksToMarkdownLossy(blocks); const html = editor.blocksToHTMLLossy(blocks); - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/html': new Blob([html], { type: 'text/html' }), - 'text/plain': new Blob([markdown], { type: 'text/plain' }), - }), - ]); + const htmlBlob = new Blob([html], { type: 'text/html' }); + const markdownBlob = new Blob([markdown], { type: 'text/plain' }); + await navigator.clipboard.write([new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': markdownBlob })]); return true; } catch { diff --git a/frontend/src/modules/common/blocknote/helpers/open-attachment.tsx b/frontend/src/modules/common/blocknote/helpers/open-attachment.tsx index e745e169b..06216cb65 100644 --- a/frontend/src/modules/common/blocknote/helpers/open-attachment.tsx +++ b/frontend/src/modules/common/blocknote/helpers/open-attachment.tsx @@ -7,19 +7,14 @@ import type { CustomBlockNoteEditor } from '~/modules/common/blocknote/types'; type MediaBlock = CarouselItemData & { attachmentId?: string }; /** Opens editor media in a carousel starting at the clicked attachment. */ -export const openAttachment = async ( - editor: CustomBlockNoteEditor, - blockNoteRef: React.RefObject<HTMLDivElement | null>, - clickedSrc?: string, -) => { +export const openAttachment = async (editor: CustomBlockNoteEditor, blockNoteRef: React.RefObject<HTMLDivElement | null>, clickedSrc?: string) => { const mediaBlocks: MediaBlock[] = []; editor.forEachBlock(({ id, props, type: contentType }) => { if (!('url' in props) || !props.url) return true; const { url, name } = props as { url: string; name?: string }; - const attachmentId = - 'attachmentId' in props && typeof props.attachmentId === 'string' ? props.attachmentId : undefined; + const attachmentId = 'attachmentId' in props && typeof props.attachmentId === 'string' ? props.attachmentId : undefined; mediaBlocks.push({ id, url, filename: name || '', name: name || '', contentType, attachmentId }); return true; @@ -30,9 +25,7 @@ export const openAttachment = async ( // Inline blocks may reference a thumbnail, so resolve the converted variant by attachment id and fall back to the stored ref. const attachments = await Promise.all( mediaBlocks.map(async ({ attachmentId, ...block }) => { - const fullSize = attachmentId - ? (await resolveAttachmentUrl(attachmentId, null, { preferredVariant: 'converted' }))?.url - : undefined; + const fullSize = attachmentId ? (await resolveAttachmentUrl(attachmentId, null, { preferredVariant: 'converted' }))?.url : undefined; const url = fullSize ?? (editor.resolveFileUrl ? await editor.resolveFileUrl(block.url) : block.url); return { ...block, url }; }), @@ -45,9 +38,5 @@ export const openAttachment = async ( ) : 0; - openAttachmentDialog({ - attachmentIndex, - attachments, - triggerRef: blockNoteRef as React.RefObject<null>, - }); + openAttachmentDialog({ attachmentIndex, attachments, triggerRef: blockNoteRef as React.RefObject<null> }); }; diff --git a/frontend/src/modules/common/blocknote/helpers/resolve-file-url.ts b/frontend/src/modules/common/blocknote/helpers/resolve-file-url.ts index e7118012c..67e215303 100644 --- a/frontend/src/modules/common/blocknote/helpers/resolve-file-url.ts +++ b/frontend/src/modules/common/blocknote/helpers/resolve-file-url.ts @@ -8,8 +8,5 @@ interface ResolveFileUrlContext { /** Supplies the editor's org context to `resolveBlockNoteFileRef` as a fallback for references whose attachment is not cached. */ export function createResolveFileUrl({ baseFilePanelProps }: ResolveFileUrlContext) { return (ref: string): Promise<string> => - resolveBlockNoteFileRef(ref, { - tenantId: baseFilePanelProps?.tenantId, - organizationId: baseFilePanelProps?.organizationId, - }); + resolveBlockNoteFileRef(ref, { tenantId: baseFilePanelProps?.tenantId, organizationId: baseFilePanelProps?.organizationId }); } diff --git a/frontend/src/modules/common/blocknote/helpers/forced-title.test.ts b/frontend/src/modules/common/blocknote/helpers/title-document.test.ts similarity index 65% rename from frontend/src/modules/common/blocknote/helpers/forced-title.test.ts rename to frontend/src/modules/common/blocknote/helpers/title-document.test.ts index 03b169b72..2e9de1bc9 100644 --- a/frontend/src/modules/common/blocknote/helpers/forced-title.test.ts +++ b/frontend/src/modules/common/blocknote/helpers/title-document.test.ts @@ -1,29 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { emptyTitleDocument, splitTitleBlocks, titleFromBlocks } from './forced-title'; +import { emptyTitleDocument, splitTitleBlocks, titleDocumentHasBody } from './title-document'; const text = (t: string) => ({ type: 'text', text: t, styles: {} }); const heading = (t: string, level = 1) => ({ type: 'heading', props: { level }, content: t ? [text(t)] : [] }); const paragraph = (t: string) => ({ type: 'paragraph', props: {}, content: t ? [text(t)] : [] }); -describe('titleFromBlocks', () => { - it('reads the first block text', () => { - expect(titleFromBlocks(JSON.stringify([heading('My item'), paragraph('body')]))).toBe('My item'); - }); - - it('collects nested inline content (links)', () => { - const linked = { - type: 'heading', - props: { level: 1 }, - content: [text('See '), { type: 'link', href: 'https://x', content: [text('this')] }], - }; - expect(titleFromBlocks(JSON.stringify([linked]))).toBe('See this'); - }); - - it('is empty for an empty seed document (any title level) and safe on garbage', () => { - expect(titleFromBlocks(emptyTitleDocument())).toBe(''); - expect(titleFromBlocks(emptyTitleDocument(2))).toBe(''); - expect(titleFromBlocks('not json')).toBe(''); - expect(titleFromBlocks('[]')).toBe(''); +describe('emptyTitleDocument', () => { + it('seeds one empty heading at the given level, with no body', () => { + expect(JSON.parse(emptyTitleDocument(2))).toEqual([heading('', 2)]); + expect(titleDocumentHasBody(emptyTitleDocument())).toBe(false); }); }); @@ -45,6 +30,14 @@ describe('splitTitleBlocks', () => { expect(body).toEqual([image]); }); + it('keeps a media block 0 in the body, since it holds no title', () => { + const image = { type: 'image', props: { url: 'https://x/i.png' } }; + const { name, body } = splitTitleBlocks([image, paragraph('caption'), paragraph('')]); + expect(name).toBe(''); + expect(body).toEqual([image, paragraph('caption')]); + expect(titleDocumentHasBody(JSON.stringify([image]))).toBe(true); + }); + it('empty body yields no blocks; whitespace title trims to empty', () => { const { name, body } = splitTitleBlocks([heading(' '), paragraph('')]); expect(name).toBe(''); diff --git a/frontend/src/modules/common/blocknote/helpers/forced-title.ts b/frontend/src/modules/common/blocknote/helpers/title-document.ts similarity index 56% rename from frontend/src/modules/common/blocknote/helpers/forced-title.ts rename to frontend/src/modules/common/blocknote/helpers/title-document.ts index 6090c43fb..ea87df9bf 100644 --- a/frontend/src/modules/common/blocknote/helpers/forced-title.ts +++ b/frontend/src/modules/common/blocknote/helpers/title-document.ts @@ -1,23 +1,15 @@ -// Forced-title helpers: the entity keeps `name` as stored source of truth while the editor shows `[heading(name), ...body]`. +// Title documents: block 0 of a stored description is its title. Editors seed it from a template and label it with +// `titlePlaceholder`, nothing enforces it, and both sides read the title with `titleFromDocument` (shared/blocknote). +import { getInlineTextFromBlock, parseBlocks } from 'shared/blocknote'; import type { CustomBlock, TitleLevel } from '~/modules/common/blocknote/types'; /** Matches backend maxLength.field (backend/src/db/utils/constraints.ts): name column limit. */ export const TITLE_MAX_LENGTH = 255; -type LooseInlineContent = { type?: string; text?: string; content?: LooseInlineContent[] }; type LooseBlock = { type: string; props?: Record<string, unknown>; content?: unknown; children?: LooseBlock[] }; -/** Plain text of a block's inline content (one nesting level for links etc.). */ -const blockText = (block: LooseBlock | undefined): string => { - if (!block || !Array.isArray(block.content)) return ''; - const collect = (items: LooseInlineContent[]): string => - items.map((item) => item.text ?? (Array.isArray(item.content) ? collect(item.content) : '')).join(''); - return collect(block.content as LooseInlineContent[]); -}; - /** True when a block renders nothing: no text, no children, and not a media/void block. */ -const isEmptyTextBlock = (block: LooseBlock): boolean => - Array.isArray(block.content) && blockText(block).trim() === '' && !block.children?.length; +const isEmptyTextBlock = (block: LooseBlock): boolean => Array.isArray(block.content) && !getInlineTextFromBlock(block) && !block.children?.length; const titleBlock = (name: string, level: TitleLevel) => ({ @@ -32,21 +24,15 @@ export const emptyTitleDocument = (level: TitleLevel = 1) => JSON.stringify([tit /** A stringified title document seeded with `name`, for forms that open pre-titled. */ export const seededTitleDocument = (name: string, level: TitleLevel = 1) => JSON.stringify([titleBlock(name, level)]); -/** Synchronous title read from stringified blocks. */ -export const titleFromBlocks = (strBlocks: string): string => { - try { - const blocks = JSON.parse(strBlocks) as LooseBlock[]; - return blockText(blocks[0]).trim(); - } catch { - return ''; - } -}; - -/** Pure split of parsed blocks: block 0 text → name, the rest (sans trailing empties) → body. */ +/** + * Pure split of parsed blocks: block 0 text → name, the rest (sans trailing empties) → body. A block 0 without + * inline content (an image moved to the top) holds no title, so it stays in the body. + */ export const splitTitleBlocks = (blocks: LooseBlock[]): { name: string; body: LooseBlock[] } => { const [first, ...rest] = blocks; - while (rest.length && isEmptyTextBlock(rest[rest.length - 1])) rest.pop(); - return { name: blockText(first).trim(), body: rest }; + const body = first && !Array.isArray(first.content) ? [first, ...rest] : rest; + while (body.length && isEmptyTextBlock(body[body.length - 1])) body.pop(); + return { name: getInlineTextFromBlock(first), body }; }; /** @@ -61,10 +47,4 @@ export const trimTitleDocument = (strBlocks: string): string => { }; /** True when the document carries more than its title, so a create form can tell an empty body apart. */ -export const titleDocumentHasBody = (strBlocks: string): boolean => { - try { - return splitTitleBlocks(JSON.parse(strBlocks) as LooseBlock[]).body.length > 0; - } catch { - return false; - } -}; +export const titleDocumentHasBody = (strBlocks: string): boolean => splitTitleBlocks(parseBlocks(strBlocks) ?? []).body.length > 0; diff --git a/frontend/src/modules/common/blocknote/hooks/use-editor-keyboard.ts b/frontend/src/modules/common/blocknote/hooks/use-editor-keyboard.ts index b87de4d58..409766e36 100644 --- a/frontend/src/modules/common/blocknote/hooks/use-editor-keyboard.ts +++ b/frontend/src/modules/common/blocknote/hooks/use-editor-keyboard.ts @@ -2,14 +2,7 @@ import type { KeyboardEventHandler } from 'react'; import type { CustomBlockNoteEditor } from '~/modules/common/blocknote/types'; // Hoisted so the map is not rebuilt per keystroke. -const wrappingChars: Record<string, string> = { - '[': ']', - '{': '}', - '(': ')', - '`': '`', - '"': '"', - "'": "'", -}; +const wrappingChars: Record<string, string> = { '[': ']', '{': '}', '(': ')', '`': '`', '"': '"', "'": "'" }; interface UseEditorKeyboardArgs { editor: CustomBlockNoteEditor; @@ -21,12 +14,7 @@ interface UseEditorKeyboardArgs { } /** Handle selection wrapping plus commit-and-close shortcuts without bubbling form submission. */ -export function useEditorKeyboard({ - editor, - onEscapeClick, - onEnterClick, - commit, -}: UseEditorKeyboardArgs): KeyboardEventHandler { +export function useEditorKeyboard({ editor, onEscapeClick, onEnterClick, commit }: UseEditorKeyboardArgs): KeyboardEventHandler { return (event) => { const { metaKey, ctrlKey, key } = event; const isEscape = key === 'Escape'; diff --git a/frontend/src/modules/common/blocknote/hooks/use-smart-blur.ts b/frontend/src/modules/common/blocknote/hooks/use-smart-blur.ts index d75b44248..5560f28a0 100644 --- a/frontend/src/modules/common/blocknote/hooks/use-smart-blur.ts +++ b/frontend/src/modules/common/blocknote/hooks/use-smart-blur.ts @@ -1,9 +1,4 @@ -import { - FilePanelExtension, - FormattingToolbarExtension, - SideMenuExtension, - SuggestionMenu, -} from '@blocknote/core/extensions'; +import { FilePanelExtension, FormattingToolbarExtension, SideMenuExtension, SuggestionMenu } from '@blocknote/core/extensions'; import { useExtension, useExtensionState } from '@blocknote/react'; import type { FocusEventHandler, RefObject } from 'react'; import type { CustomBlockNoteEditor } from '~/modules/common/blocknote/types'; diff --git a/frontend/src/modules/common/blocknote/hooks/use-yjs-token.ts b/frontend/src/modules/common/blocknote/hooks/use-yjs-token.ts index 7a55b0948..390912132 100644 --- a/frontend/src/modules/common/blocknote/hooks/use-yjs-token.ts +++ b/frontend/src/modules/common/blocknote/hooks/use-yjs-token.ts @@ -8,13 +8,7 @@ import { useUserStore, yjsTokenKey } from '~/modules/user/user-store'; * Keeps one entity's Yjs token in the user store, where the non-React connection layer reads it, while `enabled`. * `refused` means the backend will not issue one (no update access, or the entity is gone): edit without the relay. */ -export function useYjsToken(params: { - entityType: ProductEntityType; - entityId: string; - tenantId: string; - organizationId: string; - enabled: boolean; -}) { +export function useYjsToken(params: { entityType: ProductEntityType; entityId: string; tenantId: string; organizationId: string; enabled: boolean }) { const { enabled, ...scope } = params; const setYjsToken = useUserStore((s) => s.setYjsToken); const tokenKey = yjsTokenKey(scope.entityType, scope.entityId); diff --git a/frontend/src/modules/common/blocknote/query.ts b/frontend/src/modules/common/blocknote/query.ts index 6682385e4..12865e007 100644 --- a/frontend/src/modules/common/blocknote/query.ts +++ b/frontend/src/modules/common/blocknote/query.ts @@ -6,21 +6,13 @@ import { ApiError } from '~/lib/api'; /** A token lives five minutes and the relay closes its socket then; refetching at four keeps a fresh one ready for the reconnect. */ const YJS_TOKEN_REFETCH_MS = 4 * 60 * 1000; -export const yjsTokenKeys = { - entity: (entityType: ProductEntityType, entityId: string) => ['yjs', 'token', entityType, entityId] as const, -}; +export const yjsTokenKeys = { entity: (entityType: ProductEntityType, entityId: string) => ['yjs', 'token', entityType, entityId] as const }; /** 403 or 404: the caller may not edit the entity, or it is gone; no retry changes that. */ -export const isYjsTokenRefusal = (error: unknown) => - error instanceof ApiError && (error.status === 403 || error.status === 404); +export const isYjsTokenRefusal = (error: unknown) => error instanceof ApiError && (error.status === 403 || error.status === 404); /** The Yjs token for one entity, refreshed before it expires. */ -export const yjsTokenQueryOptions = (params: { - entityType: ProductEntityType; - entityId: string; - tenantId: string; - organizationId: string; -}) => +export const yjsTokenQueryOptions = (params: { entityType: ProductEntityType; entityId: string; tenantId: string; organizationId: string }) => queryOptions({ queryKey: yjsTokenKeys.entity(params.entityType, params.entityId), queryFn: async () => { diff --git a/frontend/src/modules/common/blocknote/styles.css b/frontend/src/modules/common/blocknote/styles.css index 14f4537ae..84963130a 100644 --- a/frontend/src/modules/common/blocknote/styles.css +++ b/frontend/src/modules/common/blocknote/styles.css @@ -1,4 +1,5 @@ -/* Lift editing task card above sibling virtualizer items so BlockNote floating UI (slash menu, toolbar) isn't clipped */ +/* Serves an app's task board (raak): lift the editing task card above sibling virtualizer items so BlockNote floating UI + (slash menu, toolbar) isn't clipped. */ li:has([data-state="editing"]), li:has(.is-focused) { z-index: 10; @@ -14,8 +15,6 @@ li:has(.is-focused) { background: transparent; } -/* Dark theme variables inherited from tailwind.css via .bn-container.bn-shadcn.dark selector */ - /* Override BlockNote's soft editor text color with the app foreground. */ .bn-container.bn-shadcn { --bn-font-family: "Open Sans", ui-sans-serif, sans-serif; @@ -33,10 +32,10 @@ li:has(.is-focused) { } .bn-editor[contenteditable="false"] .bn-file-block-content-wrapper { - cursor: default !important; + cursor: default; } .bn-editor[contenteditable="false"] .bn-add-file-button { - pointer-events: none !important; + pointer-events: none; } .bn-editor[contenteditable="false"] p.bn-add-file-button-text { display: none; @@ -69,6 +68,23 @@ li:has(.is-focused) { font-weight: 400; } +/* An empty heading's placeholder keeps the heading's type, so nothing jumps when typing starts. */ +.bn-shadcn .bn-block-content[data-content-type="heading"]:has(.ProseMirror-trailingBreak:only-child)::after { + font-size: inherit; + line-height: inherit; + font-weight: inherit; +} + +/* `titlePlaceholder` labels block 0 only. The chain outranks BlockNote's injected per-type rules by specificity. */ +.bn-title-placeholder + .bn-editor + > .bn-block-group + > .bn-block-outer:first-child + > .bn-block + > .bn-block-content[data-content-type="heading"]:has(.ProseMirror-trailingBreak:only-child)::after { + content: var(--bn-title-placeholder); +} + .bn-editor[contenteditable="false"] .bn-block-content[data-content-type="codeBlock"] select { display: none; } @@ -146,8 +162,8 @@ li:has(.is-focused) { } .bn-link-toolbar > button.px-3 { - padding-left: 0.5rem !important; - padding-right: 0.5rem !important; + padding-left: 0.5rem; + padding-right: 0.5rem; height: 2rem; } @@ -197,9 +213,9 @@ li:has(.is-focused) { .bn-shadcn .bn-editor, .bn-shadcn .bn-static-editor { - white-space: pre-wrap !important; - padding: 0px !important; - background: transparent !important; + white-space: pre-wrap; + padding: 0px; + background: transparent; height: 100%; width: 100%; } @@ -211,9 +227,7 @@ li:has(.is-focused) { .bn-file-block-content-wrapper:has(> .bn-visual-media-wrapper > .bn-visual-media[style*="aspect-ratio"]) { max-width: 100%; } -.bn-file-block-content-wrapper[style*="fit-content"]:has( - > .bn-visual-media-wrapper > .bn-visual-media[style*="aspect-ratio"] -) { +.bn-file-block-content-wrapper[style*="fit-content"]:has(> .bn-visual-media-wrapper > .bn-visual-media[style*="aspect-ratio"]) { width: 100% !important; } @@ -241,58 +255,6 @@ li:has(.is-focused) { gap: 0.15rem; } -.slash-menu { - max-height: 40vh; - overflow-y: auto; - background-color: var(--popover) !important; - border: 0.05rem solid var(--border) !important; - border-radius: 0.5rem; - box-shadow: 0 0.05rem 0.3rem 0 rgb(0 0 0 / 0.1) !important; - display: flex; - flex-direction: column; - height: fit-content; - padding: 0.25rem; -} - -.slash-menu-item { - border-radius: 0.25rem; - font-size: 0.888rem; - min-width: 14rem; - align-items: center; - display: flex; - height: 2.25rem; - flex-direction: row; - justify-content: space-between; -} - -/* BlockNote's shadcn theme resets icons without a size-* class via an unlayered rule, so !important is required. */ -.slash-menu-item svg { - width: 1rem !important; - height: 1rem !important; -} - -.slash-menu-item-badge { - font-size: 0.8rem; - min-width: 1rem; - align-items: center; - color: var(--muted-foreground); - opacity: 0.5 !important; - display: flex; - padding: 0.125rem 0 0.125rem 0.25rem; -} - -.slash-menu-separator { - margin-top: 0.25rem !important; - margin-bottom: 0.25rem !important; -} - -.slash-menu-item[aria-selected="true"] { - background-color: var(--accent); -} -.slash-menu-item:hover { - background-color: color-mix(in oklch, var(--accent) 60%, transparent); -} - .notify { display: flex; justify-content: center; @@ -336,7 +298,7 @@ li:has(.is-focused) { } .no-hover-bg:hover { - background-color: transparent !important; + background-color: transparent; } .notify-icon-wrapper { @@ -386,12 +348,12 @@ li:has(.is-focused) { } .bn-shadcn th { - border: 0.05rem solid var(--table-border); + border: 0.05rem solid var(--border); border-collapse: collapse; } .bn-shadcn td { padding: 4px; - border: 0.05rem solid var(--table-border); + border: 0.05rem solid var(--border); border-collapse: collapse; } @@ -405,17 +367,3 @@ li:has(.is-focused) { [data-content-type="heading"][data-level="3"] { --level: 1.1rem; } - -/* Forced-title placeholder for block 0; !important beats BlockNote's injected placeholder rules, so re-check on every @blocknote bump. */ -.bn-forced-title - .bn-editor - > .bn-block-group - > .bn-block-outer:first-child - > .bn-block - > .bn-block-content[data-content-type="heading"]:has(.ProseMirror-trailingBreak:only-child)::after { - content: var(--bn-title-placeholder, "Title") !important; - color: color-mix(in oklch, var(--muted-foreground) 60%, transparent); - font-size: inherit; - font-weight: inherit; - line-height: inherit; -} diff --git a/frontend/src/modules/common/blocknote/tests/collaborative-blocknote.test.tsx b/frontend/src/modules/common/blocknote/tests/collaborative-blocknote.test.tsx index 073fe69d9..8a8d7d769 100644 --- a/frontend/src/modules/common/blocknote/tests/collaborative-blocknote.test.tsx +++ b/frontend/src/modules/common/blocknote/tests/collaborative-blocknote.test.tsx @@ -15,9 +15,7 @@ const connection = { provider: {}, fragment: {}, synced: true, stopped: false, r vi.mock('~/modules/common/blocknote/yjs-connections', () => ({ useYjsConnection: (editSessionId: string | undefined) => (editSessionId ? { ...connection } : null), })); -vi.mock('~/modules/common/blocknote/hooks/use-yjs-token', () => ({ - useYjsToken: () => ({ token: 'token', refused: false }), -})); +vi.mock('~/modules/common/blocknote/hooks/use-yjs-token', () => ({ useYjsToken: () => ({ token: 'token', refused: false }) })); vi.mock('~/hooks/use-online-manager', () => ({ useOnlineManager: () => true })); vi.mock('~/modules/user/user-store', () => ({ useCurrentUser: () => ({ name: 'Editor' }) })); vi.mock('~/modules/common/spinner', () => ({ Spinner: () => null })); diff --git a/frontend/src/modules/common/blocknote/tests/editor-commit.test.tsx b/frontend/src/modules/common/blocknote/tests/editor-commit.test.tsx new file mode 100644 index 000000000..73f011e16 --- /dev/null +++ b/frontend/src/modules/common/blocknote/tests/editor-commit.test.tsx @@ -0,0 +1,305 @@ +// @vitest-environment jsdom +import { BlockNoteEditor, type PartialBlock } from '@blocknote/core'; +import { blocksToYXmlFragment } from '@blocknote/core/yjs'; +import { act, type ComponentProps, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WebsocketProvider } from 'y-websocket'; +import * as Y from 'yjs'; +import type { BlockNoteContentApi } from '~/modules/common/blocknote/blocknote-editor'; + +// The editor, its commit paths and the Yjs editor registry are real; only the host's outward boundaries are inert. +vi.mock('~/query/query-client', async () => { + const { QueryClient } = await import('@tanstack/react-query'); + return { queryClient: new QueryClient() }; +}); +// Offline, so CollaborativeBlockNote opens the standalone editor at once. +vi.mock('~/hooks/use-online-manager', () => ({ useOnlineManager: () => false })); +vi.mock('~/modules/common/blocknote/hooks/use-yjs-token', () => ({ useYjsToken: () => ({ token: undefined, refused: false }) })); +vi.mock('~/modules/common/blocknote/yjs-connections', () => ({ useYjsConnection: () => null })); +vi.mock('~/modules/user/user-store', async (importOriginal) => ({ + ...(await importOriginal<typeof import('~/modules/user/user-store')>()), + useCurrentUser: () => ({ name: 'Editor' }), +})); + +const { customSchema } = await import('~/modules/common/blocknote/blocknote-config'); +const { checkedExtension } = await import('~/modules/common/blocknote/custom-elements/checklist/checklist-extension'); +const { BlockNote } = await import('~/modules/common/blocknote/blocknote-editor'); +const { CollaborativeBlockNote } = await import('~/modules/common/blocknote/collaborative-blocknote'); +const { isYjsEditorActive } = await import('~/modules/common/blocknote/yjs-editor'); +const { setRouter } = await import('~/routes/-router-instance'); + +type CustomPartialBlock = PartialBlock<typeof customSchema.blockSchema, typeof customSchema.inlineContentSchema, typeof customSchema.styleSchema>; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const headless = BlockNoteEditor.create({ schema: customSchema, _headless: true, extensions: [checkedExtension()] }); + +/** The document as the editor serializes it, so an untouched editor holds exactly this string. */ +const serialize = (blocks: CustomPartialBlock[]) => { + headless.replaceBlocks(headless.document, blocks); + return JSON.stringify(headless.document); +}; + +const checklist = (text: string): CustomPartialBlock[] => [{ id: 'item', type: 'checklistItem', props: { checkboxId: 'box-1' }, content: text }]; +const stored = serialize(checklist('todo')); +const otherStored = serialize(checklist('changed elsewhere')); + +/** Navigation listeners the editor subscribed on the router. */ +const beforeLoadListeners = new Set<() => void>(); +setRouter({ + subscribe: (_event: string, listener: () => void) => { + beforeLoadListeners.add(listener); + return () => beforeLoadListeners.delete(listener); + }, +} as never); +const navigate = () => + act(() => { + for (const listener of beforeLoadListeners) listener(); + }); + +let root: Root; +let container: HTMLDivElement; +const contentApi = createRef<BlockNoteContentApi>(); + +beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +const editorElement = () => container.querySelector<HTMLElement>('.bn-editor') as HTMLElement; +const blur = () => act(() => editorElement().dispatchEvent(new FocusEvent('focusout', { bubbles: true }))); +const pressEscape = () => act(() => editorElement().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))); +const unmount = () => act(async () => root.unmount()); +/** A real edit through the editor: toggles the checklist item's checkbox. */ +const edit = () => act(() => expect(contentApi.current?.toggleChecklist('box-1')).toBe(true)); + +const bundle = (fragment: Y.XmlFragment, entityId = 'attachment-1') => ({ + provider: {} as WebsocketProvider, + fragment, + user: { name: 'Editor', color: '#000000' }, + entityType: 'attachment' as const, + entityId, +}); +const emptyFragment = () => new Y.Doc().getXmlFragment('document-store'); +const seededFragment = () => blocksToYXmlFragment(headless, checklist('todo') as never, emptyFragment()); + +type BlockNoteProps = Partial<ComponentProps<typeof BlockNote>>; +type HostProps = Partial<ComponentProps<typeof CollaborativeBlockNote>>; + +/** CollaborativeBlockNote offline: the standalone editor on the stored description. */ +const renderHost = (props: HostProps) => + act(async () => + root.render( + <CollaborativeBlockNote + entityType="attachment" + entityId="attachment-1" + tenantId="tenant-1" + organizationId="org-1" + canEdit + description={stored} + updateData={() => {}} + contentApiRef={contentApi} + {...props} + />, + ), + ); + +describe('standalone BlockNote commits', () => { + const renderEditor = (props: BlockNoteProps) => + act(async () => root.render(<BlockNote id="doc" updateData={() => {}} contentApiRef={contentApi} {...props} />)); + + it('holds the stored document untouched', async () => { + await renderEditor({ defaultValue: stored }); + + expect(contentApi.current?.getContent()).toBe(stored); + }); + + it('commits a changed document on blur', async () => { + const updateData = vi.fn(); + await renderEditor({ defaultValue: stored, updateData }); + + await edit(); + await blur(); + + expect(updateData).toHaveBeenCalledExactlyOnceWith(contentApi.current?.getContent()); + expect(updateData.mock.calls[0][0]).not.toBe(stored); + }); + + it('does not commit on blur when the document equals defaultValue', async () => { + const updateData = vi.fn(); + await renderEditor({ defaultValue: stored, updateData }); + + await blur(); + + expect(updateData).not.toHaveBeenCalled(); + }); + + it('commits once for Escape followed by blur and unmount', async () => { + const updateData = vi.fn(); + const onEscapeClick = vi.fn(); + await renderEditor({ defaultValue: stored, updateData, onEscapeClick }); + + await edit(); + const edited = contentApi.current?.getContent(); + await pressEscape(); + await blur(); + await unmount(); + + expect(onEscapeClick).toHaveBeenCalledOnce(); + expect(updateData).toHaveBeenCalledExactlyOnceWith(edited); + }); + + it('commits a changed document on unmount when no blur fired', async () => { + const updateData = vi.fn(); + await renderEditor({ defaultValue: stored, updateData }); + + await edit(); + const edited = contentApi.current?.getContent(); + await unmount(); + + expect(updateData).toHaveBeenCalledExactlyOnceWith(edited); + }); + + it('does not commit on unmount when the document equals defaultValue', async () => { + const updateData = vi.fn(); + await renderEditor({ defaultValue: stored, updateData }); + + await unmount(); + + expect(updateData).not.toHaveBeenCalled(); + }); +}); + +describe('collaborative BlockNote commits', () => { + const renderEditor = (fragment: Y.XmlFragment, props: BlockNoteProps = {}) => + act(async () => + root.render( + <BlockNote id="doc" defaultValue="" updateData={() => {}} contentApiRef={contentApi} collaboration={bundle(fragment)} {...props} />, + ), + ); + + it('never commits an empty document, on blur, Escape or unmount', async () => { + const updateData = vi.fn(); + await renderEditor(emptyFragment(), { updateData }); + + await blur(); + await pressEscape(); + await unmount(); + + expect(updateData).not.toHaveBeenCalled(); + }); + + it('commits a changed document on blur', async () => { + const updateData = vi.fn(); + await renderEditor(seededFragment(), { updateData }); + + await edit(); + await blur(); + + expect(updateData).toHaveBeenCalledExactlyOnceWith(contentApi.current?.getContent()); + }); + + it('does not commit on unmount', async () => { + const updateData = vi.fn(); + await renderEditor(seededFragment(), { updateData }); + + await edit(); + await unmount(); + + expect(updateData).not.toHaveBeenCalled(); + }); +}); + +describe('CollaborativeBlockNote standalone navigation write', () => { + it('writes the editor document as a standalone update when it differs from the description', async () => { + const updateData = vi.fn(); + await renderHost({ updateData }); + + await edit(); + await navigate(); + + expect(updateData).toHaveBeenCalledExactlyOnceWith(contentApi.current?.getContent(), false); + }); + + it('does not write when the document equals the description', async () => { + const updateData = vi.fn(); + await renderHost({ updateData }); + + await navigate(); + + expect(updateData).not.toHaveBeenCalled(); + }); + + it('does not write when the description is null', async () => { + const updateData = vi.fn(); + await renderHost({ description: null, updateData }); + + await navigate(); + + expect(updateData).not.toHaveBeenCalled(); + }); +}); + +describe('behaviour the description sync redesign changes', () => { + it('commits on unmount when defaultValue changed after mount, though the user never touched the document', async () => { + const updateData = vi.fn(); + const renderEditor = (defaultValue: string) => + act(async () => root.render(<BlockNote id="doc" defaultValue={defaultValue} updateData={updateData} contentApiRef={contentApi} />)); + await renderEditor(stored); + + await renderEditor(otherStored); + expect(contentApi.current?.getContent()).toBe(stored); + await unmount(); + + expect(updateData).toHaveBeenCalledExactlyOnceWith(stored); + }); + + it('commits on unmount a stored document the editor serializes differently, though the user never touched it', async () => { + const updateData = vi.fn(); + const unnormalized = JSON.stringify([ + { id: 'p', type: 'paragraph', props: {}, content: [{ type: 'text', text: 'hello', styles: {} }], children: [] }, + ]); + await act(async () => root.render(<BlockNote id="doc" defaultValue={unnormalized} updateData={updateData} contentApiRef={contentApi} />)); + + await unmount(); + + expect(updateData).toHaveBeenCalledOnce(); + expect(updateData.mock.calls[0][0]).not.toBe(unnormalized); + }); + + it('registers the entity as an active Yjs editor while a collaborative editor is mounted', async () => { + await act(async () => root.render(<BlockNote id="doc" updateData={() => {}} collaboration={bundle(emptyFragment(), 'att-9')} />)); + + expect(isYjsEditorActive('attachment', 'att-9')).toBe(true); + await unmount(); + expect(isYjsEditorActive('attachment', 'att-9')).toBe(false); + }); + + it('compares the navigation write against the description it mounted with', async () => { + const updateData = vi.fn(); + await renderHost({ updateData }); + + // A newer description arrives; the editor still holds the one it mounted with. + await renderHost({ description: otherStored, updateData }); + await navigate(); + + expect(updateData).not.toHaveBeenCalled(); + }); + + it('writes the mounted document over a newer description on unmount, as a standalone update', async () => { + const updateData = vi.fn(); + await renderHost({ updateData }); + + await renderHost({ description: otherStored, updateData }); + await unmount(); + + expect(updateData).toHaveBeenCalledExactlyOnceWith(stored, false); + }); +}); diff --git a/frontend/src/modules/common/blocknote/tests/full-html-media.test.tsx b/frontend/src/modules/common/blocknote/tests/full-html-media.test.tsx index 39871d712..544d3cad4 100644 --- a/frontend/src/modules/common/blocknote/tests/full-html-media.test.tsx +++ b/frontend/src/modules/common/blocknote/tests/full-html-media.test.tsx @@ -13,9 +13,7 @@ const getPresignedUrlBatched = vi.fn(async (attachmentId: string) => { await presignGate; return `https://signed.example.test/${attachmentId}`; }); -vi.mock('~/modules/attachment/presign-batch', () => ({ - getPresignedUrlBatched: (attachmentId: string) => getPresignedUrlBatched(attachmentId), -})); +vi.mock('~/modules/attachment/presign-batch', () => ({ getPresignedUrlBatched: (attachmentId: string) => getPresignedUrlBatched(attachmentId) })); vi.mock('~/modules/attachment/offline/storage-service', () => ({ attachmentStorage: { getSharedBlobUrl: async () => null, createBlobUrlWithVariant: async () => null }, })); @@ -83,11 +81,7 @@ describe('BlockNoteFullHtml media', () => { // A refused reference renders nothing at all: no image, no empty file placeholder. const imageBlockCount = () => container.querySelectorAll('[data-content-type="image"]').length; - await act(async () => - root.render( - <BlockNoteFullHtml id="doc" defaultValue={document} tenantId="tenant-1" organizationId={organizationId} />, - ), - ); + await act(async () => root.render(<BlockNoteFullHtml id="doc" defaultValue={document} tenantId="tenant-1" organizationId={organizationId} />)); // First pass: the unresolved blocks, painted while the presign is pending. await vi.waitFor(() => expect(container.textContent).toContain('text survives')); expect(sourcesNow()).toEqual([ownKey, attachmentId]); @@ -116,11 +110,7 @@ describe('BlockNoteFullHtml media', () => { ]); const watcher = watchImageSources(container); - await act(async () => - root.render( - <BlockNoteFullHtml id="doc" defaultValue={document} tenantId="tenant-1" organizationId={organizationId} />, - ), - ); + await act(async () => root.render(<BlockNoteFullHtml id="doc" defaultValue={document} tenantId="tenant-1" organizationId={organizationId} />)); await vi.waitFor(() => expect(container.textContent).toContain('text survives')); watcher.stop(); @@ -154,14 +144,10 @@ describe('BlockNoteFullHtml: documents BlockNote cannot render', () => { paragraph('text survives'), ]); // Passes the block schema check: a paragraph whose inline node BlockNote does not know. - const unknownInline = JSON.stringify([ - { id: 'inline', type: 'paragraph', props: {}, content: [{ type: 'x' }], children: [] }, - ]); + const unknownInline = JSON.stringify([{ id: 'inline', type: 'paragraph', props: {}, content: [{ type: 'x' }], children: [] }]); it('must not blank a document via a block type outside the schema: its nested blocks and the rest render', async () => { - await act(async () => - root.render(<BlockNoteFullHtml id="doc" defaultValue={unknownType} organizationId={organizationId} />), - ); + await act(async () => root.render(<BlockNoteFullHtml id="doc" defaultValue={unknownType} organizationId={organizationId} />)); await vi.waitFor(() => expect(container.textContent).toContain('text survives')); expect(container.textContent).toContain('nested survives'); diff --git a/frontend/src/modules/common/blocknote/tests/full-html-ready.test.tsx b/frontend/src/modules/common/blocknote/tests/full-html-ready.test.tsx new file mode 100644 index 000000000..f647e81dc --- /dev/null +++ b/frontend/src/modules/common/blocknote/tests/full-html-ready.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('~/modules/attachment/presign-batch', () => ({ getPresignedUrlBatched: async (id: string) => id })); +vi.mock('~/modules/attachment/offline/storage-service', () => ({ + attachmentStorage: { getSharedBlobUrl: async () => null, createBlobUrlWithVariant: async () => null }, +})); +vi.mock('~/modules/attachment/offline/download-service', () => ({ downloadService: { queueForDownload: vi.fn() } })); +vi.mock('~/modules/attachment/query', () => ({ findAttachmentInCache: () => undefined })); +vi.mock('~/modules/attachment/dialog/open-attachment-dialog', () => ({ openAttachmentDialog: vi.fn() })); + +const { BlockNoteFullHtml } = await import('~/modules/common/blocknote/full-html'); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// Each test renders its own text: the first-pass HTML cache is module state shared across tests. +const paragraphs = (...texts: string[]) => + JSON.stringify( + texts.map((text, i) => ({ id: `p${i}`, type: 'paragraph', props: {}, content: text ? [{ type: 'text', text, styles: {} }] : [], children: [] })), + ); + +/** Lets the first pass (a microtask) and the resolved pass (a promise chain) settle. */ +const settle = () => act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + +describe('BlockNoteFullHtml onReady and defaultValue changes', () => { + const container = document.createElement('div'); + const root = createRoot(container); + const render = (defaultValue: string, onReady?: () => void) => + act(async () => root.render(<BlockNoteFullHtml id="doc" defaultValue={defaultValue} organizationId="org-1" onReady={onReady} />)); + + afterEach(() => act(() => root.render(null))); + + it('fires onReady once per mount, on the first non-empty HTML, through both passes and a changed defaultValue', async () => { + const onReady = vi.fn(); + await render(paragraphs('ready first'), onReady); + await settle(); + + expect(container.textContent).toBe('ready first'); + expect(onReady).toHaveBeenCalledOnce(); + + await render(paragraphs('ready second'), onReady); + await settle(); + expect(container.textContent).toBe('ready second'); + expect(onReady).toHaveBeenCalledOnce(); + + await act(() => root.render(null)); + await render(paragraphs('ready first'), onReady); + await settle(); + expect(onReady).toHaveBeenCalledTimes(2); + }); + + it('never fires onReady for an empty description', async () => { + const onReady = vi.fn(); + await render('', onReady); + await settle(); + + expect(container.textContent).toBe(''); + expect(onReady).not.toHaveBeenCalled(); + }); + + it('fires onReady for a document of one empty paragraph, whose HTML is not empty', async () => { + const onReady = vi.fn(); + await render(paragraphs(''), onReady); + await settle(); + + expect(container.textContent).toBe(''); + expect(onReady).toHaveBeenCalledOnce(); + }); + + it('keeps the old HTML until the first pass of a changed defaultValue is ready, then replaces it', async () => { + await render(paragraphs('old body')); + await settle(); + expect(container.textContent).toBe('old body'); + + // The changed value commits, its first pass waits for a microtask. + act(() => root.render(<BlockNoteFullHtml id="doc" defaultValue={paragraphs('new body')} organizationId="org-1" />)); + expect(container.textContent).toBe('old body'); + + await settle(); + expect(container.textContent).toBe('new body'); + }); +}); diff --git a/frontend/src/modules/common/blocknote/tests/yjs-connections.test.ts b/frontend/src/modules/common/blocknote/tests/yjs-connections.test.ts index c6addf84c..21a37a5e9 100644 --- a/frontend/src/modules/common/blocknote/tests/yjs-connections.test.ts +++ b/frontend/src/modules/common/blocknote/tests/yjs-connections.test.ts @@ -9,13 +9,7 @@ class MockProvider { synced = false; doc: MockDoc; /** Message type → handler, as y-websocket keeps them per provider; the relay's generation frame is type 4. */ - messageHandlers: (( - encoder: unknown, - decoder: unknown, - provider: unknown, - emitSynced: boolean, - type: number, - ) => void)[] = []; + messageHandlers: ((encoder: unknown, decoder: unknown, provider: unknown, emitSynced: boolean, type: number) => void)[] = []; private listeners = new Map<string, Set<(...args: unknown[]) => void>>(); constructor(_url: string, _room: string, doc: MockDoc, opts: { params: Record<string, string> }) { @@ -75,9 +69,7 @@ vi.mock('@tanstack/react-query', () => ({ }, }, })); -vi.mock('~/query/query-client', () => ({ - queryClient: { invalidateQueries: (...args: unknown[]) => invalidateQueries(...args) }, -})); +vi.mock('~/query/query-client', () => ({ queryClient: { invalidateQueries: (...args: unknown[]) => invalidateQueries(...args) } })); vi.mock('~/modules/common/blocknote/query', () => ({ yjsTokenKeys: { entity: (...key: unknown[]) => key } })); vi.mock('~/modules/common/blocknote/yjs-resync', () => ({ watchPendingStructs: () => () => {} })); vi.mock('~/env', () => ({ isDebugMode: false })); @@ -242,8 +234,7 @@ describe('yjs connection: token refusals', () => { await close(provider, 4001); }; /** The refetch a refusal starts lands a new token before the next attempt. */ - const fetchToken = (tokenKey: string, token: string) => - act(async () => useUserStore.getState().setYjsToken(tokenKey, token)); + const fetchToken = (tokenKey: string, token: string) => act(async () => useUserStore.getState().setYjsToken(tokenKey, token)); it('must not stop syncing via an API outage at token refresh: an expired token refused again never counts', async () => { const { provider, state } = await mountConnection(); diff --git a/frontend/src/modules/common/blocknote/types.ts b/frontend/src/modules/common/blocknote/types.ts index 943416c36..43148decd 100644 --- a/frontend/src/modules/common/blocknote/types.ts +++ b/frontend/src/modules/common/blocknote/types.ts @@ -43,12 +43,7 @@ type MaxNineItems<T extends string> = export type SlashIndexedItems = MaxNineItems<CustomBlockTypes>; export type IconType = ( - props: React.SVGAttributes<SVGElement> & { - children?: React.ReactNode; - size?: string | number; - color?: string; - title?: string; - }, + props: React.SVGAttributes<SVGElement> & { children?: React.ReactNode; size?: string | number; color?: string; title?: string }, ) => React.ReactElement; /** How an upload is referenced: by attachment id, or by cloud key when its upload template stores publicly (the template decides). */ @@ -83,8 +78,8 @@ export type CommonBlockNoteProps = { emojis?: boolean; excludeBlockTypes?: CustomBlockRegularTypes[]; excludeFileBlockTypes?: CustomBlockFileTypes[]; - /** Forced-title mode: block 0 is a heading acting as document title. `true` pins level 1; `{ level }` sets it lower. */ - forcedTitle?: boolean | { level: TitleLevel }; + /** Labels an empty heading in block 0, the title of a title document (helpers/title-document). */ + titlePlaceholder?: string; extensions?: ExtensionFactoryInstance[]; members?: Member[]; // for mentions onFocus?: () => void; @@ -101,9 +96,7 @@ export type CustomBlockNoteMenuProps = { editor: CustomBlockNoteEditor; allowedTypes: CustomBlockTypes[]; headingLevels: NonNullable<CommonBlockNoteProps['headingLevels']>; - /** Forced-title mode: menus hide headings at or above this level for body blocks and skip block 0 entirely. */ - titleLevel?: TitleLevel; }; -/** Heading level a forced-title editor pins block 0 to. */ +/** Heading level of a title document's block 0 (helpers/title-document). */ export type TitleLevel = 1 | 2 | 3; diff --git a/frontend/src/modules/common/blocknote/yjs-connections.ts b/frontend/src/modules/common/blocknote/yjs-connections.ts index c1c56d5f4..72f8b1cbf 100644 --- a/frontend/src/modules/common/blocknote/yjs-connections.ts +++ b/frontend/src/modules/common/blocknote/yjs-connections.ts @@ -21,11 +21,7 @@ const MAX_BACKOFF_MS = 30_000; const MAX_TOKEN_FAILURES = 5; /** WebSocket close codes sent by the Yjs relay; the 4000-4999 range is reserved for application use. */ -const YJS_CLOSE = { - TOKEN_INVALID: 4001, - ACCESS_DENIED: 4003, - BAD_REQUEST: 4400, -} as const; +const YJS_CLOSE = { TOKEN_INVALID: 4001, ACCESS_DENIED: 4003, BAD_REQUEST: 4400 } as const; /** The relay's own message type next to y-websocket's sync (0) and awareness (1): the document's generation, sent before every handshake answer. Must match yjs/src/sync/relay.ts. */ const YJS_MESSAGE_GENERATION = 4; @@ -71,11 +67,7 @@ interface YjsSyncState { rebuilds: Record<string, number>; } -const useYjsSyncStore = create<YjsSyncState>(() => ({ - synced: {}, - stopped: {}, - rebuilds: {}, -})); +const useYjsSyncStore = create<YjsSyncState>(() => ({ synced: {}, stopped: {}, rebuilds: {} })); /** * Ends a connection for good: no reconnect, and its editor turns read-only (useYjsConnection reports `stopped`), so @@ -207,12 +199,7 @@ function unbindProvider(conn: YjsConnection) { * the new fragment once it synced (useYjsConnection reports `synced` false meanwhile), and the user is told when the * dropped document held edits, since the description they see next is the one written elsewhere. */ -function rebuildConnection( - editSessionId: string, - conn: YjsConnection, - entityType: ProductEntityType, - tenantId: string, -) { +function rebuildConnection(editSessionId: string, conn: YjsConnection, entityType: ProductEntityType, tenantId: string) { const { edited } = conn; unbindProvider(conn); Object.assign(conn, openDoc(editSessionId, entityType, tenantId), { generation: null, edited: false }); @@ -236,13 +223,7 @@ function acquireConnection(editSessionId: string, entityType: ProductEntityType, return existing; } - const conn: YjsConnection = { - ...openDoc(editSessionId, entityType, tenantId), - refCount: 1, - stopped: false, - generation: null, - edited: false, - }; + const conn: YjsConnection = { ...openDoc(editSessionId, entityType, tenantId), refCount: 1, stopped: false, generation: null, edited: false }; bindProvider(editSessionId, conn, entityType, tenantId); connections.set(editSessionId, conn); return conn; diff --git a/frontend/src/modules/common/blocknote/yjs-resync.ts b/frontend/src/modules/common/blocknote/yjs-resync.ts index f4e99b96f..16b38b03f 100644 --- a/frontend/src/modules/common/blocknote/yjs-resync.ts +++ b/frontend/src/modules/common/blocknote/yjs-resync.ts @@ -20,11 +20,7 @@ interface ResyncableProvider { * the store and reconnects once the park has lasted `PARKED_GRACE_MS` while connected, with a * cooldown so a gap the relay itself cannot fill does not turn into a reconnect storm. */ -export function watchPendingStructs( - doc: Y.Doc, - provider: ResyncableProvider, - now: () => number = Date.now, -): () => void { +export function watchPendingStructs(doc: Y.Doc, provider: ResyncableProvider, now: () => number = Date.now): () => void { let parkedSince = 0; let lastResyncAt = 0; diff --git a/frontend/src/modules/common/board/board-drag.ts b/frontend/src/modules/common/board/board-drag.ts index bae2f1c22..a5a1353eb 100644 --- a/frontend/src/modules/common/board/board-drag.ts +++ b/frontend/src/modules/common/board/board-drag.ts @@ -2,23 +2,14 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge import type { KeyboardEvent } from 'react'; import { createContext, useContext } from 'react'; -export type PanelReorderDragData = { - dragItem: true; - type: 'panelReorder'; - panelId: string; -}; +export type PanelReorderDragData = { dragItem: true; type: 'panelReorder'; panelId: string }; export const isPanelReorderDragData = (data: Record<string | symbol, unknown>): data is PanelReorderDragData => { return data.dragItem === true && data.type === 'panelReorder' && typeof data.panelId === 'string'; }; /** Compute new panel order after dragging sourceId relative to targetId. Returns null if unchanged. */ -export function reorderPanels( - currentOrder: string[], - sourceId: string, - targetId: string, - edge: Edge | null, -): string[] | null { +export function reorderPanels(currentOrder: string[], sourceId: string, targetId: string, edge: Edge | null): string[] | null { const fromIndex = currentOrder.indexOf(sourceId); const toIndex = currentOrder.indexOf(targetId); if (fromIndex === -1 || toIndex === -1) return null; diff --git a/frontend/src/modules/common/board/board-layout.tsx b/frontend/src/modules/common/board/board-layout.tsx index fed638ad0..cc1cab39e 100644 --- a/frontend/src/modules/common/board/board-layout.tsx +++ b/frontend/src/modules/common/board/board-layout.tsx @@ -1,9 +1,5 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import { - draggable, - dropTargetForElements, - monitorForElements, -} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { draggable, dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; import { preserveOffsetOnSource } from '@atlaskit/pragmatic-drag-and-drop/utils/preserve-offset-on-source'; import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; @@ -14,12 +10,7 @@ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState import { isPanelReorderDragData, PanelDragHandleContext, reorderPanels } from '~/modules/common/board/board-drag'; import { useBoardStore } from '~/modules/common/board/board-store'; import { DropIndicator } from '~/modules/common/drop-indicator'; -import { - type PanelGroupApi, - ResizablePanel, - ResizablePanelGroup, - ResizableSeparator, -} from '~/modules/common/resizable-panels/resizable-panels'; +import { type PanelGroupApi, ResizablePanel, ResizablePanelGroup, ResizableSeparator } from '~/modules/common/resizable-panels/resizable-panels'; import { ScrollArea } from '~/modules/ui/scroll-area'; import { cn } from '~/utils/cn'; @@ -111,6 +102,11 @@ export function BoardLayout({ const [dropIndicator, setDropIndicator] = useState<{ panelId: string; edge: Edge } | null>(null); + // pdnd's onDrag fires continuously over a target; keeping the previous object skips re-renders while the edge holds + const updateDropIndicator = useCallback((next: { panelId: string; edge: Edge } | null) => { + setDropIndicator((prev) => (prev?.panelId === next?.panelId && prev?.edge === next?.edge ? prev : next)); + }, []); + useEffect(() => { if (!reorderable) return; return monitorForElements({ @@ -141,28 +137,18 @@ export function BoardLayout({ className={cn('group/board', !autoHeight && 'h-[inherit]', groupClassName)} > {panels.map(({ panelId }, i) => ( - <motion.div - key={panelId} - layout="position" - layoutId={`${boardId}-${panelId}`} - className="relative flex shrink-0" - > + <motion.div key={panelId} layout="position" layoutId={`${boardId}-${panelId}`} className="relative flex shrink-0"> {reorderable && dropIndicator?.panelId === panelId && dropIndicator.edge === 'left' && ( <DropIndicator edge="left" gap={i === 0 ? 0 : 0.6} /> )} - <ResizablePanel - id={panelId} - minWidth={PANEL_MIN_WIDTH} - collapsedWidth={COLLAPSED_PANEL_MIN_WIDTH} - collapsible - > + <ResizablePanel id={panelId} minWidth={PANEL_MIN_WIDTH} collapsedWidth={COLLAPSED_PANEL_MIN_WIDTH} collapsible> {reorderable ? ( <PanelDragWrapper panelId={panelId} index={i} total={panels.length} panelIdsRef={panelIdsRef} - onEdgeChange={setDropIndicator} + onEdgeChange={updateDropIndicator} onPanelToggle={handlePanelToggle} onPanelReorder={onPanelReorder} > @@ -172,9 +158,7 @@ export function BoardLayout({ children(panelId, i) )} </ResizablePanel> - {reorderable && dropIndicator?.panelId === panelId && dropIndicator.edge === 'right' && ( - <DropIndicator edge="right" gap={-0.1} /> - )} + {reorderable && dropIndicator?.panelId === panelId && dropIndicator.edge === 'right' && <DropIndicator edge="right" gap={-0.1} />} {i < panels.length - 1 && ( <ResizableSeparator @@ -198,10 +182,7 @@ export function BoardLayout({ return ( <ScrollArea - className={cn( - 'transition sm:h-[calc(100dvh-var(--board-offset-sm))] md:h-[calc(100dvh-var(--board-offset-md))]', - className, - )} + className={cn('transition sm:h-[calc(100dvh-var(--board-offset-sm))] md:h-[calc(100dvh-var(--board-offset-md))]', className)} viewportClassName="overflow-y-hidden! overscroll-y-auto" horizontalScroll autoScrollOnDrag="horizontal" @@ -272,10 +253,7 @@ function PanelDragWrapper({ canDrop: ({ source }) => isPanelReorderDragData(source.data) && source.data.panelId !== panelId, getIsSticky: () => true, getData: ({ input }) => - attachClosestEdge( - { dragItem: true, type: 'panelReorder' as const, panelId }, - { element: wrapper, input, allowedEdges: ['left', 'right'] }, - ), + attachClosestEdge({ dragItem: true, type: 'panelReorder' as const, panelId }, { element: wrapper, input, allowedEdges: ['left', 'right'] }), onDrag: ({ self, source }) => { const edge = extractClosestEdge(self.data); if (!edge || !isPanelReorderDragData(source.data)) return onEdgeChange(null); diff --git a/frontend/src/modules/common/board/board-panel.tsx b/frontend/src/modules/common/board/board-panel.tsx index e9db1383d..a98aeaefa 100644 --- a/frontend/src/modules/common/board/board-panel.tsx +++ b/frontend/src/modules/common/board/board-panel.tsx @@ -11,12 +11,7 @@ interface BoardPanelHeaderProps { export function BoardPanelHeader({ leading, title, actions, isCollapsed, className }: BoardPanelHeaderProps) { return ( - <div - className={cn( - 'space-between z-50 flex min-h-13 flex-row items-center gap-2 rounded-lg rounded-b-none border border-b-0 p-2 max-sm:hidden', - className, - )} - > + <div className={cn('z-50 flex min-h-13 flex-row items-center gap-2 rounded-lg rounded-b-none border border-b-0 p-2 max-sm:hidden', className)}> {leading} {!isCollapsed && title} {!isCollapsed && actions && ( @@ -111,7 +106,7 @@ export function CollapsedPanelView({ mainCount, sections = EMPTY_SECTIONS, class </div> ))} - <div className="flex grow items-center justify-center text-gray-500 text-xs"> + <div className="flex grow items-center justify-center text-muted-foreground text-xs"> <div className="absolute top-[calc(50%-0.5rem)]">{mainCount}</div> </div> diff --git a/frontend/src/modules/common/board/board-store.test.ts b/frontend/src/modules/common/board/board-store.test.ts index 61e153426..8ef47b83d 100644 --- a/frontend/src/modules/common/board/board-store.test.ts +++ b/frontend/src/modules/common/board/board-store.test.ts @@ -18,16 +18,10 @@ describe('board-store panel orders', () => { useBoardStore.getState().setPanelOrder(boardId, 'explainer', 100); useBoardStore.getState().setPanelOrder(boardId, 'ai-chat', 200); - expect(useBoardStore.getState().boardPanelOrders[boardId]).toEqual({ - explainer: 100, - 'ai-chat': 200, - }); + expect(useBoardStore.getState().boardPanelOrders[boardId]).toEqual({ explainer: 100, 'ai-chat': 200 }); useBoardStore.getState().setPanelOrder(boardId, 'explainer', 150); - expect(useBoardStore.getState().boardPanelOrders[boardId]).toEqual({ - explainer: 150, - 'ai-chat': 200, - }); + expect(useBoardStore.getState().boardPanelOrders[boardId]).toEqual({ explainer: 150, 'ai-chat': 200 }); }); it('does not rewrite state when displayOrder is unchanged', () => { @@ -48,10 +42,7 @@ describe('board-store panel orders', () => { useBoardStore.getState().prunePanelOrders(boardId, ['explainer', 'ai-chat']); - expect(useBoardStore.getState().boardPanelOrders[boardId]).toEqual({ - explainer: 100, - 'ai-chat': 200, - }); + expect(useBoardStore.getState().boardPanelOrders[boardId]).toEqual({ explainer: 100, 'ai-chat': 200 }); }); it('is a no-op when nothing is stale', () => { diff --git a/frontend/src/modules/common/board/explainer-panel.tsx b/frontend/src/modules/common/board/explainer-panel.tsx index 2491a532a..78e9e66f1 100644 --- a/frontend/src/modules/common/board/explainer-panel.tsx +++ b/frontend/src/modules/common/board/explainer-panel.tsx @@ -1,3 +1,4 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; import { InfoIcon } from 'lucide-react'; import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; @@ -6,6 +7,7 @@ import { useAlertStore } from '~/modules/common/alerter/alert-store'; import { BlockNoteFullHtml } from '~/modules/common/blocknote/lazy-full-html'; import { LocalPanelShell } from '~/modules/common/board/local-panel-shell'; import { Spinner } from '~/modules/common/spinner'; +import { organizationQueryOptions } from '~/modules/organization/query'; import { Button } from '~/modules/ui/button'; import { ScrollArea } from '~/modules/ui/scroll-area'; @@ -19,7 +21,8 @@ export const EXPLAINER_PANEL_ID = 'explainer'; export function ExplainerPanel() { const { t } = useTranslation(); - const { organization, tenantId } = useOrganizationLayoutContext(); + const { organizationId, tenantId } = useOrganizationLayoutContext(); + const { data: organization } = useSuspenseQuery(organizationQueryOptions(organizationId, tenantId)); const setAlertSeen = useAlertStore((state) => state.setAlertSeen); const setAsSeen = () => setAlertSeen('welcome-text'); @@ -43,7 +46,7 @@ export function ExplainerPanel() { defaultValue={organization.welcomeText || ''} className="inline leading-none" tenantId={tenantId} - organizationId={organization.id} + organizationId={organizationId} /> </Suspense> </div> diff --git a/frontend/src/modules/common/board/panel-drag-handle-button.tsx b/frontend/src/modules/common/board/panel-drag-handle-button.tsx index 6d8055d8b..816be6c14 100644 --- a/frontend/src/modules/common/board/panel-drag-handle-button.tsx +++ b/frontend/src/modules/common/board/panel-drag-handle-button.tsx @@ -35,11 +35,7 @@ export function PanelDragHandleButton({ name, fallbackLabel, className, icon, ch ref={panelDrag?.registerHandle} className={cn(className, panelDrag && 'group/drag cursor-grab active:cursor-grabbing')} aria-roledescription={panelDrag ? t('c:sortable') : undefined} - aria-label={ - panelDrag - ? t('c:sortable_position', { name, position: panelDrag.index + 1, total: panelDrag.total }) - : fallbackLabel - } + aria-label={panelDrag ? t('c:sortable_position', { name, position: panelDrag.index + 1, total: panelDrag.total }) : fallbackLabel} onKeyDown={panelDrag?.onKeyDown} onClick={panelDrag?.onToggleCollapsed} > diff --git a/frontend/src/modules/common/close-button.tsx b/frontend/src/modules/common/close-button.tsx index c76255074..528d42f5f 100644 --- a/frontend/src/modules/common/close-button.tsx +++ b/frontend/src/modules/common/close-button.tsx @@ -1,11 +1,12 @@ import { XIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Button } from '~/modules/ui/button'; import { cn } from '~/utils/cn'; const sizeConfig = { - sm: { icon: 'icon-md', button: 'size-6' }, - md: { icon: 'icon-lg', button: 'size-7' }, - lg: { icon: 'icon-xl', button: 'size-8' }, + sm: { icon: 'size-4', button: 'size-6' }, + md: { icon: 'size-5', button: 'size-7' }, + lg: { icon: 'size-6', button: 'size-8' }, } as const; interface CloseButtonProps { @@ -15,15 +16,11 @@ interface CloseButtonProps { } export function CloseButton({ onClick, size = 'md', className }: CloseButtonProps) { + const { t } = useTranslation(); const { icon, button } = sizeConfig[size]; return ( - <Button - variant="ghost" - size="icon" - className={cn(button, 'opacity-70 hover:opacity-100', className)} - onClick={onClick} - > + <Button variant="ghost" size="icon" aria-label={t('c:close')} className={cn(button, 'opacity-70 hover:opacity-100', className)} onClick={onClick}> <XIcon className={icon} strokeWidth={1.5} /> </Button> ); diff --git a/frontend/src/modules/common/combobox-search-input.tsx b/frontend/src/modules/common/combobox-search-input.tsx new file mode 100644 index 000000000..0b20599bb --- /dev/null +++ b/frontend/src/modules/common/combobox-search-input.tsx @@ -0,0 +1,61 @@ +import { CircleXIcon } from 'lucide-react'; +import { SearchSpinner } from '~/modules/common/search-spinner'; +import { ComboboxPrimitive } from '~/modules/ui/combobox'; +import { cn } from '~/utils/cn'; + +/** Search-styled combobox input for the command palette and dropdowner. */ +export function ComboboxSearchInput({ + className, + wrapClassName, + isSearching = false, + spinnerDelay, + showClear = true, + value, + ref, + ...props +}: Omit<ComboboxPrimitive.Input.Props, 'value'> & { + value: string; + wrapClassName?: string; + isSearching?: boolean; + spinnerDelay?: number; + showClear?: boolean; +}) { + return ( + <div + data-slot="combobox-search-input-wrapper" + className={cn('group relative flex h-10 items-center border-b px-3', wrapClassName, value.length > 0 && 'pr-10')} + > + <SearchSpinner isSearching={isSearching} value={value} appearDelay={spinnerDelay} /> + <ComboboxPrimitive.Input + data-slot="combobox-search-input" + className={cn( + 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-search-cancel-button]:hidden', + className, + )} + value={value} + data-1p-ignore + data-lpignore="true" + {...props} + ref={(el) => { + // type="search" is set imperatively (base-ui omits it from its types) so password managers skip the input. + if (el) el.type = 'search'; + if (typeof ref === 'function') ref(el); + else if (ref) ref.current = el; + }} + /> + {showClear && value.length > 0 && ( + <ComboboxPrimitive.Clear + render={ + <button + type="button" + aria-label="Clear search" + className="absolute top-1/2 right-3 -translate-y-1/2 cursor-pointer opacity-70 hover:opacity-100" + /> + } + > + <CircleXIcon /> + </ComboboxPrimitive.Clear> + )} + </div> + ); +} diff --git a/frontend/src/modules/common/contact-form/contact-form-map.tsx b/frontend/src/modules/common/contact-form/contact-form-map.tsx index d7fd2f858..55296d01b 100644 --- a/frontend/src/modules/common/contact-form/contact-form-map.tsx +++ b/frontend/src/modules/common/contact-form/contact-form-map.tsx @@ -1,12 +1,4 @@ -import { - AdvancedMarker, - APIProvider, - ControlPosition, - Map as GMap, - InfoWindow, - MapControl, - useAdvancedMarkerRef, -} from '@vis.gl/react-google-maps'; +import { AdvancedMarker, APIProvider, ControlPosition, Map as GMap, InfoWindow, MapControl, useAdvancedMarkerRef } from '@vis.gl/react-google-maps'; import { ArrowUpRightIcon, MilestoneIcon, MinusIcon, PlusIcon, XIcon } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { useState } from 'react'; @@ -19,26 +11,11 @@ import { Button } from '~/modules/ui/button'; import { useUIStore } from '~/modules/ui/ui-store'; import Logo from '/static/common/logo/logo-icon-only.svg'; -type MapConfig = { - id: string; - label: string; - mapId?: string; - mapTypeId?: string; -}; +type MapConfig = { id: string; label: string; mapId?: string; mapTypeId?: string }; const mapStyles: MapConfig[] = [ - { - id: 'light', - label: 'Light', - mapId: '49ae42fed52588c3', - mapTypeId: 'roadmap', - }, - { - id: 'dark', - label: 'Dark', - mapId: '739af084373f96fe', - mapTypeId: 'roadmap', - }, + { id: 'light', label: 'Light', mapId: '49ae42fed52588c3', mapTypeId: 'roadmap' }, + { id: 'dark', label: 'Dark', mapId: '739af084373f96fe', mapTypeId: 'roadmap' }, ]; function MarkerWithInfoWindow({ position }: { position: { lat: number; lng: number } }) { @@ -58,7 +35,7 @@ function MarkerWithInfoWindow({ position }: { position: { lat: number; lng: numb <div className="flex items-center justify-between"> <strong className="text-sm">{appConfig.company.name}</strong> <Button onClick={() => setInfowindowOpen(false)} size="micro" variant="ghost"> - <XIcon className="icon-sm" /> + <XIcon className="size-3.5" /> </Button> </div> <span className="block">{appConfig.company.streetAddress}</span> @@ -69,9 +46,9 @@ function MarkerWithInfoWindow({ position }: { position: { lat: number; lng: numb className="focus-effect mt-1 flex rounded-md p-1 font-semibold" rel="noreferrer" > - <MilestoneIcon strokeWidth={2.5} className="icon-xs mr-1" /> + <MilestoneIcon strokeWidth={2.5} className="mr-1 size-3" /> {t('c:get_directions')} - <ArrowUpRightIcon className="icon-xs ml-1 opacity-50" /> + <ArrowUpRightIcon className="ml-1 size-3 opacity-50" /> </a> </div> </InfoWindow> @@ -80,26 +57,17 @@ function MarkerWithInfoWindow({ position }: { position: { lat: number; lng: numb ); } -type CustomZoomControlProps = { - controlPosition: ControlPosition; - zoom: number; - onZoomChange: (zoom: number) => void; -}; +type CustomZoomControlProps = { controlPosition: ControlPosition; zoom: number; onZoomChange: (zoom: number) => void }; function CustomZoomControl({ controlPosition, zoom, onZoomChange }: CustomZoomControlProps) { return ( <MapControl position={controlPosition}> <div className="m-2 flex flex-col p-1"> - <Button - onClick={() => onZoomChange(zoom + 0.5)} - size="micro" - variant="outlineGhost" - className="rounded-b-none border-b-0" - > - <PlusIcon className="icon-sm" /> + <Button onClick={() => onZoomChange(zoom + 0.5)} size="micro" variant="outlineGhost" className="rounded-b-none border-b-0"> + <PlusIcon className="size-3.5" /> </Button> <Button onClick={() => onZoomChange(zoom - 0.5)} size="micro" variant="outlineGhost" className="rounded-t-none"> - <MinusIcon className="icon-sm" /> + <MinusIcon className="size-3.5" /> </Button> </div> </MapControl> @@ -120,8 +88,8 @@ function ContactFormMap() { <ErrorNotice boundary="app" error={error as ErrorNoticeError} resetErrorBoundary={resetErrorBoundary} /> )} > - <div className="h-full w-full"> - <div className="h-full w-full overflow-hidden rounded-sm bg-accent"> + <div className="size-full"> + <div className="size-full overflow-hidden rounded-sm bg-accent"> <APIProvider apiKey={appConfig.googleMapsKey} libraries={['marker']}> <AnimatePresence> {hasStarted && ( @@ -131,7 +99,7 @@ function ContactFormMap() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.5, delay: 1 }} - className="h-full w-full" + className="size-full" > <GMap mapId={mapConfig.mapId || null} @@ -143,11 +111,7 @@ function ContactFormMap() { defaultZoom={appConfig.company.mapZoom} > <MarkerWithInfoWindow position={appConfig.company.coordinates} /> - <CustomZoomControl - controlPosition={ControlPosition.LEFT_BOTTOM} - zoom={zoom} - onZoomChange={setZoom} - /> + <CustomZoomControl controlPosition={ControlPosition.LEFT_BOTTOM} zoom={zoom} onZoomChange={setZoom} /> </GMap> </motion.div> )} diff --git a/frontend/src/modules/common/contact-form/contact-form.tsx b/frontend/src/modules/common/contact-form/contact-form.tsx index 5ce2afc20..e781095e4 100644 --- a/frontend/src/modules/common/contact-form/contact-form.tsx +++ b/frontend/src/modules/common/contact-form/contact-form.tsx @@ -10,9 +10,10 @@ import { LegalContact } from '~/modules/auth/legal/legal-contact'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { useFormWithDraft } from '~/modules/common/form-draft/use-draft-form'; import { InputFormField } from '~/modules/common/form-fields/input'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; import { toaster } from '~/modules/common/toaster/toaster'; import { useCreateRequestMutation } from '~/modules/requests/query'; -import { Button, SubmitButton } from '~/modules/ui/button'; +import { Button } from '~/modules/ui/button'; import { Form } from '~/modules/ui/field'; import { useUserStore } from '~/modules/user/user-store'; import { lazyNamed } from '~/utils/lazy-named'; @@ -61,7 +62,7 @@ export function ContactForm({ dialog: isDialog }: { dialog?: boolean }) { return ( <div className="flex w-full flex-col gap-4 md:flex-row md:gap-10"> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="w-full space-y-4 md:space-y-6"> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex w-full flex-col gap-4 md:gap-6"> <InputFormField control={form.control} name="name" diff --git a/frontend/src/modules/common/content-placeholder.tsx b/frontend/src/modules/common/content-placeholder.tsx index dbfce5a53..261b67c9b 100644 --- a/frontend/src/modules/common/content-placeholder.tsx +++ b/frontend/src/modules/common/content-placeholder.tsx @@ -15,21 +15,15 @@ interface Props { const defaultTitleProps = {}; -export function ContentPlaceholder({ - title, - icon: Icon, - className = '', - children, - titleProps = defaultTitleProps, -}: Props) { +export function ContentPlaceholder({ title, icon: Icon, className = '', children, titleProps = defaultTitleProps }: Props) { const { t } = useTranslation(); const titleText = t(title, titleProps as Record<string, unknown>); return ( - <div className={cn('relative flex h-full w-full flex-col items-center justify-center p-8 text-center', className)}> + <div className={cn('relative flex size-full flex-col items-center justify-center p-8 text-center', className)}> {Icon && <Icon strokeWidth={0.7} className="size-20 opacity-50" />} - <p className="mt-4 text-sm opacity-60">{titleText}</p> + <p className="mt-4 text-muted-foreground text-sm">{titleText}</p> {children && <div className="mt-8">{children}</div>} </div> ); diff --git a/frontend/src/modules/common/country-flag.tsx b/frontend/src/modules/common/country-flag.tsx index ac25533cd..f6b7bd155 100644 --- a/frontend/src/modules/common/country-flag.tsx +++ b/frontend/src/modules/common/country-flag.tsx @@ -14,10 +14,8 @@ export function CountryFlag({ countryCode, className, imgType = 'svg', width = 1 if (typeof countryCode !== 'string') return null; if (countryCode.toLowerCase() === 'en') countryCode = 'gb'; - const flagUrl = - imgType === 'svg' - ? `/static/common/flags/${countryCode.toLowerCase()}.svg` - : `/static/common/flags/png/${countryCode.toLowerCase()}.png`; + const code = countryCode.toLowerCase(); + const flagUrl = imgType === 'svg' ? `/static/common/flags/${code}.svg` : `/static/common/flags/png/${code}.png`; if (!isOnline) return null; return ( diff --git a/frontend/src/modules/common/data-grid/cell-renderers/render-checkbox.tsx b/frontend/src/modules/common/data-grid/cell-renderers/render-checkbox.tsx index cec35ba5c..3b90cb280 100644 --- a/frontend/src/modules/common/data-grid/cell-renderers/render-checkbox.tsx +++ b/frontend/src/modules/common/data-grid/cell-renderers/render-checkbox.tsx @@ -2,7 +2,7 @@ import { useRef } from 'react'; import { Checkbox } from '~/modules/ui/checkbox'; import type { RenderCheckboxProps } from '../types'; -export function RenderCheckbox({ onChange, indeterminate: _indeterminate, ...props }: RenderCheckboxProps) { +export function RenderCheckbox({ onChange, ...props }: RenderCheckboxProps) { const withShift = useRef(false); const handleChange = (checked: boolean) => { diff --git a/frontend/src/modules/common/data-grid/cell-renderers/render-enum-select.tsx b/frontend/src/modules/common/data-grid/cell-renderers/render-enum-select.tsx index bd72ddd61..c0b3e3b55 100644 --- a/frontend/src/modules/common/data-grid/cell-renderers/render-enum-select.tsx +++ b/frontend/src/modules/common/data-grid/cell-renderers/render-enum-select.tsx @@ -6,16 +6,9 @@ import { Popover, PopoverContent } from '~/modules/ui/popover'; import type { RenderEditCellProps } from '../types'; /** Enum-editor defaults: no double commit from the portaled popover, and cell content stays visible. */ -export const enumSelectEditorOptions = { - editorType: 'select', - commitOnOutsideClick: false, - displayCellContent: true, -} as const; +export const enumSelectEditorOptions = { editorType: 'select', commitOnOutsideClick: false, displayCellContent: true } as const; -export type EnumSelectOption<TValue extends string> = { - value: TValue; - label: ReactNode; -}; +export type EnumSelectOption<TValue extends string> = { value: TValue; label: ReactNode }; type Props<TRow, TValue extends string> = Pick<RenderEditCellProps<TRow>, 'onRowChange' | 'onClose'> & { row: TRow; @@ -74,9 +67,7 @@ export function RenderEnumSelect<TRow extends { id: string }, TValue extends str if (!open) onClose(); }; - const menu = ( - <EnumSelectMenu currentValue={currentValue} options={options} renderOption={renderOption} onSelect={handleSelect} /> - ); + const menu = <EnumSelectMenu currentValue={currentValue} options={options} renderOption={renderOption} onSelect={handleSelect} />; if (isMobile) { return ( @@ -104,7 +95,8 @@ export function RenderEnumSelect<TRow extends { id: string }, TValue extends str <PopoverContent anchor={anchor} align="start" - className="z-301 p-0" + positionerClassName="z-301" + className="p-0" // Skip restoration to the replaced anchor cell; EditCell focuses its new cell instance. finalFocus={false} style={{ width }} @@ -147,7 +139,7 @@ function EnumSelectMenu<TValue extends string>({ if (value != null) onSelect(value); }} > - <ComboboxList ref={listRef} className="rounded-lg p-1 outline-none" tabIndex={-1}> + <ComboboxList ref={listRef} className="rounded-lg p-1 outline-hidden" tabIndex={-1}> {normalized.map((opt) => ( <ComboboxItem key={opt.value} value={opt.value} className="flex items-center gap-2"> <span className="flex-1 text-foreground">{opt.label}</span> diff --git a/frontend/src/modules/common/data-grid/cell-renderers/render-expand-toggle.tsx b/frontend/src/modules/common/data-grid/cell-renderers/render-expand-toggle.tsx index 9bb609fe5..00e7145f3 100644 --- a/frontend/src/modules/common/data-grid/cell-renderers/render-expand-toggle.tsx +++ b/frontend/src/modules/common/data-grid/cell-renderers/render-expand-toggle.tsx @@ -1,6 +1,7 @@ import { ChevronRightIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '~/modules/ui/button'; +import { cn } from '~/utils/cn'; export interface RenderExpandToggleProps { expanded: boolean; @@ -146,12 +147,12 @@ export function RenderExpandToggle({ }); return ( - <span className="relative flex h-full w-full items-center justify-center"> + <span className="relative flex size-full items-center justify-center"> {paths.length > 0 && ( // Connectors stretch to the rendered cell for mobile row and rem scaling; the viewBox keeps desktop drawing coordinates. <svg aria-hidden - className="pointer-events-none absolute inset-0 h-full w-full text-input" + className="pointer-events-none absolute inset-0 size-full text-input" viewBox={`0 0 ${COL} ${rowHeight}`} preserveAspectRatio="none" > @@ -193,7 +194,7 @@ export function RenderExpandToggle({ } }} > - <ChevronRightIcon className={`opacity-70 transition-transform ${expanded ? 'rotate-90' : ''}`} /> + <ChevronRightIcon className={cn('opacity-70 transition-transform', expanded && 'rotate-90')} /> </Button> ) : depth > 0 ? ( // Deepest leaf bullets ride the thin track (4px right of center), inner-leaf bullets the solid track (4px left). @@ -203,10 +204,7 @@ export function RenderExpandToggle({ className="absolute top-1/2 left-[calc(50%+4px)] size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-input bg-background" /> ) : ( - <span - aria-hidden - className="absolute top-1/2 left-[calc(50%-4px)] size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-input" - /> + <span aria-hidden className="absolute top-1/2 left-[calc(50%-4px)] size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-input" /> ) ) : null} </span> diff --git a/frontend/src/modules/common/data-grid/cell-renderers/render-external-editor.tsx b/frontend/src/modules/common/data-grid/cell-renderers/render-external-editor.tsx index 0fe1b0154..b6ee11642 100644 --- a/frontend/src/modules/common/data-grid/cell-renderers/render-external-editor.tsx +++ b/frontend/src/modules/common/data-grid/cell-renderers/render-external-editor.tsx @@ -2,11 +2,7 @@ import { type RefObject, useEffect, useRef } from 'react'; import type { RenderEditCellProps } from '../types'; /** External-editor defaults: cell content stays visible during the single frame edit mode lasts. */ -export const externalEditorOptions = { - editorType: 'text', - displayCellContent: true, - commitOnOutsideClick: false, -} as const; +export const externalEditorOptions = { editorType: 'text', displayCellContent: true, commitOnOutsideClick: false } as const; type Props<TRow> = Pick<RenderEditCellProps<TRow>, 'onClose'> & { /** Opens the editor; receives the grid cell so the sheet or dialog can return focus to it. */ @@ -42,9 +38,7 @@ export function liveCellRef(cell: HTMLElement | null): RefObject<HTMLElement | n return { get current() { if (!grid || !rowIndex || !colIndex) return null; - return grid.querySelector<HTMLElement>( - `[role="row"][aria-rowindex="${rowIndex}"] [role="gridcell"][aria-colindex="${colIndex}"]`, - ); + return grid.querySelector<HTMLElement>(`[role="row"][aria-rowindex="${rowIndex}"] [role="gridcell"][aria-colindex="${colIndex}"]`); }, }; } diff --git a/frontend/src/modules/common/data-grid/cell-renderers/render-input.tsx b/frontend/src/modules/common/data-grid/cell-renderers/render-input.tsx index ecd47a36e..4f89670d1 100644 --- a/frontend/src/modules/common/data-grid/cell-renderers/render-input.tsx +++ b/frontend/src/modules/common/data-grid/cell-renderers/render-input.tsx @@ -8,7 +8,7 @@ export function EditCellInput({ className, type, ...props }: React.ComponentProp type={type} data-slot="edit-cell-input" className={cn( - 'h-full w-full min-w-0 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground', + 'size-full min-w-0 bg-transparent px-3 text-sm outline-hidden placeholder:text-muted-foreground', 'selection:bg-primary selection:text-primary-foreground', 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', className, diff --git a/frontend/src/modules/common/data-grid/cell.tsx b/frontend/src/modules/common/data-grid/cell.tsx index 0796bef0c..25558061e 100644 --- a/frontend/src/modules/common/data-grid/cell.tsx +++ b/frontend/src/modules/common/data-grid/cell.tsx @@ -4,7 +4,7 @@ import { useRovingTabIndex } from './hooks'; import type { CalculatedColumn, CellMouseEventHandler, CellRendererProps, MergedSlots, TileSide } from './types'; import { cn, createCellEvent, getCellClassname, getCellStyle, isCellEditableUtil } from './utils/grid-utils'; -const cellInRangeClassname = 'rdg-cell-in-range bg-primary/10 aria-selected:outline-none'; +const cellInRangeClassname = 'rdg-cell-in-range bg-primary/10 aria-selected:outline-hidden'; const cellRangeTopClassname = 'rdg-cell-range-top border-t-2 border-t-primary'; const cellRangeBottomClassname = 'rdg-cell-range-bottom border-b-2 border-b-primary'; const cellRangeLeftClassname = 'rdg-cell-range-left border-l-2 border-l-primary'; @@ -44,11 +44,7 @@ function Cell<R, SR>({ const isEditable = isCellEditableUtil(column, row); // Explicit cursor so the hover affordance matches the editor: I-beam for free text, pointer for pickers. - const editorCursor = isEditable - ? column.editorOptions?.editorType === 'select' - ? 'cursor-pointer' - : 'cursor-text' - : undefined; + const editorCursor = isEditable ? (column.editorOptions?.editorType === 'select' ? 'cursor-pointer' : 'cursor-text') : undefined; className = getCellClassname( column, @@ -122,10 +118,7 @@ function Cell<R, SR>({ aria-readonly={!isEditable || undefined} tabIndex={effectiveTabIndex} className={className} - style={{ - ...getCellStyle(column, colSpan), - ...style, - }} + style={{ ...getCellStyle(column, colSpan), ...style }} onClick={handleClick} onMouseDown={handleMouseDown} onDoubleClick={handleDoubleClick} @@ -171,15 +164,7 @@ interface MergedCellContentProps<R, SR> { } /** Empty slots collapse and render no placeholder. */ -function MergedCellContent<R, SR>({ - column, - slots, - row, - rowIdx, - isCellEditable, - tabIndex, - onRowChange, -}: MergedCellContentProps<R, SR>) { +function MergedCellContent<R, SR>({ column, slots, row, rowIdx, isCellEditable, tabIndex, onRowChange }: MergedCellContentProps<R, SR>) { function renderSide(side: TileSide, sideClassName: string) { const sideSlots = slots[side]; if (sideSlots.length === 0) return null; @@ -203,11 +188,7 @@ function MergedCellContent<R, SR>({ if (children.every((child) => child === null)) return null; return ( - <div - data-tile-slot={side} - data-is-compact="true" - className={cn('flex min-w-0 items-center gap-2', sideClassName)} - > + <div data-tile-slot={side} data-is-compact="true" className={cn('flex min-w-0 items-center gap-2', sideClassName)}> {children} </div> ); @@ -236,13 +217,10 @@ function MergedCellContent<R, SR>({ } /** Renders cell content, falling back to placeholderValue when renderCell returns nullish */ -function renderCellContent<R, SR>( - column: CellRendererProps<R, SR>['column'], - props: Parameters<typeof column.renderCell>[0], -) { +function renderCellContent<R, SR>(column: CellRendererProps<R, SR>['column'], props: Parameters<typeof column.renderCell>[0]) { const content = column.renderCell(props); if (content == null && column.placeholderValue != null) { - return <span className="text-muted-foreground/50">{column.placeholderValue}</span>; + return <span className="text-muted-foreground/70">{column.placeholderValue}</span>; } return content; } diff --git a/frontend/src/modules/common/data-grid/columns.tsx b/frontend/src/modules/common/data-grid/columns.tsx index fb0f9d0fb..d4001a2ba 100644 --- a/frontend/src/modules/common/data-grid/columns.tsx +++ b/frontend/src/modules/common/data-grid/columns.tsx @@ -1,3 +1,4 @@ +import { tw } from '~/utils/tw'; import { RenderCheckbox } from './cell-renderers'; import { useHeaderRowSelection, useRowSelection } from './hooks/use-row-selection'; import type { Column, RenderCellProps, RenderHeaderCellProps } from './types'; @@ -43,10 +44,12 @@ export const SelectColumn: Column<any, any> = { width: 35, minWidth: 35, maxWidth: 35, - cellClass: - 'rdg-cell-checkbox aria-selected:outline-none aria-selected:[&_[data-slot=checkbox]]:ring-2 aria-selected:[&_[data-slot=checkbox]]:ring-ring aria-selected:[&_[data-slot=checkbox]]:ring-offset-2 aria-selected:[&_[data-slot=checkbox]]:ring-offset-background aria-selected:[&_[data-slot=checkbox]]:rounded', - headerCellClass: - 'rdg-cell-checkbox aria-selected:outline-none aria-selected:[&_[data-slot=checkbox]]:ring-2 aria-selected:[&_[data-slot=checkbox]]:ring-ring aria-selected:[&_[data-slot=checkbox]]:ring-offset-2 aria-selected:[&_[data-slot=checkbox]]:ring-offset-background aria-selected:[&_[data-slot=checkbox]]:rounded', + cellClass: tw( + 'rdg-cell-checkbox aria-selected:outline-hidden aria-selected:[&_[data-slot=checkbox]]:rounded aria-selected:[&_[data-slot=checkbox]]:ring-2 aria-selected:[&_[data-slot=checkbox]]:ring-ring aria-selected:[&_[data-slot=checkbox]]:ring-offset-2 aria-selected:[&_[data-slot=checkbox]]:ring-offset-background', + ), + headerCellClass: tw( + 'rdg-cell-checkbox aria-selected:outline-hidden aria-selected:[&_[data-slot=checkbox]]:rounded aria-selected:[&_[data-slot=checkbox]]:ring-2 aria-selected:[&_[data-slot=checkbox]]:ring-ring aria-selected:[&_[data-slot=checkbox]]:ring-offset-2 aria-selected:[&_[data-slot=checkbox]]:ring-offset-background', + ), renderHeaderCell(props) { return <HeaderRenderer {...props} />; }, diff --git a/frontend/src/modules/common/data-grid/data-grid.tsx b/frontend/src/modules/common/data-grid/data-grid.tsx index 5b81367b2..a27458f07 100644 --- a/frontend/src/modules/common/data-grid/data-grid.tsx +++ b/frontend/src/modules/common/data-grid/data-grid.tsx @@ -253,9 +253,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr // defaults const baseRowHeight = rawRowHeight ?? 35; - const headerRowHeight = hideHeader - ? 0 - : (rawHeaderRowHeight ?? (typeof baseRowHeight === 'number' ? baseRowHeight : 35)); + const headerRowHeight = hideHeader ? 0 : (rawHeaderRowHeight ?? (typeof baseRowHeight === 'number' ? baseRowHeight : 35)); const userRenderRow = renderers?.renderRow ?? defaultRenderRow; const userRenderCell = renderers?.renderCell ?? defaultRenderCell; // Latest refs keep the row-drag config stable when consumers pass non-memoized callbacks. @@ -293,11 +291,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr const renderCell = useMemo(() => { if (!rowDragConfig) return userRenderCell; return (key: Key, props: CellRendererProps<R, SR>) => - props.column.rowDragHandle === true ? ( - <RowDragCell<R, SR> key={key} {...props} config={rowDragConfig} /> - ) : ( - userRenderCell(key, props) - ); + props.column.rowDragHandle === true ? <RowDragCell<R, SR> key={key} {...props} config={rowDragConfig} /> : userRenderCell(key, props); }, [rowDragConfig, userRenderCell]); const renderRow = useMemo(() => { if (!rowDragConfig) return userRenderRow; @@ -318,10 +312,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr // 'compact' is the density toggle, 'mobile' is the xs breakpoint; columns key `modes` overrides off both. const isMobileBreakpoint = currentBreakpoint === 'xs'; - const activeModes = useMemo<ActiveModes>( - () => ({ compact: isCompact ?? false, mobile: isMobileBreakpoint }), - [isCompact, isMobileBreakpoint], - ); + const activeModes = useMemo<ActiveModes>(() => ({ compact: isCompact ?? false, mobile: isMobileBreakpoint }), [isCompact, isMobileBreakpoint]); // Disable row selection on the smallest breakpoint (xs) where checkboxes are hidden const effectiveSelectedRows = isMobileBreakpoint ? undefined : selectedRows; @@ -364,25 +355,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr const { gridRef, viewportHeight, scrollTop, measured } = useGridDimensions(undefined, enableRowVirtualization); - const { - columns, - colSpanColumns, - lastFrozenColumnIndex, - headerRowsCount, - templateColumns, - layoutCssVars, - totalFrozenColumnWidth, - } = useCalculatedColumns({ - rawColumns, - defaultColumnOptions, - getColumnWidth, - currentBreakpoint, - activeModes, - }); + const { columns, colSpanColumns, lastFrozenColumnIndex, headerRowsCount, templateColumns, layoutCssVars, totalFrozenColumnWidth } = + useCalculatedColumns({ rawColumns, defaultColumnOptions, getColumnWidth, currentBreakpoint, activeModes }); // Motion-animated reorder needs all four conditions: virtualized rows unmount mid-scroll and break FLIP, index keys defeat DOM persistence, transforms unstick frozen cells. - const animateReorder = - rowDragEnabled && !enableRowVirtualization && typeof rowKeyGetter === 'function' && lastFrozenColumnIndex === -1; + const animateReorder = rowDragEnabled && !enableRowVirtualization && typeof rowKeyGetter === 'function' && lastFrozenColumnIndex === -1; useStickyHeader(gridRef, headerRowsCount, headerRowHeight, enableStickyHeader); @@ -422,9 +399,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr } const getRenderedWidth = (column: CalculatedColumn<R, unknown>) => renderedColumnWidths.get(column.key) ?? (typeof column.width === 'number' ? column.width : column.minWidth); - return (row: R) => - computeWrapTextRowHeight(scaledBase, columns as readonly CalculatedColumn<R, unknown>[], row, getRenderedWidth) + - slotExtra; + const wrapColumns = columns as readonly CalculatedColumn<R, unknown>[]; + return (row: R) => computeWrapTextRowHeight(scaledBase, wrapColumns, row, getRenderedWidth) + slotExtra; }, [baseRowHeight, columns, isMobileBreakpoint, renderedColumnWidths]); const groupedColumnHeaderRowsCount = headerRowsCount - 1; @@ -450,10 +426,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr const headerSelectionValue = useMemo((): HeaderRowSelectionContextValue => { if (!isSelectable) { - return { - isRowSelected: false, - isIndeterminate: false, - }; + return { isRowSelected: false, isIndeterminate: false }; } let hasSelectedRow = false; @@ -471,21 +444,10 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr } } - return { - isRowSelected: hasSelectedRow && !hasUnselectedRow, - isIndeterminate: hasSelectedRow && hasUnselectedRow, - }; + return { isRowSelected: hasSelectedRow && !hasUnselectedRow, isIndeterminate: hasSelectedRow && hasUnselectedRow }; }, [rows, effectiveSelectedRows, rowKeyGetter, isSelectable]); - const { - rowOverscanStartIdx, - rowOverscanEndIdx, - totalRowHeight, - gridTemplateRows, - getRowTop, - getRowHeight, - findRowIdx, - } = useViewportRows({ + const { rowOverscanStartIdx, rowOverscanEndIdx, totalRowHeight, gridTemplateRows, getRowTop, getRowHeight, findRowIdx } = useViewportRows({ rows, rowHeight, clientHeight, @@ -498,15 +460,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr gridTemplateColumns, handleColumnResize, handleColumnResizeEnd: handleColumnResizeEndWidths, - } = useColumnWidths( - columns, - templateColumns, - gridRef, - columnWidths, - onColumnWidthsChange, - onColumnResize, - setColumnResizing, - ); + } = useColumnWidths(columns, templateColumns, gridRef, columnWidths, onColumnWidthsChange, onColumnResize, setColumnResizing); const maxColIdx = columns.length - 1; const selectedCellIsWithinSelectionBounds = isCellWithinSelectionBounds(selectedPosition); @@ -672,13 +626,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr } }, [selectedPosition.mode]); - useNearEnd({ - totalRows: rows.length, - rowOverscanEndIdx, - measured, - onNearEndChange, - threshold: rawNearEndThreshold, - }); + useNearEnd({ totalRows: rows.length, rowOverscanEndIdx, measured, onNearEndChange, threshold: rawNearEndThreshold }); // event handlers function selectHeaderRow(args: SelectHeaderRowEvent) { @@ -740,16 +688,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr if (onCellKeyDown && isRowIdxWithinViewportBounds(rowIdx)) { const row = rows[rowIdx]; const cellEvent = createCellEvent(event); - onCellKeyDown( - { - mode: 'SELECT', - row, - column: columns[idx], - rowIdx, - selectCell, - }, - cellEvent, - ); + onCellKeyDown({ mode: 'SELECT', row, column: columns[idx], rowIdx, selectCell }, cellEvent); if (cellEvent.isGridDefaultPrevented()) return; } @@ -785,10 +724,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr if (typeof onRowsChange !== 'function') return; if (row === rows[rowIdx]) return; const updatedRows = rows.with(rowIdx, row); - onRowsChange(updatedRows, { - indexes: [rowIdx], - column, - }); + onRowsChange(updatedRows, { indexes: [rowIdx], column }); } function commitEditorChanges() { @@ -849,13 +785,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr } if (isCellEditable(selectedPosition) && isDefaultCellInput(event, onCellPaste != null)) { - setSelectedPosition(({ idx, rowIdx }) => ({ - idx, - rowIdx, - mode: 'EDIT', - row, - originalRow: row, - })); + setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'EDIT', row, originalRow: row })); } } @@ -936,15 +866,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr const { key, shiftKey } = event; let cellNavigationMode: CellNavigationMode = 'NONE'; if (key === 'Tab') { - if ( - canExitGrid({ - shiftKey, - maxColIdx, - minRowIdx, - maxRowIdx, - selectedPosition, - }) - ) { + if (canExitGrid({ shiftKey, maxColIdx, minRowIdx, maxRowIdx, selectedPosition })) { commitEditorChanges(); // Allow focus to leave the grid so the next control in the tab order can be focused return; @@ -976,18 +898,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr isCellWithinBounds: isCellWithinSelectionBounds, }); - selectCell(nextSelectedCellPosition, { - shouldFocusCell: true, - extendSelection: cellSelectionMode === 'cell-range' && shiftKey, - }); + selectCell(nextSelectedCellPosition, { shouldFocusCell: true, extendSelection: cellSelectionMode === 'cell-range' && shiftKey }); } function getCellEditor(rowIdx: number) { - if ( - !isCellWithinViewportBounds(selectedPosition) || - selectedPosition.rowIdx !== rowIdx || - selectedPosition.mode === 'SELECT' - ) { + if (!isCellWithinViewportBounds(selectedPosition) || selectedPosition.rowIdx !== rowIdx || selectedPosition.mode === 'SELECT') { return; } @@ -1043,18 +958,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr const { idx: selectedIdx, rowIdx: selectedRowIdx } = selectedPosition; - const startRowIdx = - selectedCellIsWithinViewportBounds && selectedRowIdx < rowOverscanStartIdx - ? rowOverscanStartIdx - 1 - : rowOverscanStartIdx; - const endRowIdx = - selectedCellIsWithinViewportBounds && selectedRowIdx > rowOverscanEndIdx - ? rowOverscanEndIdx + 1 - : rowOverscanEndIdx; + const startRowIdx = selectedCellIsWithinViewportBounds && selectedRowIdx < rowOverscanStartIdx ? rowOverscanStartIdx - 1 : rowOverscanStartIdx; + const endRowIdx = selectedCellIsWithinViewportBounds && selectedRowIdx > rowOverscanEndIdx ? rowOverscanEndIdx + 1 : rowOverscanEndIdx; for (let viewportRowIdx = startRowIdx; viewportRowIdx <= endRowIdx; viewportRowIdx++) { - const isRowOutsideViewport = - viewportRowIdx === rowOverscanStartIdx - 1 || viewportRowIdx === rowOverscanEndIdx + 1; + const isRowOutsideViewport = viewportRowIdx === rowOverscanStartIdx - 1 || viewportRowIdx === rowOverscanEndIdx + 1; const rowIdx = isRowOutsideViewport ? selectedRowIdx : viewportRowIdx; let rowColumns = columns; @@ -1138,14 +1046,13 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr { 'rdg-readonly': readOnly, // Row-body clicks select rows, so the row outline replaces the per-cell one. - 'rdg-row-selection [&_.rdg-cell]:aria-selected:outline-none': rowSelectionMode !== 'none', + 'rdg-row-selection [&_.rdg-cell]:aria-selected:outline-hidden': rowSelectionMode !== 'none', }, className, )} style={{ // set scrollPadding to correctly position non-sticky cells after scrolling - scrollPaddingInlineStart: - selectedPosition.idx > lastFrozenColumnIndex ? `${totalFrozenColumnWidth}px` : undefined, + scrollPaddingInlineStart: selectedPosition.idx > lastFrozenColumnIndex ? `${totalFrozenColumnWidth}px` : undefined, scrollPaddingBlock: isRowIdxWithinViewportBounds(selectedPosition.rowIdx) ? `${headerRowsHeight}px` : undefined, gridTemplateColumns, gridTemplateRows: templateRows, diff --git a/frontend/src/modules/common/data-grid/edit-cell.tsx b/frontend/src/modules/common/data-grid/edit-cell.tsx index 7b80ebf4b..f898b170c 100644 --- a/frontend/src/modules/common/data-grid/edit-cell.tsx +++ b/frontend/src/modules/common/data-grid/edit-cell.tsx @@ -1,23 +1,14 @@ import { useEffectEvent, useLayoutEffect, useRef } from 'react'; -import type { - CellKeyboardEvent, - CellRendererProps, - EditCellKeyDownArgs, - Maybe, - Omit, - RenderEditCellProps, -} from './types'; +import type { CellKeyboardEvent, CellRendererProps, EditCellKeyDownArgs, Maybe, Omit, RenderEditCellProps } from './types'; import { createCellEvent, getCellClassname, getCellStyle, onEditorNavigation } from './utils/grid-utils'; const canUsePostTask = typeof scheduler !== 'undefined' && typeof scheduler.postTask === 'function'; -const cellEditingClassname = '!p-0 [&>input]:border-0 [&>input]:shadow-none [&>input]:bg-transparent'; +const cellEditingClassname = 'p-0! [&>input]:border-0 [&>input]:shadow-none [&>input]:bg-transparent'; type SharedCellRendererProps<R, SR> = Pick<CellRendererProps<R, SR>, 'colSpan'>; -interface EditCellProps<R, SR> - extends Omit<RenderEditCellProps<R, SR>, 'onRowChange' | 'onClose'>, - SharedCellRendererProps<R, SR> { +interface EditCellProps<R, SR> extends Omit<RenderEditCellProps<R, SR>, 'onRowChange' | 'onClose'>, SharedCellRendererProps<R, SR> { rowIdx: number; onRowChange: (row: R, commitChanges: boolean, shouldFocusCell: boolean) => void; closeEditor: (shouldFocusCell: boolean) => void; @@ -29,16 +20,7 @@ interface EditCellProps<R, SR> * Commits outside mousedown events after capture reaches portals and before blur unmounts the editor. * A scheduled fallback handles events whose propagation stops before returning to window. */ -export function EditCell<R, SR>({ - column, - colSpan, - row, - rowIdx, - onRowChange, - closeEditor, - onKeyDown, - navigate, -}: EditCellProps<R, SR>) { +export function EditCell<R, SR>({ column, colSpan, row, rowIdx, onRowChange, closeEditor, onKeyDown, navigate }: EditCellProps<R, SR>) { const captureEventRef = useRef<MouseEvent | undefined>(undefined); const abortControllerRef = useRef<AbortController>(undefined); const frameRequestRef = useRef<number>(undefined); @@ -158,22 +140,9 @@ export function EditCell<R, SR>({ > {column.renderEditCell != null && ( <> - {column.renderEditCell({ - column, - row, - rowIdx, - onRowChange: onEditorRowChange, - onClose, - })} + {column.renderEditCell({ column, row, rowIdx, onRowChange: onEditorRowChange, onClose })} {column.editorOptions?.displayCellContent && - column.renderCell({ - column, - row, - rowIdx, - isCellEditable: true, - tabIndex: -1, - onRowChange: onEditorRowChange, - })} + column.renderCell({ column, row, rowIdx, isCellEditable: true, tabIndex: -1, onRowChange: onEditorRowChange })} </> )} </div> diff --git a/frontend/src/modules/common/data-grid/grouped-column-header-cell.tsx b/frontend/src/modules/common/data-grid/grouped-column-header-cell.tsx index 2bdb0fcdd..b3b48fa4e 100644 --- a/frontend/src/modules/common/data-grid/grouped-column-header-cell.tsx +++ b/frontend/src/modules/common/data-grid/grouped-column-header-cell.tsx @@ -37,11 +37,7 @@ export function GroupedColumnHeaderCell<R, SR>({ aria-selected={isCellSelected} tabIndex={tabIndex} className={cn('rdg-cell', column.headerCellClass)} - style={{ - ...getHeaderCellStyle(column, rowIdx, rowSpan), - gridColumnStart: index, - gridColumnEnd: index + colSpan, - }} + style={{ ...getHeaderCellStyle(column, rowIdx, rowSpan), gridColumnStart: index, gridColumnEnd: index + colSpan }} onFocus={onFocus} onMouseDown={onMouseDown} > diff --git a/frontend/src/modules/common/data-grid/grouped-column-header-row.tsx b/frontend/src/modules/common/data-grid/grouped-column-header-row.tsx index 15cca93bd..9b8c3ec0f 100644 --- a/frontend/src/modules/common/data-grid/grouped-column-header-row.tsx +++ b/frontend/src/modules/common/data-grid/grouped-column-header-row.tsx @@ -60,8 +60,6 @@ function GroupedColumnHeaderRow<R, SR>({ ); } -const GroupedColumnHeaderRowMemo = memo(GroupedColumnHeaderRow) as <R, SR>( - props: GroupedColumnHeaderRowProps<R, SR>, -) => React.JSX.Element; +const GroupedColumnHeaderRowMemo = memo(GroupedColumnHeaderRow) as <R, SR>(props: GroupedColumnHeaderRowProps<R, SR>) => React.JSX.Element; export { GroupedColumnHeaderRowMemo as GroupedColumnHeaderRow }; diff --git a/frontend/src/modules/common/data-grid/header-cell.tsx b/frontend/src/modules/common/data-grid/header-cell.tsx index a8afdbf61..a025be219 100644 --- a/frontend/src/modules/common/data-grid/header-cell.tsx +++ b/frontend/src/modules/common/data-grid/header-cell.tsx @@ -19,7 +19,7 @@ import { } from './utils/grid-utils'; const resizeHandleClassname = - 'cursor-col-resize absolute inset-y-0 end-0 w-4 after:content-[""] after:absolute after:top-1/2 after:-translate-y-1/2 after:end-0 after:h-4 after:w-0.5 after:rounded-full after:bg-foreground/30 hover:after:bg-primary/80'; + 'cursor-col-resize absolute inset-y-0 inset-e-0 w-4 after:content-[""] after:absolute after:top-1/2 after:-translate-y-1/2 after:inset-e-0 after:h-4 after:w-0.5 after:rounded-full after:bg-foreground/30 hover:after:bg-primary/80'; const draggableColumnType = 'grid-column'; type ColumnDragData = { type: typeof draggableColumnType; columnKey: string }; @@ -79,15 +79,9 @@ export function HeaderCell<R, SR>({ column, 'border-t-0', column.headerCellClass, - { - 'cursor-pointer': sortable, - 'touch-action-none': resizable, - 'opacity-40': isDragging, - 'z-3': column.frozen, - }, + { 'cursor-pointer': sortable, 'touch-none': resizable, 'opacity-40': isDragging, 'z-3': column.frozen }, // aria-selected is never true without cell selection, so the focus outline comes from :focus-visible. - !isCellSelectionEnabled && - 'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-solid focus-visible:-outline-offset-2', + !isCellSelectionEnabled && 'focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-solid focus-visible:-outline-offset-2', ); useEffect(() => { @@ -136,21 +130,12 @@ export function HeaderCell<R, SR>({ if (onSortColumnsChange == null) return; const { sortDescendingFirst } = column; if (sortColumn === undefined) { - const nextSort: SortColumn = { - columnKey: column.key, - direction: sortDescendingFirst ? 'DESC' : 'ASC', - }; + const nextSort: SortColumn = { columnKey: column.key, direction: sortDescendingFirst ? 'DESC' : 'ASC' }; onSortColumnsChange(sortColumns && ctrlClick ? [...sortColumns, nextSort] : [nextSort]); } else { let nextSortColumn: SortColumn | undefined; - if ( - (sortDescendingFirst === true && sortDirection === 'DESC') || - (sortDescendingFirst !== true && sortDirection === 'ASC') - ) { - nextSortColumn = { - columnKey: column.key, - direction: sortDirection === 'ASC' ? 'DESC' : 'ASC', - }; + if ((sortDescendingFirst === true && sortDirection === 'DESC') || (sortDescendingFirst !== true && sortDirection === 'ASC')) { + nextSortColumn = { columnKey: column.key, direction: sortDirection === 'ASC' ? 'DESC' : 'ASC' }; } if (ctrlClick) { const nextSortColumns = [...sortColumns!]; @@ -201,17 +186,9 @@ export function HeaderCell<R, SR>({ } } - const style: React.CSSProperties = { - ...getHeaderCellStyle(column, rowIdx, rowSpan), - ...getCellStyle(column, colSpan), - }; + const style: React.CSSProperties = { ...getHeaderCellStyle(column, rowIdx, rowSpan), ...getCellStyle(column, colSpan) }; - const content = column.renderHeaderCell({ - column, - sortDirection, - priority, - tabIndex: childTabIndex, - }); + const content = column.renderHeaderCell({ column, sortDirection, priority, tabIndex: childTabIndex }); return ( <> @@ -233,16 +210,10 @@ export function HeaderCell<R, SR>({ > {content} - {resizable && ( - <ResizeHandle column={column} onColumnResize={onColumnResize} onColumnResizeEnd={onColumnResizeEnd} /> - )} + {resizable && <ResizeHandle column={column} onColumnResize={onColumnResize} onColumnResizeEnd={onColumnResizeEnd} />} </div> {closestEdge && <DropIndicator edge={closestEdge} gap={0} />} - {preview && - createPortal( - <div className="rounded border bg-background px-3 py-1.5 text-sm shadow-lg">{column.name}</div>, - preview.container, - )} + {preview && createPortal(<div className="rounded border bg-background px-3 py-1.5 text-sm shadow-lg">{column.name}</div>, preview.container)} </> ); } diff --git a/frontend/src/modules/common/data-grid/header-row.tsx b/frontend/src/modules/common/data-grid/header-row.tsx index eb366e3cd..b748301b9 100644 --- a/frontend/src/modules/common/data-grid/header-row.tsx +++ b/frontend/src/modules/common/data-grid/header-row.tsx @@ -19,7 +19,7 @@ export interface HeaderRowProps<R, SR> { headerRowClass: Maybe<string>; } -export const headerRowClassname = 'rdg-header-row contents font-semibold text-foreground/70'; +export const headerRowClassname = 'rdg-header-row contents font-semibold text-muted-foreground'; function HeaderRow<R, SR>({ headerRowClass, diff --git a/frontend/src/modules/common/data-grid/hooks/use-calculated-columns.ts b/frontend/src/modules/common/data-grid/hooks/use-calculated-columns.ts index 637831e5f..51dd234e6 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-calculated-columns.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-calculated-columns.ts @@ -15,13 +15,7 @@ import type { Omit, TileSide, } from '../types'; -import { - breakpointOrder, - clampColumnWidth, - resolveMergeRule, - resolveModeOverrides, - warnInvalidMergeRule, -} from '../utils/grid-utils'; +import { breakpointOrder, clampColumnWidth, resolveMergeRule, resolveModeOverrides, warnInvalidMergeRule } from '../utils/grid-utils'; type Mutable<T> = { -readonly [P in keyof T]: T[P] extends ReadonlyArray<infer V> ? Mutable<V>[] : T[P]; @@ -111,11 +105,7 @@ export function useCalculatedColumns<R, SR>({ collectColumns(rawColumns, 1); - function collectColumns( - rawColumns: readonly ColumnOrColumnGroup<R, SR>[], - level: number, - parent?: MutableCalculatedColumnParent<R, SR>, - ) { + function collectColumns(rawColumns: readonly ColumnOrColumnGroup<R, SR>[], level: number, parent?: MutableCalculatedColumnParent<R, SR>) { for (const rawColumn of rawColumns) { // Reactive hide flag, such as a column-visibility toggle: excluded like a failing breakpoint. if (rawColumn.hidden) continue; @@ -225,12 +215,7 @@ export function useCalculatedColumns<R, SR>({ } } - return { - columns, - colSpanColumns, - lastFrozenColumnIndex, - headerRowsCount, - }; + return { columns, colSpanColumns, lastFrozenColumnIndex, headerRowsCount }; }, [ rawColumns, defaultWidth, @@ -299,22 +284,10 @@ export function useCalculatedColumns<R, SR>({ return { templateColumns, layoutCssVars, totalFrozenColumnWidth }; }, [getColumnWidth, columns, lastFrozenColumnIndex]); - return { - columns, - colSpanColumns, - templateColumns, - layoutCssVars, - headerRowsCount, - lastFrozenColumnIndex, - totalFrozenColumnWidth, - }; + return { columns, colSpanColumns, templateColumns, layoutCssVars, headerRowsCount, lastFrozenColumnIndex, totalFrozenColumnWidth }; } -function updateColumnParent<R, SR>( - column: MutableCalculatedColumn<R, SR> | MutableCalculatedColumnParent<R, SR>, - index: number, - level: number, -) { +function updateColumnParent<R, SR>(column: MutableCalculatedColumn<R, SR> | MutableCalculatedColumnParent<R, SR>, index: number, level: number) { if (level < column.level) { column.level = level; } diff --git a/frontend/src/modules/common/data-grid/hooks/use-column-widths.ts b/frontend/src/modules/common/data-grid/hooks/use-column-widths.ts index b8033b5fa..ece596f47 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-column-widths.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-column-widths.ts @@ -27,10 +27,7 @@ export function useColumnWidths<R, SR>( onColumnResize: Maybe<(column: CalculatedColumn<R, SR>, width: number) => void>, setColumnResizing: (isColumnResizing: boolean) => void, ) { - const [columnToAutoResize, setColumnToAutoResize] = useState<{ - readonly key: string; - readonly width: 'max-content'; - } | null>(null); + const [columnToAutoResize, setColumnToAutoResize] = useState<{ readonly key: string; readonly width: 'max-content' } | null>(null); const resizeSnapshotRef = useRef<ResizeSnapshot<R, SR> | null>(null); @@ -104,10 +101,7 @@ export function useColumnWidths<R, SR>( for (const col of columns) { const w = redistributed.get(col.key); if (w !== undefined) { - newColumnWidths.set(col.key, { - type: col.key === column.key ? 'resized' : 'measured', - width: w, - }); + newColumnWidths.set(col.key, { type: col.key === column.key ? 'resized' : 'measured', width: w }); } } @@ -131,21 +125,14 @@ export function useColumnWidths<R, SR>( onColumnWidthsChange(newColumnWidths); } - return { - gridTemplateColumns, - handleColumnResize, - handleColumnResizeEnd, - } as const; + return { gridTemplateColumns, handleColumnResize, handleColumnResizeEnd } as const; } /** Redistribute drag width through the nearest right column, then other columns within minimums. */ function redistributeWidths<R, SR>(snapshot: ResizeSnapshot<R, SR>, rawWidth: number): Map<string, number> { const { resizingCol, initialWidth, allWidths, rightCols, leftCols } = snapshot; - const resizedWidth = max( - resizingCol.minWidth, - resizingCol.maxWidth != null ? min(rawWidth, resizingCol.maxWidth) : rawWidth, - ); + const resizedWidth = max(resizingCol.minWidth, resizingCol.maxWidth != null ? min(rawWidth, resizingCol.maxWidth) : rawWidth); const delta = resizedWidth - initialWidth; const overflow = max(0, resizingCol.minWidth - rawWidth); @@ -171,11 +158,7 @@ function redistributeWidths<R, SR>(snapshot: ResizeSnapshot<R, SR>, rawWidth: nu } /** Shrinks the nearest neighbor first, then the rest proportionally, and returns the amount actually shrunk. */ -function shrinkColumns<R, SR>( - cols: readonly CalculatedColumn<R, SR>[], - widths: Map<string, number>, - amount: number, -): number { +function shrinkColumns<R, SR>(cols: readonly CalculatedColumn<R, SR>[], widths: Map<string, number>, amount: number): number { if (cols.length === 0 || amount <= 0) return 0; let remaining = amount; @@ -204,11 +187,7 @@ function shrinkColumns<R, SR>( } /** Grow columns equally by distributing `amount`. Reads current widths so growth stacks. */ -function growColumns<R, SR>( - cols: readonly CalculatedColumn<R, SR>[], - widths: Map<string, number>, - amount: number, -): void { +function growColumns<R, SR>(cols: readonly CalculatedColumn<R, SR>[], widths: Map<string, number>, amount: number): void { if (cols.length === 0 || amount <= 0) return; const share = amount / cols.length; for (const col of cols) { diff --git a/frontend/src/modules/common/data-grid/hooks/use-copy-paste.ts b/frontend/src/modules/common/data-grid/hooks/use-copy-paste.ts index f6c264c83..39b4b035a 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-copy-paste.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-copy-paste.ts @@ -71,11 +71,7 @@ export function useCopyPaste<R, SR>({ if (rowIdx < 0 || rowIdx >= rows.length || idx < 0 || idx >= columns.length) { return null; } - return { - column: columns[idx], - row: rows[rowIdx], - rowIdx, - }; + return { column: columns[idx], row: rows[rowIdx], rowIdx }; }, [selectedPosition, rows, columns]); const handleCopy = useCallback( @@ -125,13 +121,7 @@ export function useCopyPaste<R, SR>({ const { idx, rowIdx } = selectedPosition; if (rowIdx < 0 || idx < 0) return; - const affectedCells: Array<{ - row: R; - column: CalculatedColumn<R, SR>; - rowIdx: number; - colIdx: number; - value: string; - }> = []; + const affectedCells: Array<{ row: R; column: CalculatedColumn<R, SR>; rowIdx: number; colIdx: number; value: string }> = []; for (let r = 0; r < parsedCells.length; r++) { const targetRowIdx = rowIdx + r; @@ -151,17 +141,10 @@ export function useCopyPaste<R, SR>({ } } - const updatedRows = onPasteRange({ - startPosition: { idx, rowIdx }, - values: parsedCells, - affectedCells, - }); + const updatedRows = onPasteRange({ startPosition: { idx, rowIdx }, values: parsedCells, affectedCells }); if (updatedRows && onRowsChange) { - const updates = updatedRows.map((row, i) => ({ - rowIdx: rowIdx + Math.floor(i / parsedCells[0].length), - row, - })); + const updates = updatedRows.map((row, i) => ({ rowIdx: rowIdx + Math.floor(i / parsedCells[0].length), row })); onRowsChange(updates); event.preventDefault(); } @@ -236,10 +219,5 @@ export function useCopyPaste<R, SR>({ } }, [getSelectedCell, onPaste, onRowChange]); - return { - handleCopy, - handlePaste, - copyToClipboard, - pasteFromClipboard, - }; + return { handleCopy, handlePaste, copyToClipboard, pasteFromClipboard }; } diff --git a/frontend/src/modules/common/data-grid/hooks/use-drag-auto-scroll.ts b/frontend/src/modules/common/data-grid/hooks/use-drag-auto-scroll.ts index c20538e3d..d1f6c4610 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-drag-auto-scroll.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-drag-auto-scroll.ts @@ -1,7 +1,4 @@ -import { - autoScrollForElements, - autoScrollWindowForElements, -} from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { autoScrollForElements, autoScrollWindowForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; import { type RefObject, useEffect, useRef } from 'react'; /** Nearest vertically-scrollable ancestor, matching `getScrollParent` in `useGridDimensions`. */ @@ -30,13 +27,8 @@ export function useDragAutoScroll(gridRef: RefObject<HTMLDivElement | null>, ena scrollParentRef.current = findScrollParent(grid.parentElement); if (scrollParentRef.current) { - return autoScrollForElements({ - element: scrollParentRef.current, - getAllowedAxis: () => 'vertical', - }); + return autoScrollForElements({ element: scrollParentRef.current, getAllowedAxis: () => 'vertical' }); } - return autoScrollWindowForElements({ - getAllowedAxis: () => 'vertical', - }); + return autoScrollWindowForElements({ getAllowedAxis: () => 'vertical' }); }, [enabled, gridRef]); } diff --git a/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.test.tsx b/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.test.tsx new file mode 100644 index 000000000..e53f89b57 --- /dev/null +++ b/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGridDimensions } from './use-grid-dimensions'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const gridHeight = 2000; +let gridTop = 100; +const renders: { scrollTop: number; viewportHeight: number; measured: boolean }[] = []; + +function Probe() { + const { gridRef, scrollTop, viewportHeight, measured } = useGridDimensions(); + renders.push({ scrollTop, viewportHeight, measured }); + return <div ref={gridRef} />; +} + +let root: Root | undefined; + +/** Moves the grid relative to the viewport, as a window scroll does, and runs the scroll listener. */ +function scrollTo(top: number) { + gridTop = top; + act(() => window.dispatchEvent(new Event('scroll'))); +} + +describe('useGridDimensions with window scroll', () => { + beforeEach(() => { + gridTop = 100; + renders.length = 0; + vi.stubGlobal('ResizeObserver', class {}); + // Frames run synchronously so each scroll event measures at once. + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ top: gridTop, bottom: gridTop + gridHeight, height: gridHeight, left: 0, right: 400, width: 400 }) as DOMRect, + ); + root = createRoot(document.body.appendChild(document.createElement('div'))); + act(() => root?.render(<Probe />)); + }); + + afterEach(() => { + act(() => root?.unmount()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('commits the first measurement, then only scroll steps that move the row window', () => { + expect(renders.at(-1)).toEqual({ scrollTop: 0, viewportHeight: window.innerHeight, measured: true }); + const measuredRenders = renders.length; + + // Scrolling while the grid is below the viewport top keeps scrollTop at 0. + scrollTo(40); + expect(renders.length).toBe(measuredRenders); + + scrollTo(-50); + expect(renders.at(-1)?.scrollTop).toBe(64); + const steppedRenders = renders.length; + + // Within the same step: no new snapshot. + scrollTo(-60); + scrollTo(-70); + expect(renders.length).toBe(steppedRenders); + }); + + it('clamps to the grid height once the grid has scrolled out of view', () => { + scrollTo(-5000); + const clamped = renders.at(-1)?.scrollTop; + expect(clamped).toBeGreaterThanOrEqual(gridHeight - 16); + expect(clamped).toBeLessThanOrEqual(gridHeight + 16); + const clampedRenders = renders.length; + + scrollTo(-6000); + scrollTo(-9000); + expect(renders.length).toBe(clampedRenders); + }); +}); diff --git a/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.ts b/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.ts index 6e10a5515..65b62b9f8 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-grid-dimensions.ts @@ -3,8 +3,8 @@ import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from 'reac interface GridDimensions { viewportHeight: number; + /** Scroll offset into the grid, clamped to its height and rounded to SCROLL_STEP. */ scrollTop: number; - gridRect: DOMRect | null; /** False until the first layout measurement commits; the placeholder numbers below are not viewport geometry. */ measured: boolean; } @@ -13,12 +13,13 @@ interface GridDimensionsResult extends GridDimensions { gridRef: RefObject<HTMLDivElement | null>; } -const initialDimensions: GridDimensions = { - viewportHeight: 1, - scrollTop: 0, - gridRect: null, - measured: false, -}; +const initialDimensions: GridDimensions = { viewportHeight: 1, scrollTop: 0, measured: false }; + +/** + * scrollTop is rounded to this step, so scrolling commits a snapshot at most once per step. The row window shifts by at + * most half a step (16px), well inside the four-row overscan of useViewportRows (140px at the 35px default row height). + */ +const SCROLL_STEP = 32; /** Nearest scrollable ancestor, or null when the window or document is the scroll container. */ function getScrollParent(node: HTMLElement): HTMLElement | null { @@ -35,10 +36,7 @@ function getScrollParent(node: HTMLElement): HTMLElement | null { } /** Row-virtualization dimensions from an explicit or nearest scroll container; CSS owns column sizing. */ -export function useGridDimensions( - scrollContainerRef?: RefObject<HTMLElement | null>, - enableRowVirtualization = true, -): GridDimensionsResult { +export function useGridDimensions(scrollContainerRef?: RefObject<HTMLElement | null>, enableRowVirtualization = true): GridDimensionsResult { const gridRef = useRef<HTMLDivElement>(null); const snapshotRef = useRef<GridDimensions>(initialDimensions); // The notifier from useSyncExternalStore's subscribe lives in a ref, so the layout effect does not re-run when subscribe is re-invoked. @@ -81,37 +79,20 @@ export function useGridDimensions( rafId = requestAnimationFrame(fn); }; - /** Read scroll-related measurements and merge into previous state */ + /** Read scroll measurements; returns `prev` while the inputs of the row window are unchanged. */ const measureScroll = (prev: GridDimensions): GridDimensions => { const rect = grid.getBoundingClientRect(); const viewportHeight = isWindowScroll ? window.innerHeight : scrollContainer.clientHeight; + const viewportTop = isWindowScroll ? 0 : scrollContainer.getBoundingClientRect().top; - let scrollTop: number; - if (isWindowScroll) { - scrollTop = Math.max(0, -rect.top); - } else { - const containerRect = scrollContainer.getBoundingClientRect(); - scrollTop = Math.max(0, containerRect.top - rect.top); - } + // Clamped to the grid's own range, so scrolling while the grid is out of view leaves the snapshot unchanged. + const offset = Math.min(Math.max(0, viewportTop - rect.top), rect.height); + const scrollTop = Math.round(offset / SCROLL_STEP) * SCROLL_STEP; // Skip rerenders when nothing changed, but never while unmeasured: the first measurement must commit even if it matches the placeholders. - if ( - prev.measured && - prev.viewportHeight === viewportHeight && - Math.abs(prev.scrollTop - scrollTop) < 1 && - prev.gridRect?.top === rect.top && - prev.gridRect?.left === rect.left - ) { - return prev; - } + if (prev.measured && prev.viewportHeight === viewportHeight && prev.scrollTop === scrollTop) return prev; - return { - ...prev, - viewportHeight, - scrollTop, - gridRect: rect, - measured: true, - }; + return { viewportHeight, scrollTop, measured: true }; }; // --- Initial synchronous measurement --- @@ -148,8 +129,5 @@ export function useGridDimensions( }; }, [scrollContainerRef, enableRowVirtualization]); - return { - gridRef, - ...dimensions, - }; + return { gridRef, ...dimensions }; } diff --git a/frontend/src/modules/common/data-grid/hooks/use-near-end.ts b/frontend/src/modules/common/data-grid/hooks/use-near-end.ts index f99a7073c..b8b75b4f3 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-near-end.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-near-end.ts @@ -14,17 +14,8 @@ interface UseNearEndOptions { } /** Level-triggered near-end state for infinite scrolling, so a consumer can retry a load it had to defer. */ -export function useNearEnd({ - totalRows, - rowOverscanEndIdx, - measured, - onNearEndChange, - threshold, -}: UseNearEndOptions): void { - const effectiveThreshold = useMemo( - () => threshold ?? Math.min(50, Math.max(10, Math.floor(totalRows * 0.25))), - [threshold, totalRows], - ); +export function useNearEnd({ totalRows, rowOverscanEndIdx, measured, onNearEndChange, threshold }: UseNearEndOptions): void { + const effectiveThreshold = useMemo(() => threshold ?? Math.min(50, Math.max(10, Math.floor(totalRows * 0.25))), [threshold, totalRows]); // Before measurement the overscan range is a placeholder, not a viewport position, so near-end reports false. const nearEnd = measured && totalRows > 0 && rowOverscanEndIdx >= totalRows - effectiveThreshold; diff --git a/frontend/src/modules/common/data-grid/hooks/use-roving-tab-index.ts b/frontend/src/modules/common/data-grid/hooks/use-roving-tab-index.ts index e2dcf8575..0f52c1aae 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-roving-tab-index.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-roving-tab-index.ts @@ -27,9 +27,5 @@ export function useRovingTabIndex(isSelected: boolean) { const isFocusable = isSelected && !isChildFocused; - return { - tabIndex: isFocusable ? 0 : -1, - childTabIndex: isSelected ? 0 : -1, - onFocus: isSelected ? onFocus : undefined, - }; + return { tabIndex: isFocusable ? 0 : -1, childTabIndex: isSelected ? 0 : -1, onFocus: isSelected ? onFocus : undefined }; } diff --git a/frontend/src/modules/common/data-grid/hooks/use-row-selection.ts b/frontend/src/modules/common/data-grid/hooks/use-row-selection.ts index e8124d9ed..9f461c7a8 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-row-selection.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-row-selection.ts @@ -35,9 +35,7 @@ export interface HeaderRowSelectionContextValue { export const HeaderRowSelectionContext = createContext<HeaderRowSelectionContextValue | undefined>(undefined); -export const HeaderRowSelectionChangeContext = createContext< - ((selectRowEvent: SelectHeaderRowEvent) => void) | undefined ->(undefined); +export const HeaderRowSelectionChangeContext = createContext<((selectRowEvent: SelectHeaderRowEvent) => void) | undefined>(undefined); export function useHeaderRowSelection() { const headerRowSelectionContext = useContext(HeaderRowSelectionContext); diff --git a/frontend/src/modules/common/data-grid/hooks/use-sticky-header.ts b/frontend/src/modules/common/data-grid/hooks/use-sticky-header.ts index bc6f2796c..1f3729cbf 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-sticky-header.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-sticky-header.ts @@ -1,15 +1,8 @@ import type { RefObject } from 'react'; import { useLayoutEffect } from 'react'; -const STICKY_CLASS = 'rdg-header-sticky'; - /** Pins measured header cells to the viewport with fixed positioning, syncing horizontal offsets in animation frames. */ -export function useStickyHeader( - gridRef: RefObject<HTMLDivElement | null>, - headerRowsCount: number, - headerRowHeight: number, - enabled: boolean, -) { +export function useStickyHeader(gridRef: RefObject<HTMLDivElement | null>, headerRowsCount: number, headerRowHeight: number, enabled: boolean) { useLayoutEffect(() => { const grid = gridRef.current; if (!enabled || !grid || headerRowsCount === 0) return; @@ -21,6 +14,9 @@ export function useStickyHeader( let resizeRafId = 0; let resizeSettleTimer = 0; let isSticky = false; + // Grid bounds of the last horizontal sync: vertical scrolling leaves them unchanged, so it skips the per-cell writes. + let syncedLeft = Number.NaN; + let syncedRight = Number.NaN; let headerCells: HTMLElement[] = []; let originalStyles: { cssText: string }[] = []; @@ -48,9 +44,7 @@ export function useStickyHeader( cell.style.backgroundColor = 'var(--background)'; } - // Reserve space so content doesn't jump - grid!.style.setProperty('--rdg-sticky-offset', `${headerRowsHeight}px`); - grid!.classList.add(STICKY_CLASS); + // The grid's template rows keep reserving the header height, so rows don't jump up. isSticky = true; } @@ -58,19 +52,18 @@ export function useStickyHeader( for (let i = 0; i < headerCells.length; i++) { headerCells[i].style.cssText = originalStyles[i].cssText; } - grid!.classList.remove(STICKY_CLASS); - grid!.style.removeProperty('--rdg-sticky-offset'); headerCells = []; originalStyles = []; isSticky = false; } - function syncHorizontal() { + function syncHorizontal(gridRect = grid!.getBoundingClientRect()) { if (!isSticky || headerCells.length === 0) return; - const gridRect = grid!.getBoundingClientRect(); const scrollLeft = grid!.scrollLeft; const gridLeft = gridRect.left; const gridRight = gridRect.right; + syncedLeft = gridLeft; + syncedRight = gridRight; let currentLeft = gridLeft - scrollLeft; for (const cell of headerCells) { @@ -111,12 +104,11 @@ export function useStickyHeader( if (shouldStick && !isSticky) { applyFixed(); + syncHorizontal(rect); } else if (!shouldStick && isSticky) { removeFixed(); - } - - if (isSticky) { - syncHorizontal(); + } else if (isSticky && (rect.left !== syncedLeft || rect.right !== syncedRight)) { + syncHorizontal(rect); } } @@ -128,7 +120,7 @@ export function useStickyHeader( const onScrollHorizontal = () => { if (!isSticky) return; cancelAnimationFrame(hScrollRafId); - hScrollRafId = requestAnimationFrame(syncHorizontal); + hScrollRafId = requestAnimationFrame(() => syncHorizontal()); }; const onResize = () => { diff --git a/frontend/src/modules/common/data-grid/hooks/use-viewport-rows.ts b/frontend/src/modules/common/data-grid/hooks/use-viewport-rows.ts index 4fea99ae0..248eb61db 100644 --- a/frontend/src/modules/common/data-grid/hooks/use-viewport-rows.ts +++ b/frontend/src/modules/common/data-grid/hooks/use-viewport-rows.ts @@ -13,14 +13,7 @@ interface ViewportRowsArgs<R> { measured: boolean; } -export function useViewportRows<R>({ - rows, - rowHeight, - clientHeight, - scrollTop, - enableVirtualization, - measured, -}: ViewportRowsArgs<R>) { +export function useViewportRows<R>({ rows, rowHeight, clientHeight, scrollTop, enableVirtualization, measured }: ViewportRowsArgs<R>) { const { totalRowHeight, gridTemplateRows, getRowTop, getRowHeight, findRowIdx } = useMemo(() => { if (typeof rowHeight === 'number') { return { @@ -52,10 +45,7 @@ export function useViewportRows<R>({ const rowPositions = rows.map((row, index) => { const currentRowHeight = rowHeight(row); - const position = { - top: totalRowHeight, - height: currentRowHeight, - }; + const position = { top: totalRowHeight, height: currentRowHeight }; totalRowHeight += currentRowHeight; if (currentHeight === null) { @@ -123,13 +113,5 @@ export function useViewportRows<R>({ } } - return { - rowOverscanStartIdx, - rowOverscanEndIdx, - totalRowHeight, - gridTemplateRows, - getRowTop, - getRowHeight, - findRowIdx, - }; + return { rowOverscanStartIdx, rowOverscanEndIdx, totalRowHeight, gridTemplateRows, getRowTop, getRowHeight, findRowIdx }; } diff --git a/frontend/src/modules/common/data-grid/row-drag.tsx b/frontend/src/modules/common/data-grid/row-drag.tsx index 397569cd1..81dc51cb0 100644 --- a/frontend/src/modules/common/data-grid/row-drag.tsx +++ b/frontend/src/modules/common/data-grid/row-drag.tsx @@ -19,12 +19,7 @@ function isRowDragData(data: Record<string, unknown>): data is RowDragData { } /** Resolve the cursor's nearest allowed drop zone, or null when the row blocks all zones. */ -function resolveDropZone( - rectTop: number, - rectHeight: number, - clientY: number, - isZoneAllowed: (zone: DropZone) => boolean, -): DropZone | null { +function resolveDropZone(rectTop: number, rectHeight: number, clientY: number, isZoneAllowed: (zone: DropZone) => boolean): DropZone | null { const ratio = (clientY - rectTop) / rectHeight; // Preference order = natural zone first, then the closer of the two others. let preference: DropZone[]; @@ -37,12 +32,7 @@ function resolveDropZone( } /** Mutates drop-edge attributes without rerendering; a bottom edge may paint on the next row while the drop keeps the original zone. */ -function setRowDropEdge( - rowEl: HTMLElement, - zone: DropZone | null, - prevRowElRef: { current: HTMLElement | null }, - allowRedirect = true, -) { +function setRowDropEdge(rowEl: HTMLElement, zone: DropZone | null, prevRowElRef: { current: HTMLElement | null }, allowRedirect = true) { let targetEl: HTMLElement | null = null; let attrValue: DropZone | null = null; if (zone !== null) { @@ -142,10 +132,7 @@ export function RowDropTarget<R, SR>({ rowKey, config, renderRow, ...props }: Ro if (!isRowDragData(source.data)) return; const zone = (self.data as Record<string, unknown>).dropZone as DropZone | null; // Redirect bottom to the next row's top only when that row accepts this drag's `top` zone. - const allowRedirect = - zone !== 'bottom' || !canDropRow - ? true - : canDropRow({ fromIdx: source.data.rowIdx, toIdx: rowIdx + 1, zone: 'top' }); + const allowRedirect = zone !== 'bottom' || !canDropRow || canDropRow({ fromIdx: source.data.rowIdx, toIdx: rowIdx + 1, zone: 'top' }); setRowDropEdge(el, zone, prevRowElRef, allowRedirect); }, onDragLeave: () => { @@ -172,14 +159,7 @@ export function RowDropTarget<R, SR>({ rowKey, config, renderRow, ...props }: Ro } /** Handle cells are the drag sources; the native drag preview is portaled into the browser's drag image. */ -export function RowDragCell<R, SR>({ - rowIdx, - row, - column, - className, - config, - ...props -}: CellRendererProps<R, SR> & { config: RowDragConfig<R> }) { +export function RowDragCell<R, SR>({ rowIdx, row, column, className, config, ...props }: CellRendererProps<R, SR> & { config: RowDragConfig<R> }) { const ref = useRef<HTMLDivElement>(null); // `isDragging` flips twice per drag, so React state stays off the per-mousemove path. const [isDragging, setIsDragging] = useState(false); @@ -193,11 +173,7 @@ export function RowDragCell<R, SR>({ element: el, getInitialData: (): RowDragData => ({ type: ROW_DRAG_TYPE, rowIdx }), onGenerateDragPreview: ({ nativeSetDragImage }) => { - setCustomNativeDragPreview({ - nativeSetDragImage, - getOffset: () => ({ x: 16, y: 16 }), - render: ({ container }) => setPreview({ container }), - }); + setCustomNativeDragPreview({ nativeSetDragImage, getOffset: () => ({ x: 16, y: 16 }), render: ({ container }) => setPreview({ container }) }); }, onDragStart: () => setIsDragging(true), onDrop: () => { @@ -220,14 +196,7 @@ export function RowDragCell<R, SR>({ return ( <> - <CellComponent - ref={ref} - rowIdx={rowIdx} - row={row} - column={column} - className={cn(className, { 'opacity-40': isDragging })} - {...props} - /> + <CellComponent ref={ref} rowIdx={rowIdx} row={row} column={column} className={cn(className, { 'opacity-40': isDragging })} {...props} /> {dragPreview} </> ); diff --git a/frontend/src/modules/common/data-grid/row.tsx b/frontend/src/modules/common/data-grid/row.tsx index 97118d081..3ae14b1cf 100644 --- a/frontend/src/modules/common/data-grid/row.tsx +++ b/frontend/src/modules/common/data-grid/row.tsx @@ -5,8 +5,7 @@ import type { RenderRowProps } from './types'; import { cn, getCellRangeBoundary, getColSpan, isCellInRange } from './utils/grid-utils'; // Rows are real subgrid boxes: column tracks still resolve at the root grid, and the row has a hit-test box for drag and drop and row-wide indicators. -const rowClassname = - 'rdg-row group/row col-span-full grid grid-cols-subgrid aria-selected:bg-accent aria-selected:hover:bg-accent'; +const rowClassname = 'rdg-row group/row col-span-full grid grid-cols-subgrid aria-selected:bg-accent aria-selected:hover:bg-accent'; function Row<R, SR>({ className, @@ -51,8 +50,7 @@ function Row<R, SR>({ const position = { idx, rowIdx }; const isInSelectedRange = selectedCellRange ? isCellInRange(position, selectedCellRange) : false; - const rangeBoundary = - isInSelectedRange && selectedCellRange ? getCellRangeBoundary(position, selectedCellRange) : undefined; + const rangeBoundary = isInSelectedRange && selectedCellRange ? getCellRangeBoundary(position, selectedCellRange) : undefined; if (isCellSelected && selectedCellEditor) { cells.push(selectedCellEditor); diff --git a/frontend/src/modules/common/data-grid/types.ts b/frontend/src/modules/common/data-grid/types.ts index e3f9c4120..5b6d1a489 100644 --- a/frontend/src/modules/common/data-grid/types.ts +++ b/frontend/src/modules/common/data-grid/types.ts @@ -38,9 +38,7 @@ export interface MergedSlot<TRow, TSummaryRow = unknown> { readonly className?: Maybe<string>; } -export type MergedSlots<TRow, TSummaryRow = unknown> = Readonly< - Record<TileSide, readonly MergedSlot<TRow, TSummaryRow>[]> ->; +export type MergedSlots<TRow, TSummaryRow = unknown> = Readonly<Record<TileSide, readonly MergedSlot<TRow, TSummaryRow>[]>>; export interface Position { readonly idx: number; @@ -234,10 +232,7 @@ export interface CellRendererProps<TRow, TSummaryRow> extends BaseCellRendererPr onRowChange: (column: CalculatedColumn<TRow, TSummaryRow>, rowIdx: number, newRow: TRow) => void; } -export type CellEvent<E extends React.SyntheticEvent<HTMLDivElement>> = E & { - preventGridDefault: () => void; - isGridDefaultPrevented: () => boolean; -}; +export type CellEvent<E extends React.SyntheticEvent<HTMLDivElement>> = E & { preventGridDefault: () => void; isGridDefaultPrevented: () => boolean }; export type CellMouseEvent = CellEvent<React.MouseEvent<HTMLDivElement>>; @@ -269,9 +264,7 @@ export interface EditCellKeyDownArgs<TRow, TSummaryRow = unknown> { onClose: (commitChanges?: boolean, shouldFocusCell?: boolean) => void; } -export type CellKeyDownArgs<TRow, TSummaryRow = unknown> = - | SelectCellKeyDownArgs<TRow, TSummaryRow> - | EditCellKeyDownArgs<TRow, TSummaryRow>; +export type CellKeyDownArgs<TRow, TSummaryRow = unknown> = SelectCellKeyDownArgs<TRow, TSummaryRow> | EditCellKeyDownArgs<TRow, TSummaryRow>; export interface CellSelectArgs<TRow, TSummaryRow = unknown> { rowIdx: number; @@ -279,9 +272,7 @@ export interface CellSelectArgs<TRow, TSummaryRow = unknown> { column: CalculatedColumn<TRow, TSummaryRow>; } -export type CellMouseEventHandler<R, SR> = Maybe< - (args: CellMouseArgs<NoInfer<R>, NoInfer<SR>>, event: CellMouseEvent) => void ->; +export type CellMouseEventHandler<R, SR> = Maybe<(args: CellMouseArgs<NoInfer<R>, NoInfer<SR>>, event: CellMouseEvent) => void>; export interface BaseRenderRowProps<TRow, TSummaryRow = unknown> extends BaseCellRendererProps<TRow, TSummaryRow> { viewportColumns: readonly CalculatedColumn<TRow, TSummaryRow>[]; @@ -342,10 +333,7 @@ export interface SortColumn { export type CellNavigationMode = 'NONE' | 'CHANGE_ROW'; export type SortDirection = 'ASC' | 'DESC'; -export type ColSpanArgs<TRow, TSummaryRow> = - | { type: 'HEADER' } - | { type: 'ROW'; row: TRow } - | { type: 'SUMMARY'; row: TSummaryRow }; +export type ColSpanArgs<TRow, TSummaryRow> = { type: 'HEADER' } | { type: 'ROW'; row: TRow } | { type: 'SUMMARY'; row: TSummaryRow }; export interface RenderSortIconProps { sortDirection: SortDirection | undefined; diff --git a/frontend/src/modules/common/data-grid/utils/breakpoint-utils.ts b/frontend/src/modules/common/data-grid/utils/breakpoint-utils.ts index 0d02e2386..59ece05ec 100644 --- a/frontend/src/modules/common/data-grid/utils/breakpoint-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/breakpoint-utils.ts @@ -1,10 +1,3 @@ import type { BreakpointKey } from '../types'; -export const breakpointOrder: Record<BreakpointKey, number> = { - xs: 0, - sm: 1, - md: 2, - lg: 3, - xl: 4, - '2xl': 5, -}; +export const breakpointOrder: Record<BreakpointKey, number> = { xs: 0, sm: 1, md: 2, lg: 3, xl: 4, '2xl': 5 }; diff --git a/frontend/src/modules/common/data-grid/utils/cell-range-utils.ts b/frontend/src/modules/common/data-grid/utils/cell-range-utils.ts index fbaa4bb2d..45dccb4bf 100644 --- a/frontend/src/modules/common/data-grid/utils/cell-range-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/cell-range-utils.ts @@ -7,10 +7,7 @@ export function normalizeCellRange(range: CellRange): CellRange { const minRowIdx = Math.min(range.start.rowIdx, range.end.rowIdx); const maxRowIdx = Math.max(range.start.rowIdx, range.end.rowIdx); - return { - start: { idx: minIdx, rowIdx: minRowIdx }, - end: { idx: maxIdx, rowIdx: maxRowIdx }, - }; + return { start: { idx: minIdx, rowIdx: minRowIdx }, end: { idx: maxIdx, rowIdx: maxRowIdx } }; } export function isCellInRange(position: Position, range: CellRange): boolean { @@ -99,10 +96,7 @@ export function expandRange( } /** Which range edges a cell sits on, for border styling. */ -export function getCellRangeBoundary( - position: Position, - range: CellRange, -): { isTop: boolean; isBottom: boolean; isLeft: boolean; isRight: boolean } { +export function getCellRangeBoundary(position: Position, range: CellRange): { isTop: boolean; isBottom: boolean; isLeft: boolean; isRight: boolean } { const normalized = normalizeCellRange(range); return { diff --git a/frontend/src/modules/common/data-grid/utils/clipboard-utils.ts b/frontend/src/modules/common/data-grid/utils/clipboard-utils.ts index 556226d5e..9dea0150a 100644 --- a/frontend/src/modules/common/data-grid/utils/clipboard-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/clipboard-utils.ts @@ -12,11 +12,7 @@ export function cellValueToText(value: unknown): string { } /** Serializes a cell range to TSV, the standard spreadsheet clipboard format. */ -export function serializeCellsToTSV<R, SR>( - range: CellRange, - rows: readonly R[], - columns: readonly CalculatedColumn<R, SR>[], -): string { +export function serializeCellsToTSV<R, SR>(range: CellRange, rows: readonly R[], columns: readonly CalculatedColumn<R, SR>[]): string { const normalized = normalizeCellRange(range); const lines: string[] = []; @@ -49,11 +45,7 @@ export function parseTSVToCells(tsv: string): string[][] { } /** Serializes cells to an HTML table, for rich paste targets. */ -export function serializeCellsToHTML<R, SR>( - range: CellRange, - rows: readonly R[], - columns: readonly CalculatedColumn<R, SR>[], -): string { +export function serializeCellsToHTML<R, SR>(range: CellRange, rows: readonly R[], columns: readonly CalculatedColumn<R, SR>[]): string { const normalized = normalizeCellRange(range); let html = '<table>'; diff --git a/frontend/src/modules/common/data-grid/utils/col-span-utils.ts b/frontend/src/modules/common/data-grid/utils/col-span-utils.ts index d70b78615..26546a8be 100644 --- a/frontend/src/modules/common/data-grid/utils/col-span-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/col-span-utils.ts @@ -1,10 +1,6 @@ import type { CalculatedColumn, ColSpanArgs } from '../types'; -export function getColSpan<R, SR>( - column: CalculatedColumn<R, SR>, - lastFrozenColumnIndex: number, - args: ColSpanArgs<R, SR>, -): number | undefined { +export function getColSpan<R, SR>(column: CalculatedColumn<R, SR>, lastFrozenColumnIndex: number, args: ColSpanArgs<R, SR>): number | undefined { const colSpan = typeof column.colSpan === 'function' ? column.colSpan(args) : 1; if ( Number.isInteger(colSpan) && diff --git a/frontend/src/modules/common/data-grid/utils/grid-utils.ts b/frontend/src/modules/common/data-grid/utils/grid-utils.ts index 49e1f1037..cfa3b9e3e 100644 --- a/frontend/src/modules/common/data-grid/utils/grid-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/grid-utils.ts @@ -15,9 +15,7 @@ export * from './wrap-text-utils'; export const { min, max, floor, sign } = Math; -export function assertIsValidKeyGetter<R, K extends React.Key>( - keyGetter: Maybe<(row: NoInfer<R>) => K>, -): asserts keyGetter is (row: R) => K { +export function assertIsValidKeyGetter<R, K extends React.Key>(keyGetter: Maybe<(row: NoInfer<R>) => K>): asserts keyGetter is (row: R) => K { if (typeof keyGetter !== 'function') { throw new Error('Please specify the rowKeyGetter prop to use selection'); } diff --git a/frontend/src/modules/common/data-grid/utils/keyboard-utils.ts b/frontend/src/modules/common/data-grid/utils/keyboard-utils.ts index 1b6da5b45..bdbd9fe2b 100644 --- a/frontend/src/modules/common/data-grid/utils/keyboard-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/keyboard-utils.ts @@ -64,18 +64,12 @@ export function isDefaultCellInput(event: React.KeyboardEvent<HTMLDivElement>, i /** Allow Tab navigation from a sole input, textarea, or select inside the editor container. */ export function onEditorNavigation({ key, target }: React.KeyboardEvent<HTMLDivElement>): boolean { - if ( - key === 'Tab' && - (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) - ) { + if (key === 'Tab' && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) { return target.closest('.rdg-editor-container')?.querySelectorAll('input, textarea, select').length === 1; } return false; } export function getLeftRightKey() { - return { - leftKey: 'ArrowLeft', - rightKey: 'ArrowRight', - } as const; + return { leftKey: 'ArrowLeft', rightKey: 'ArrowRight' } as const; } diff --git a/frontend/src/modules/common/data-grid/utils/selected-cell-utils.ts b/frontend/src/modules/common/data-grid/utils/selected-cell-utils.ts index 5b2733351..12f6aab36 100644 --- a/frontend/src/modules/common/data-grid/utils/selected-cell-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/selected-cell-utils.ts @@ -7,11 +7,7 @@ interface IsSelectedCellEditableOpts<R, SR> { rows: readonly R[]; } -export function isSelectedCellEditable<R, SR>({ - selectedPosition, - columns, - rows, -}: IsSelectedCellEditableOpts<R, SR>): boolean { +export function isSelectedCellEditable<R, SR>({ selectedPosition, columns, rows }: IsSelectedCellEditableOpts<R, SR>): boolean { const column = columns[selectedPosition.idx]; const row = rows[selectedPosition.rowIdx]; return isCellEditableUtil(column, row); @@ -19,10 +15,7 @@ export function isSelectedCellEditable<R, SR>({ // https://github.com/vercel/next.js/issues/56480 export function isCellEditableUtil<R, SR>(column: CalculatedColumn<R, SR>, row: R): boolean { - return ( - column.renderEditCell != null && - (typeof column.editable === 'function' ? column.editable(row) : column.editable) !== false - ); + return column.renderEditCell != null && (typeof column.editable === 'function' ? column.editable(row) : column.editable) !== false; } interface GetNextSelectedCellPositionOpts<R, SR> { @@ -51,10 +44,7 @@ function getSelectedCellColSpan<R, SR>({ mainHeaderRowIdx, lastFrozenColumnIndex, column, -}: Pick< - GetNextSelectedCellPositionOpts<R, SR>, - 'rows' | 'topSummaryRows' | 'bottomSummaryRows' | 'lastFrozenColumnIndex' | 'mainHeaderRowIdx' -> & { +}: Pick<GetNextSelectedCellPositionOpts<R, SR>, 'rows' | 'topSummaryRows' | 'bottomSummaryRows' | 'lastFrozenColumnIndex' | 'mainHeaderRowIdx'> & { rowIdx: number; column: CalculatedColumn<R, SR>; }) { @@ -64,10 +54,7 @@ function getSelectedCellColSpan<R, SR>({ } if (topSummaryRows && rowIdx > mainHeaderRowIdx && rowIdx <= topSummaryRowsCount + mainHeaderRowIdx) { - return getColSpan(column, lastFrozenColumnIndex, { - type: 'SUMMARY', - row: topSummaryRows[rowIdx + topSummaryRowsCount], - }); + return getColSpan(column, lastFrozenColumnIndex, { type: 'SUMMARY', row: topSummaryRows[rowIdx + topSummaryRowsCount] }); } if (rowIdx >= 0 && rowIdx < rows.length) { @@ -76,20 +63,13 @@ function getSelectedCellColSpan<R, SR>({ } if (bottomSummaryRows) { - return getColSpan(column, lastFrozenColumnIndex, { - type: 'SUMMARY', - row: bottomSummaryRows[rowIdx - rows.length], - }); + return getColSpan(column, lastFrozenColumnIndex, { type: 'SUMMARY', row: bottomSummaryRows[rowIdx - rows.length] }); } return undefined; } -function findNextFocusableColumn<R, SR>( - columns: readonly CalculatedColumn<R, SR>[], - startIdx: number, - direction: 1 | -1, -): number { +function findNextFocusableColumn<R, SR>(columns: readonly CalculatedColumn<R, SR>[], startIdx: number, direction: 1 | -1): number { let idx = startIdx; const maxIdx = columns.length - 1; @@ -256,13 +236,7 @@ interface CanExitGridOpts { shiftKey: boolean; } -export function canExitGrid({ - maxColIdx, - minRowIdx, - maxRowIdx, - selectedPosition: { rowIdx, idx }, - shiftKey, -}: CanExitGridOpts): boolean { +export function canExitGrid({ maxColIdx, minRowIdx, maxRowIdx, selectedPosition: { rowIdx, idx }, shiftKey }: CanExitGridOpts): boolean { const atLastCellInRow = idx === maxColIdx; const atFirstCellInRow = idx === 0; const atLastRow = rowIdx === maxRowIdx; diff --git a/frontend/src/modules/common/data-grid/utils/style-utils.ts b/frontend/src/modules/common/data-grid/utils/style-utils.ts index a9d4ca35a..78f526678 100644 --- a/frontend/src/modules/common/data-grid/utils/style-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/style-utils.ts @@ -4,21 +4,12 @@ import { resolveWrapTextLines } from './wrap-text-utils'; export { cn } from '~/utils/cn'; -export function getHeaderCellStyle<R, SR>( - column: CalculatedColumnOrColumnGroup<R, SR>, - rowIdx: number, - rowSpan: number, -): React.CSSProperties { +export function getHeaderCellStyle<R, SR>(column: CalculatedColumnOrColumnGroup<R, SR>, rowIdx: number, rowSpan: number): React.CSSProperties { const gridRowEnd = rowIdx + 1; const paddingBlockStart = `calc(${rowSpan - 1} * var(--rdg-header-row-height))`; if (column.parent === undefined) { - return { - insetBlockStart: 0, - gridRowStart: 1, - gridRowEnd, - paddingBlockStart, - }; + return { insetBlockStart: 0, gridRowStart: 1, gridRowEnd, paddingBlockStart }; } return { @@ -40,26 +31,23 @@ export function getCellStyle<R, SR>(column: CalculatedColumn<R, SR>, colSpan = 1 }; } -export function getCellClassname<R, SR>( - column: CalculatedColumn<R, SR>, - ...extraClasses: Parameters<typeof cn> -): string { +export function getCellClassname<R, SR>(column: CalculatedColumn<R, SR>, ...extraClasses: Parameters<typeof cn>): string { const wrapLines = resolveWrapTextLines(column.wrapText); // Hosts with merged slots clamp only the main-content wrapper: display:-webkit-box on a slot wrapper breaks its flex layout. const hasMergedSlots = column.mergedSlots != null; const textOverflow = wrapLines > 0 ? hasMergedSlots - ? 'whitespace-pre-line overflow-hidden [&_[data-tile-main]>*]:line-clamp-[var(--rdg-wrap-text-lines,none)] [&_[data-tile-main]>*]:text-ellipsis [&_[data-tile-main]>*]:!py-0' - : 'whitespace-pre-line overflow-hidden [&>*]:line-clamp-[var(--rdg-wrap-text-lines,none)] [&>*]:text-ellipsis [&>*]:!py-0' + ? 'whitespace-pre-line overflow-hidden [&_[data-tile-main]>*]:line-clamp-[var(--rdg-wrap-text-lines,none)] [&_[data-tile-main]>*]:text-ellipsis [&_[data-tile-main]>*]:py-0!' + : 'whitespace-pre-line overflow-hidden [&>*]:line-clamp-[var(--rdg-wrap-text-lines,none)] [&>*]:text-ellipsis [&>*]:py-0!' : 'whitespace-nowrap overflow-clip text-ellipsis'; return cn( - `rdg-cell flex items-center group/cell relative py-0 px-2 bg-inherit outline-none scroll-mt-32 border-t-[0.05rem] border-border ${textOverflow}`, + `rdg-cell flex items-center group/cell relative py-0 px-2 bg-inherit outline-hidden scroll-mt-32 border-t-[0.05rem] border-border ${textOverflow}`, 'sm:group-hover/row:bg-accent/40', 'aria-selected:outline-2 aria-selected:outline-primary aria-selected:outline-solid aria-selected:-outline-offset-2', '[&:not([aria-readonly=true])]:aria-selected:bg-accent/60', - 'max-xs:aria-selected:bg-transparent max-xs:aria-selected:outline-none', - '[.rdg-readonly_&]:aria-selected:outline-none', + 'max-xs:aria-selected:bg-transparent max-xs:aria-selected:outline-hidden', + '[.rdg-readonly_&]:aria-selected:outline-hidden', { 'rdg-cell-frozen sticky z-1': column.frozen }, ...extraClasses, ); diff --git a/frontend/src/modules/common/data-grid/utils/wrap-text-utils.ts b/frontend/src/modules/common/data-grid/utils/wrap-text-utils.ts index 39d18c759..9f745fc4d 100644 --- a/frontend/src/modules/common/data-grid/utils/wrap-text-utils.ts +++ b/frontend/src/modules/common/data-grid/utils/wrap-text-utils.ts @@ -58,12 +58,7 @@ function snapToTier(lines: number): number { return heightTiers[heightTiers.length - 1]; } -export function tierToHeight( - tier: number, - baseHeight: number, - lineHeight = wrapTextLineHeight, - padding = wrapTextPadding, -): number { +export function tierToHeight(tier: number, baseHeight: number, lineHeight = wrapTextLineHeight, padding = wrapTextPadding): number { if (tier <= 1) return baseHeight; return Math.max(baseHeight, tier * lineHeight + padding); } diff --git a/frontend/src/modules/common/data-table/checkbox-column.tsx b/frontend/src/modules/common/data-table/checkbox-column.tsx index 4d6ebb471..832b049a8 100644 --- a/frontend/src/modules/common/data-table/checkbox-column.tsx +++ b/frontend/src/modules/common/data-table/checkbox-column.tsx @@ -1,8 +1,4 @@ import { type Column, SelectColumn } from '~/modules/common/data-grid'; // biome-ignore lint/suspicious/noExplicitAny: any is used for compatibility with react-data-grid -export const CheckboxColumn: Column<any> = { - ...SelectColumn, - key: 'checkbox-column', - minBreakpoint: 'sm', -}; +export const CheckboxColumn: Column<any> = { ...SelectColumn, key: 'checkbox-column', minBreakpoint: 'sm' }; diff --git a/frontend/src/modules/common/data-table/columns-view.tsx b/frontend/src/modules/common/data-table/columns-view.tsx index ead7b0d42..f5ce7afae 100644 --- a/frontend/src/modules/common/data-table/columns-view.tsx +++ b/frontend/src/modules/common/data-table/columns-view.tsx @@ -5,13 +5,7 @@ import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; import { TooltipButton } from '~/modules/common/tooltip-button'; import { Badge } from '~/modules/ui/badge'; import { Button } from '~/modules/ui/button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '~/modules/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from '~/modules/ui/dropdown-menu'; interface Props<TData> { columns: ColumnOrColumnGroup<TData>[]; @@ -25,10 +19,7 @@ export function ColumnsView<TData>({ columns, setColumns, className = '', childr const [columnSearch, setColumnSearch] = useState(''); const filteredColumns = columns.filter( - (column) => - typeof column.name === 'string' && - column.name && - column.name.toLocaleLowerCase().includes(columnSearch.toLocaleLowerCase()), + (column) => typeof column.name === 'string' && column.name && column.name.toLocaleLowerCase().includes(columnSearch.toLocaleLowerCase()), ); return ( @@ -39,9 +30,7 @@ export function ColumnsView<TData>({ columns, setColumns, className = '', childr > <TooltipButton className={className} toolTipContent={t('c:columns_view')}> <DropdownMenuTrigger render={<Button variant="outline" className="relative flex" />}> - {filteredColumns.some((column) => column.hidden) && ( - <Badge className="absolute -top-1 -right-1 z-10 flex h-2 w-2 justify-center p-0" /> - )} + {filteredColumns.some((column) => column.hidden) && <Badge className="absolute -top-1 -right-1 z-10 flex size-2 justify-center p-0" />} <SlidersHorizontalIcon className="size-4" /> <span className="ml-1 max-xl:hidden">{t('c:view')}</span> </DropdownMenuTrigger> @@ -52,18 +41,7 @@ export function ColumnsView<TData>({ columns, setColumns, className = '', childr key={column.key} className="min-h-8" checked={!column.hidden} - onCheckedChange={() => - setColumns((columns) => - columns.map((c) => - c.name === column.name - ? { - ...c, - hidden: !c.hidden, - } - : c, - ), - ) - } + onCheckedChange={() => setColumns((columns) => columns.map((c) => (c.name === column.name ? { ...c, hidden: !c.hidden } : c)))} > {column.name} </DropdownMenuCheckboxItem> diff --git a/frontend/src/modules/common/data-table/columns.tsx b/frontend/src/modules/common/data-table/columns.tsx new file mode 100644 index 000000000..6622f92b0 --- /dev/null +++ b/frontend/src/modules/common/data-table/columns.tsx @@ -0,0 +1,47 @@ +import { exportDate } from '~/lib/export'; +import type { BreakpointKey } from '~/modules/common/data-grid/types'; +import { type EllipsisOption, TableEllipsis } from '~/modules/common/data-table/table-ellipsis'; +import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; +import { dateShort } from '~/utils/date-short'; + +type DateValue = string | Date | null | undefined; + +interface DateColumnOptions<T> { + name: string; + /** Sortable date columns list newest first on the first sort. Defaults to true. */ + sortable?: boolean; + hidden?: boolean; + /** Reads the date; defaults to the row field named by the column key. */ + get?: (row: T) => DateValue; +} + +/** A short relative date, hidden below md; exports write the full date. */ +export const dateColumn = <T,>( + key: string, + { name, sortable = true, hidden, get = (row) => (row as Record<string, DateValue>)[key] }: DateColumnOptions<T>, +): ColumnOrColumnGroup<T> => ({ + key, + name, + ...(sortable && { sortable, sortDescendingFirst: true }), + hidden, + minBreakpoint: 'md', + minWidth: 120, + placeholderValue: '-', + renderCell: ({ row }) => dateShort(get(row)), + exportValue: (row) => exportDate(get(row)), +}); + +/** Row actions behind an ellipsis button; a row without options gets an empty cell. */ +export const ellipsisColumn = <T extends { id: string }>( + getOptions: (row: T) => EllipsisOption<T>[], + maxBreakpoint?: BreakpointKey, +): ColumnOrColumnGroup<T> => ({ + key: 'ellipsis', + name: '', + ...(maxBreakpoint && { maxBreakpoint }), + width: 32, + renderCell: ({ row, tabIndex }) => { + const options = getOptions(row); + return options.length ? <TableEllipsis row={row} tabIndex={tabIndex} options={options} /> : null; + }, +}); diff --git a/frontend/src/modules/common/data-table/data-table.tsx b/frontend/src/modules/common/data-table/data-table.tsx index 040c57453..9256b8ca9 100644 --- a/frontend/src/modules/common/data-table/data-table.tsx +++ b/frontend/src/modules/common/data-table/data-table.tsx @@ -112,13 +112,7 @@ export function DataTable<TData>({ // Virtualized tables use the grid's near-end state; fully rendered tables use InfiniteLoader observation. const [nearEnd, setNearEnd] = useState(false); - useFetchMoreOnDemand({ - demand: !!enableVirtualization && nearEnd, - hasNextPage, - isFetching: !!isFetching, - error: !!error, - fetchMore, - }); + useFetchMoreOnDemand({ demand: !!enableVirtualization && nearEnd, hasNextPage, isFetching: !!isFetching, error: !!error, fetchMore }); // Memoized because `DataGrid` passes it to memoized rows; a fresh function each render defeats that memo. const handleSelectedRowsChange = useCallback( @@ -147,19 +141,20 @@ export function DataTable<TData>({ const renderers = useMemo(() => ({ renderRow, renderCell }), [renderRow, renderCell]); return ( - <div className={cn('mb-4 h-full w-full md:mb-8', className)}> - {isLoading || !rows ? ( + <div className={cn('mb-4 size-full md:mb-8', className)}> + {/* A failed first load has no rows, so the skeleton shows only while no error is known. */} + {(isLoading || !rows) && !error ? ( <DataTableSkeleton cellsWidths={['3rem', '10rem', '4rem']} cellHeight={Number(rowHeight)} // Count only visible columns so the skeleton matches what the grid renders. columnCount={columns.filter((column) => !column.hidden).length} /> - ) : error && rows.length === 0 ? ( - <div className="flex h-full w-full flex-col items-center justify-center bg-background text-muted-foreground"> - <div className="my-8 text-center text-red-600 text-sm">{error.message}</div> + ) : error && !rows?.length ? ( + <div className="flex size-full flex-col items-center justify-center bg-background text-muted-foreground"> + <div className="my-8 text-center text-destructive text-sm">{error.message}</div> </div> - ) : !rows.length ? ( + ) : !rows?.length ? ( <NoRows isFiltered={isFiltered} isFetching={isFetching} customComponent={NoRowsComponent} /> ) : ( <div className="relative grid" ref={gridRef}> diff --git a/frontend/src/modules/common/data-table/export.tsx b/frontend/src/modules/common/data-table/export.tsx index 535f72078..322cb06d6 100644 --- a/frontend/src/modules/common/data-table/export.tsx +++ b/frontend/src/modules/common/data-table/export.tsx @@ -20,13 +20,7 @@ interface Props<TData> { } // biome-ignore lint/suspicious/noExplicitAny: any is required here -export function Export<R extends Record<string, any>>({ - filename, - columns, - selectedRows, - fetchRows, - className = '', -}: Props<R>) { +export function Export<R extends Record<string, any>>({ filename, columns, selectedRows, fetchRows, className = '' }: Props<R>) { const { t } = useTranslation(); const isOnline = useOnlineManager(); const mode = uiStore.getState().mode; @@ -64,11 +58,11 @@ export function Export<R extends Record<string, any>>({ {/* Label the full-export pair so it reads apart from the selected-rows pair below */} <DropdownMenuItem onClick={() => exportDefault('csv')}> <span>CSV</span> - <span className="ml-2 text-xs opacity-75">{t('c:all_rows').toLowerCase()}</span> + <span className="ml-2 text-muted-foreground text-xs">{t('c:all_rows').toLowerCase()}</span> </DropdownMenuItem> <DropdownMenuItem onClick={() => exportDefault('pdf')}> <span>PDF</span> - <span className="ml-2 text-xs opacity-75">{t('c:all_rows').toLowerCase()}</span> + <span className="ml-2 text-muted-foreground text-xs">{t('c:all_rows').toLowerCase()}</span> </DropdownMenuItem> </> )} @@ -76,20 +70,16 @@ export function Export<R extends Record<string, any>>({ <> <DropdownMenuItem onClick={() => exportSelected('csv')} disabled={selectedRows.length === 0}> <span>CSV</span> - <span className="ml-2 text-xs opacity-75"> - {selectedRows.length - ? `${selectedRows.length} ${t('c:selected').toLowerCase()}` - : t('c:no_selection').toLowerCase()} + <span className="ml-2 text-muted-foreground text-xs"> + {selectedRows.length ? `${selectedRows.length} ${t('c:selected').toLowerCase()}` : t('c:no_selection').toLowerCase()} </span> </DropdownMenuItem> {isOnline && ( <DropdownMenuItem onClick={() => exportSelected('pdf')} disabled={selectedRows.length === 0}> <span>PDF</span> - <span className="ml-2 text-xs opacity-75"> - {selectedRows.length - ? `${selectedRows.length} ${t('c:selected').toLowerCase()}` - : t('c:no_selection').toLowerCase()} + <span className="ml-2 text-muted-foreground text-xs"> + {selectedRows.length ? `${selectedRows.length} ${t('c:selected').toLowerCase()}` : t('c:no_selection').toLowerCase()} </span> </DropdownMenuItem> )} diff --git a/frontend/src/modules/common/data-table/infinite-loader.tsx b/frontend/src/modules/common/data-table/infinite-loader.tsx index f5fbbb9cd..dafcc551a 100644 --- a/frontend/src/modules/common/data-table/infinite-loader.tsx +++ b/frontend/src/modules/common/data-table/infinite-loader.tsx @@ -13,33 +13,18 @@ type InfiniteLoaderProps = { fetchMore?: () => Promise<unknown>; }; -export function InfiniteLoader({ - hasNextPage, - isFetching, - isFetchMoreError, - hideEndIndicator, - fetchMore, -}: InfiniteLoaderProps) { +export function InfiniteLoader({ hasNextPage, isFetching, isFetchMoreError, hideEndIndicator, fetchMore }: InfiniteLoaderProps) { const { t } = useTranslation(); const isOnline = useOnlineManager(); // inView is level-triggered state: a sentinel entering view during a fetch is served once that fetch settles. const { ref: measureRef, inView } = useInView(); - useFetchMoreOnDemand({ - demand: inView, - hasNextPage, - isFetching: !!isFetching, - error: !!isFetchMoreError, - fetchMore, - }); + useFetchMoreOnDemand({ demand: inView, hasNextPage, isFetching: !!isFetching, error: !!isFetchMoreError, fetchMore }); - if (isFetchMoreError) - return <div className="my-8 text-center text-red-600 text-sm">{t('error:load_more_failed')}</div>; + if (isFetchMoreError) return <div className="my-8 text-center text-destructive text-sm">{t('error:load_more_failed')}</div>; if (!isOnline && hasNextPage) - return ( - <div className="mt-4 w-full text-center text-muted-foreground/50 text-sm italic">{t('c:offline.load_more')}</div> - ); + return <div className="mt-4 w-full text-center text-muted-foreground/70 text-sm italic">{t('c:offline.load_more')}</div>; return ( <> @@ -53,7 +38,7 @@ export function InfiniteLoader({ function AllLoaded() { return ( - <div className="mt-4 mb-10 w-full text-center text-xl opacity-50"> + <div className="mt-4 mb-10 w-full text-center text-muted-foreground/70 text-xl"> <div>·</div> <div className="-mt-5">·</div> <div className="-mt-5">·</div> @@ -64,7 +49,7 @@ function AllLoaded() { function Loading() { return ( - <div className="relative top-4 mb-10 flex h-0 w-full animate-pulse items-center justify-center space-x-1 opacity-50"> + <div className="relative top-4 mb-10 flex h-0 w-full animate-pulse items-center justify-center gap-1 opacity-50"> <span className="sr-only">Loading...</span> <div className="h-1 w-3 animate-bounce rounded-full bg-foreground [animation-delay:-0.3s]" /> <div className="h-1 w-3 animate-bounce rounded-full bg-foreground [animation-delay:-0.15s]" /> diff --git a/frontend/src/modules/common/data-table/no-rows.tsx b/frontend/src/modules/common/data-table/no-rows.tsx index 398630fbf..956378fc5 100644 --- a/frontend/src/modules/common/data-table/no-rows.tsx +++ b/frontend/src/modules/common/data-table/no-rows.tsx @@ -13,15 +13,9 @@ export function NoRows({ isFiltered, isFetching, customComponent }: NoRowsProps) return ( <div className="flex w-full flex-col items-center justify-center p-8"> {isFiltered && !isFetching && ( - <ContentPlaceholder - icon={SearchIcon} - title="c:no_resource_found" - titleProps={{ resource: t('c:results').toLowerCase() }} - /> + <ContentPlaceholder icon={SearchIcon} title="c:no_resource_found" titleProps={{ resource: t('c:results').toLowerCase() }} /> )} - {!isFiltered && - !isFetching && - (customComponent ?? t('c:no_resource_yet', { resource: t('c:results').toLowerCase() }))} + {!isFiltered && !isFetching && (customComponent ?? t('c:no_resource_yet', { resource: t('c:results').toLowerCase() }))} </div> ); } diff --git a/frontend/src/modules/common/data-table/sort-columns.ts b/frontend/src/modules/common/data-table/sort-columns.ts index b832939d6..58870ed46 100644 --- a/frontend/src/modules/common/data-table/sort-columns.ts +++ b/frontend/src/modules/common/data-table/sort-columns.ts @@ -12,10 +12,7 @@ export const useSortColumns = (sort: Sort | undefined, order: Order | undefined, const setSortColumns = (newSortColumns: SortColumn[]) => { if (newSortColumns.length === 0) return setSearch({ sort: undefined, order: undefined }); - setSearch({ - sort: newSortColumns[0].columnKey, - order: newSortColumns[0].direction === 'ASC' ? 'asc' : 'desc', - }); + setSearch({ sort: newSortColumns[0].columnKey, order: newSortColumns[0].direction === 'ASC' ? 'asc' : 'desc' }); }; return { sortColumns, setSortColumns }; diff --git a/frontend/src/modules/common/data-table/stories/data-table.stories.tsx b/frontend/src/modules/common/data-table/stories/data-table.stories.tsx new file mode 100644 index 000000000..3a7787341 --- /dev/null +++ b/frontend/src/modules/common/data-table/stories/data-table.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import { DataTable } from '~/modules/common/data-table/data-table'; +import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; + +type Row = { id: string; name: string }; + +const columns: ColumnOrColumnGroup<Row>[] = [{ key: 'name', name: 'Name' }]; + +function Table({ rows, error, isLoading }: { rows?: Row[]; error?: Error; isLoading?: boolean }) { + return ( + <DataTable<Row> columns={columns} rows={rows} error={error} isLoading={isLoading} rowKeyGetter={(row) => row.id} hasNextPage={false} readOnly /> + ); +} + +/** What a table shows for each query state: a skeleton while loading, the error of a failed first load, and rows. */ +const meta = { title: 'common/data-table/DataTable', component: Table } satisfies Meta<typeof Table>; + +export default meta; +type Story = StoryObj<typeof meta>; + +export const Loading: Story = { + args: { isLoading: true }, + play: async ({ canvasElement }) => { + await expect(canvasElement.querySelector('[data-slot="skeleton"]')).not.toBeNull(); + }, +}; + +export const FailedFirstLoad: Story = { + args: { error: new Error('Could not load the list') }, + play: async ({ canvasElement }) => { + await expect(within(canvasElement).getByText('Could not load the list')).toBeVisible(); + await expect(canvasElement.querySelector('[data-slot="skeleton"]')).toBeNull(); + }, +}; + +export const Rows: Story = { + args: { rows: [{ id: 'r1', name: 'First row' }] }, + play: async ({ canvasElement }) => { + await expect(await within(canvasElement).findByText('First row')).toBeVisible(); + }, +}; diff --git a/frontend/src/modules/common/data-table/stories/table-bar-shell.stories.tsx b/frontend/src/modules/common/data-table/stories/table-bar-shell.stories.tsx new file mode 100644 index 000000000..09675785e --- /dev/null +++ b/frontend/src/modules/common/data-table/stories/table-bar-shell.stories.tsx @@ -0,0 +1,232 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactNode } from 'react'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { AttachmentsTableBar } from '~/modules/attachment/table/attachments-bar'; +import type { EnrichedChannel } from '~/modules/entities/types'; +import { MembersTableBar } from '~/modules/memberships/members-table/members-bar'; +import { OrganizationsTableBar } from '~/modules/organization/table/organizations-bar'; +import { PagesTableBar } from '~/modules/page/table/pages-bar'; +import { RequestsTableBar } from '~/modules/requests/table/requests-bar'; +import { TenantsTableBar } from '~/modules/tenants/table/tenants-bar'; +import { UsersTableBar } from '~/modules/user/table/users-bar'; +import { withApp } from '~/stories/with-app'; + +// Bars read their count from this cached list; the rows are stand-ins, the bars read only ids and a few fields. +const listKey = ['story', 'list']; +const rows = [ + { id: 'r1', name: 'One', email: 'one@example.com', type: 'waitlist', wasInvited: false, createdBy: null }, + { id: 'r2', name: 'Two', email: 'two@example.com', type: 'waitlist', wasInvited: true, createdBy: null }, +]; +// A stand-in channel with the fields the bars read; the cast skips the rest of the enriched row. +const channel = { + id: 'org-1', + entityType: 'organization', + tenantId: 'tenant-1', + organizationId: 'org-1', + can: { organization: { update: true }, attachment: { delete: true } }, +} as unknown as EnrichedChannel; + +type BarArgs = { + q?: string; + role?: string; + selected?: typeof rows; + isSheet?: boolean; + canUpdate?: boolean; + setSearch: (values: Record<string, unknown>) => void; + clearSelection: () => void; + bar: 'users' | 'organizations' | 'tenants' | 'requests' | 'members' | 'attachments' | 'pages'; +}; + +function Bar({ bar, q, role, selected = [], isSheet, canUpdate = true, setSearch, clearSelection }: BarArgs): ReactNode { + // The bars take generated row types; the stand-in rows carry only what the bars read. + const common = { + queryKey: listKey, + columns: [], + setColumns: () => {}, + setSearch, + clearSelection, + selected: selected as never[], + }; + const searchVars = { q, role, limit: 20 } as never; + const membersChannel = canUpdate ? channel : ({ ...channel, can: {} } as EnrichedChannel); + + if (bar === 'users') return <UsersTableBar {...common} searchVars={searchVars} />; + if (bar === 'organizations') return <OrganizationsTableBar {...common} searchVars={searchVars} />; + if (bar === 'tenants') return <TenantsTableBar {...common} searchVars={searchVars} />; + if (bar === 'requests') return <RequestsTableBar {...common} searchVars={searchVars} />; + if (bar === 'members') return <MembersTableBar {...common} searchVars={searchVars} channel={membersChannel} isSheet={isSheet} />; + if (bar === 'attachments') return <AttachmentsTableBar {...common} searchVars={searchVars} channel={channel} isSheet={isSheet} canUpload />; + return <PagesTableBar total={2} searchVars={{ q }} setSearch={setSearch} columns={[]} setColumns={() => {}} />; +} + +/** The bar above each entity table: count, search, reset, actions, export, focus view and the selection bar. */ +const meta = { + title: 'common/data-table/TableBarShell', + component: Bar, + decorators: [withApp], + parameters: { app: { queryData: [[listKey, { items: rows, total: 2 }]] } }, + args: { setSearch: fn(), clearSelection: fn(), bar: 'users' }, +} satisfies Meta<typeof Bar>; + +export default meta; +type Story = StoryObj<typeof meta>; + +const body = () => within(document.body); +const searchInput = (canvasElement: HTMLElement, name: string) => canvasElement.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; +const hasIcon = (element: HTMLElement, icon: string) => !!element.querySelector(`.lucide-${icon}`); + +/** Resets through the count's clear button; the search resets before the selection clears. */ +async function expectReset(canvasElement: HTMLElement, args: BarArgs, reset: Record<string, unknown>) { + await userEvent.click(within(canvasElement).getByRole('button', { name: 'clear' })); + await expect(args.setSearch).toHaveBeenCalledWith(reset); + const clear = args.clearSelection as ReturnType<typeof fn>; + const search = args.setSearch as ReturnType<typeof fn>; + if (clear.mock.calls.length) { + await expect(search.mock.invocationCallOrder[0]).toBeLessThan(clear.mock.invocationCallOrder[0]); + } +} + +export const UsersFiltered: Story = { + args: { bar: 'users', q: 'ada', role: 'admin', selected: rows }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await expect(searchInput(canvasElement, 'userSearch')).not.toBeNull(); + await expect(canvas.queryByRole('button', { name: 'invite' })).toBeNull(); + await expect(hasIcon(canvasElement, 'download')).toBe(false); + await expect(hasIcon(canvasElement, 'expand')).toBe(true); + await expect(await body().findByRole('button', { name: 'delete' })).toBeInTheDocument(); + // The embedded invite dialog mounts into an empty container right after the bar. + await expect(canvasElement.querySelector('div.empty\\:hidden')).not.toBeNull(); + + await expectReset(canvasElement, args, { q: '', role: undefined }); + await expect(args.clearSelection).toHaveBeenCalled(); + }, +}; + +export const UsersSearch: Story = { + args: { bar: 'users' }, + play: async ({ canvasElement, args }) => { + await expect(within(canvasElement).getByRole('button', { name: 'invite' })).toBeInTheDocument(); + + const input = searchInput(canvasElement, 'userSearch'); + if (!input) throw new Error('no search input'); + await userEvent.type(input, 'ada'); + await waitFor(() => expect(args.setSearch).toHaveBeenCalledWith({ q: 'ada' })); + const clear = args.clearSelection as ReturnType<typeof fn>; + const search = args.setSearch as ReturnType<typeof fn>; + await expect(clear.mock.invocationCallOrder[0]).toBeLessThan(search.mock.invocationCallOrder[0]); + }, +}; + +export const OrganizationsExport: Story = { + args: { bar: 'organizations', selected: rows }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(searchInput(canvasElement, 'organizationSearch')).not.toBeNull(); + await expect(canvas.getByRole('button', { name: 'create' })).toBeInTheDocument(); + await expect(await body().findByRole('button', { name: 'newsletter' })).toBeInTheDocument(); + + await userEvent.click(canvasElement.querySelector('.lucide-download')?.closest('button') as HTMLElement); + const menu = await body().findByRole('menu'); + // Full export and selected-rows export. + await expect(within(menu).getAllByRole('menuitem')).toHaveLength(4); + await expect(within(menu).getAllByText('2 selected')).toHaveLength(2); + await userEvent.keyboard('{Escape}'); + }, +}; + +export const OrganizationsFiltered: Story = { + args: { bar: 'organizations', q: 'x' }, + play: async ({ canvasElement, args }) => { + await expect(within(canvasElement).queryByRole('button', { name: 'create' })).toBeNull(); + await expectReset(canvasElement, args, { q: '' }); + await expect(args.clearSelection).toHaveBeenCalled(); + }, +}; + +export const RequestsExport: Story = { + args: { bar: 'requests', selected: rows }, + play: async ({ canvasElement }) => { + await expect(searchInput(canvasElement, 'requestSearch')).not.toBeNull(); + // Only the first row still waits for an invite, so the invite action shows a badge of one. + await expect(await body().findByRole('button', { name: /invite/ })).toBeInTheDocument(); + await expect(body().getByRole('button', { name: 'remove' })).toBeInTheDocument(); + + await userEvent.click(canvasElement.querySelector('.lucide-download')?.closest('button') as HTMLElement); + const menu = await body().findByRole('menu'); + await expect(within(menu).getAllByRole('menuitem')).toHaveLength(2); + await expect(within(menu).queryByText('2 selected')).toBeNull(); + await userEvent.keyboard('{Escape}'); + }, +}; + +export const MembersPage: Story = { + args: { bar: 'members', selected: rows }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(searchInput(canvasElement, 'memberSearch')).not.toBeNull(); + await expect(canvas.getByRole('button', { name: 'invite' })).toBeInTheDocument(); + await expect(hasIcon(canvasElement, 'download')).toBe(true); + await expect(hasIcon(canvasElement, 'expand')).toBe(true); + await expect(canvas.getByRole('combobox')).toHaveClass('w-auto'); + await expect(await body().findByRole('button', { name: 'remove' })).toBeInTheDocument(); + await expect(canvasElement.querySelector('div.empty\\:hidden')).not.toBeNull(); + }, +}; + +export const MembersSheet: Story = { + args: { bar: 'members', isSheet: true }, + play: async ({ canvasElement }) => { + await expect(hasIcon(canvasElement, 'download')).toBe(false); + await expect(hasIcon(canvasElement, 'expand')).toBe(false); + }, +}; + +export const MembersReadOnly: Story = { + args: { bar: 'members', canUpdate: false, q: 'x' }, + play: async ({ canvasElement, args }) => { + await expect(within(canvasElement).queryByRole('button', { name: 'invite' })).toBeNull(); + await expect(hasIcon(canvasElement, 'download')).toBe(false); + await expectReset(canvasElement, args, { q: '', role: undefined }); + }, +}; + +export const AttachmentsSheet: Story = { + args: { bar: 'attachments', isSheet: true, selected: rows }, + play: async ({ canvasElement }) => { + await expect(searchInput(canvasElement, 'attachmentSearch')).not.toBeNull(); + await expect(within(canvasElement).getByRole('button', { name: 'upload' })).toBeInTheDocument(); + await expect(hasIcon(canvasElement, 'expand')).toBe(false); + await expect(hasIcon(canvasElement, 'download')).toBe(false); + await expect(await body().findByRole('button', { name: 'delete' })).toBeInTheDocument(); + // With rows in the list the edit hint shows below the bar. + await expect(await within(canvasElement).findByText('edit_attachment.text')).toBeVisible(); + }, +}; + +export const AttachmentsFiltered: Story = { + args: { bar: 'attachments', q: 'x' }, + play: async ({ canvasElement, args }) => { + await expect(within(canvasElement).queryByRole('button', { name: 'upload' })).toBeNull(); + await expect(hasIcon(canvasElement, 'expand')).toBe(true); + await expectReset(canvasElement, args, { q: '' }); + }, +}; + +export const Tenants: Story = { + args: { bar: 'tenants', q: 'x' }, + play: async ({ canvasElement, args }) => { + await expect(searchInput(canvasElement, 'tenant-search')).not.toBeNull(); + await expectReset(canvasElement, args, { q: '' }); + await expect(args.clearSelection).not.toHaveBeenCalled(); + }, +}; + +export const Pages: Story = { + args: { bar: 'pages', q: 'x' }, + play: async ({ canvasElement, args }) => { + await expect(searchInput(canvasElement, 'pageSearch')).not.toBeNull(); + await expect(within(canvasElement).getByText('2')).toBeVisible(); + await expectReset(canvasElement, args, { q: '' }); + }, +}; diff --git a/frontend/src/modules/common/data-table/stories/table-ellipsis.stories.tsx b/frontend/src/modules/common/data-table/stories/table-ellipsis.stories.tsx new file mode 100644 index 000000000..ea76b8cfd --- /dev/null +++ b/frontend/src/modules/common/data-table/stories/table-ellipsis.stories.tsx @@ -0,0 +1,117 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PencilIcon, TrashIcon } from 'lucide-react'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { TableEllipsis } from '~/modules/common/data-table/table-ellipsis'; +import { DeleteForm } from '~/modules/common/delete-form'; +import { Dropdowner } from '~/modules/common/dropdowner/provider'; +import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; +import { openPopConfirm } from '~/modules/common/popconfirm'; + +const onDelete = fn(); +const onEdit = fn(); + +const options = [ + { + label: 'Edit', + icon: PencilIcon, + onSelect: () => { + useDropdowner.getState().remove(); + onEdit(); + }, + }, + { + label: 'Delete', + icon: TrashIcon, + onSelect: () => { + const remove = () => useDropdowner.getState().remove(); + openPopConfirm( + 'Delete Acme?', + <DeleteForm + pending={false} + allowOfflineDelete + onDelete={() => { + onDelete(); + remove(); + }} + onCancel={remove} + />, + ); + }, + }, +]; + +function RowActions() { + return ( + <> + <TableEllipsis row={{ id: 'acme' }} tabIndex={0} options={options} /> + <Dropdowner /> + </> + ); +} + +/** A table row's "…" menu: Delete turns the menu into a confirmation panel on the same button, starting on Cancel. */ +const meta = { + title: 'common/data-table/TableEllipsis', + component: RowActions, + parameters: { layout: 'centered' }, + beforeEach: () => { + onDelete.mockClear(); + onEdit.mockClear(); + useDropdowner.setState({ dropdown: null, lastRemovedTriggerId: null, lastRemovedAt: 0 }); + }, +} satisfies Meta<typeof RowActions>; + +export default meta; +type Story = StoryObj<typeof meta>; + +const body = () => within(document.body); + +export const ConfirmWithMouse: Story = { + name: 'Delete confirms with the mouse', + play: async ({ canvasElement }) => { + const ellipsis = within(canvasElement).getByRole('button'); + await userEvent.click(ellipsis); + await userEvent.click(await body().findByRole('menuitem', { name: /Delete/ })); + + const title = await body().findByText('Delete Acme?'); + await waitFor(() => expect(title).toBeVisible()); + await expect(body().queryByRole('menu')).toBeNull(); + await waitFor(() => expect(body().getByRole('button', { name: /cancel/i })).toHaveFocus()); + + await userEvent.click(body().getByRole('button', { name: /delete/i })); + await expect(onDelete).toHaveBeenCalledTimes(1); + await waitFor(() => expect(body().queryByText('Delete Acme?')).toBeNull()); + }, +}; + +export const ConfirmWithKeyboard: Story = { + name: 'Delete confirms with the keyboard, and Escape cancels', + play: async ({ canvasElement }) => { + const ellipsis = within(canvasElement).getByRole('button'); + ellipsis.focus(); + await userEvent.keyboard('{Enter}'); + const menu = await body().findByRole('menu'); + await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true)); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => expect(document.activeElement).toHaveTextContent('Edit')); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => expect(document.activeElement).toHaveTextContent('Delete')); + await userEvent.keyboard('{Enter}'); + + const title = await body().findByText('Delete Acme?'); + await waitFor(() => expect(title).toBeVisible()); + const cancel = body().getByRole('button', { name: /cancel/i }); + await waitFor(() => expect(cancel).toHaveFocus()); + + // Focus stays inside the confirmation while tabbing. + await userEvent.keyboard('{Tab}'); + await expect(body().getByRole('button', { name: /delete/i })).toHaveFocus(); + await userEvent.keyboard('{Tab}'); + await expect(cancel).toHaveFocus(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(body().queryByText('Delete Acme?')).toBeNull()); + await expect(onDelete).not.toHaveBeenCalled(); + await waitFor(() => expect(ellipsis).toHaveFocus()); + }, +}; diff --git a/frontend/src/modules/common/data-table/table-bar-button.tsx b/frontend/src/modules/common/data-table/table-bar-button.tsx index 48ae4b2e0..71072fb45 100644 --- a/frontend/src/modules/common/data-table/table-bar-button.tsx +++ b/frontend/src/modules/common/data-table/table-bar-button.tsx @@ -1,32 +1,21 @@ import { motion } from 'motion/react'; import { forwardRef, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import slugify from 'slugify'; import type { TKey } from '~/lib/i18n-locales'; import type { IconComponent } from '~/modules/common/icons/types'; import { Badge } from '~/modules/ui/badge'; import { Button, type ButtonProps } from '~/modules/ui/button'; -type Props = { - icon: IconComponent; - label: TKey; - badge?: ReactNode; -} & ButtonProps; +type Props = { icon: IconComponent; label: TKey; badge?: ReactNode } & ButtonProps; -export const TableBarButton = forwardRef<HTMLButtonElement, Props>(function TableBarButton( - { icon: Icon, label, badge, className, ...props }, - ref, -) { +export const TableBarButton = forwardRef<HTMLButtonElement, Props>(function TableBarButton({ icon: Icon, label, badge, className, ...props }, ref) { const { t } = useTranslation(); - const id = slugify(label, { lower: true, strict: true }); return ( <Button {...props} render={ <motion.button ref={ref} - layout="size" - layoutId={id} className={className} transition={{ bounce: 0, duration: 0.3, ease: 'easeOut' }} initial={{ scale: 0.9, opacity: 0 }} @@ -35,7 +24,7 @@ export const TableBarButton = forwardRef<HTMLButtonElement, Props>(function Tabl /> } > - {Icon && <motion.span className="mr-2 flex items-center">{<Icon />}</motion.span>} + {Icon && <motion.span className="flex items-center">{<Icon />}</motion.span>} {label && <span>{t(label)}</span>} {badge && <Badge context="button">{badge}</Badge>} diff --git a/frontend/src/modules/common/data-table/table-bar-shell.tsx b/frontend/src/modules/common/data-table/table-bar-shell.tsx new file mode 100644 index 000000000..c9237bd8a --- /dev/null +++ b/frontend/src/modules/common/data-table/table-bar-shell.tsx @@ -0,0 +1,98 @@ +import type { Dispatch, ReactNode, SetStateAction } from 'react'; +import type { TKey } from '~/lib/i18n-locales'; +import { ColumnsView } from '~/modules/common/data-table/columns-view'; +import { Export } from '~/modules/common/data-table/export'; +import { TableBarContainer } from '~/modules/common/data-table/table-bar-container'; +import { TableCount } from '~/modules/common/data-table/table-count'; +import { FilterBarActions, FilterBarFilters, FilterBarSearch, TableFilterBar } from '~/modules/common/data-table/table-filter-bar'; +import { TableSearch } from '~/modules/common/data-table/table-search'; +import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; +import { FocusView } from '~/modules/common/focus-view'; +import { SelectionActionBar } from '~/modules/common/selection-action-bar'; + +interface TableBarFiltersOptions<T extends { q?: string }> { + searchVars: T; + setSearch: (values: Partial<T>) => void; + clearSelection?: () => void; + /** Search values a reset writes; a filter is active while its key holds another value. */ + reset: Partial<T>; +} + +/** Search and reset handlers of a table bar; a new search clears the selection first, a reset clears it after. */ +export function useTableBarFilters<T extends { q?: string }>(options: TableBarFiltersOptions<T>) { + const { searchVars, setSearch, clearSelection, reset } = options; + const isFiltered = Object.entries(reset).some(([key, value]) => { + const current = searchVars[key as keyof T]; + return current !== undefined && current !== value; + }); + + const onSearch = (q: string) => { + clearSelection?.(); + setSearch({ q } as Partial<T>); + }; + + const onResetFilters = () => { + setSearch(reset); + clearSelection?.(); + }; + + return { isFiltered, onSearch, onResetFilters }; +} + +interface TableBarShellProps<TRow extends Record<string, unknown>> extends ReturnType<typeof useTableBarFilters> { + searchVars: { q?: string }; + total: number | null; + /** Count label, pluralized by the total. */ + label: TKey; + /** Name of the search input. */ + searchName: string; + allowOfflineSearch?: boolean; + columns: ColumnOrColumnGroup<TRow>[]; + setColumns: Dispatch<SetStateAction<ColumnOrColumnGroup<TRow>[]>>; + /** Buttons before the count, hidden while filtered. */ + actions?: ReactNode; + /** Rendered after the count. */ + countExtra?: ReactNode; + /** Filter controls next to the search. */ + filters?: ReactNode; + export?: { filename: string; fetchRows: (limit: number, offset: number) => Promise<TRow[]>; selectedRows?: TRow[] }; + /** Shows the focus view toggle. Defaults to true. */ + focusView?: boolean; + selection?: { count: number; onClear: () => void; children: ReactNode }; + /** Rendered below the bar. */ + after?: ReactNode; +} + +/** The bar above an entity table: actions and count, search and filters, columns, export and focus view. */ +export function TableBarShell<TRow extends Record<string, unknown>>(props: TableBarShellProps<TRow>) { + const { searchVars, isFiltered, onSearch, onResetFilters, columns, focusView = true, selection } = props; + + return ( + <> + <TableBarContainer searchVars={searchVars}> + <TableFilterBar onResetFilters={onResetFilters} isFiltered={isFiltered}> + <FilterBarActions> + {!isFiltered && props.actions} + <TableCount count={props.total} label={props.label} isFiltered={isFiltered} onResetFilters={onResetFilters}> + {props.countExtra} + </TableCount> + </FilterBarActions> + + <div className="sm:grow" /> + + <FilterBarSearch> + <TableSearch name={props.searchName} value={searchVars.q} setQuery={onSearch} allowOfflineSearch={props.allowOfflineSearch} /> + </FilterBarSearch> + {props.filters && <FilterBarFilters>{props.filters}</FilterBarFilters>} + </TableFilterBar> + + <ColumnsView className="max-lg:hidden" columns={columns} setColumns={props.setColumns} /> + {props.export && <Export className="max-lg:hidden" columns={columns} {...props.export} />} + {focusView && <FocusView iconOnly />} + </TableBarContainer> + + {selection && <SelectionActionBar {...selection} />} + {props.after} + </> + ); +} diff --git a/frontend/src/modules/common/data-table/table-count.tsx b/frontend/src/modules/common/data-table/table-count.tsx index 4eaff371e..c08c8a17c 100644 --- a/frontend/src/modules/common/data-table/table-count.tsx +++ b/frontend/src/modules/common/data-table/table-count.tsx @@ -20,7 +20,7 @@ export function TableCount({ count, label, className, isFiltered, children, onRe <div className={cn('flex items-center gap-2 text-sm max-sm:hidden', className)}> {isFiltered && ( <Button variant="ghost" onClick={onResetFilters} className="max-sm:hidden"> - <FunnelXIcon className="mr-2" /> + <FunnelXIcon /> {t('c:clear')} </Button> )} diff --git a/frontend/src/modules/common/data-table/table-ellipsis.tsx b/frontend/src/modules/common/data-table/table-ellipsis.tsx index d65cd584e..cf2a5219c 100644 --- a/frontend/src/modules/common/data-table/table-ellipsis.tsx +++ b/frontend/src/modules/common/data-table/table-ellipsis.tsx @@ -33,13 +33,7 @@ export function TableEllipsis<T extends { id: string }>({ row, tabIndex, options {label} </DropdownActionItem> )), - { - id: 'row-dropdown', - triggerId: `ellipsis-${row.id}`, - triggerRef, - align: 'end', - kind: 'menu', - }, + { id: 'row-dropdown', triggerId: `ellipsis-${row.id}`, triggerRef, align: 'end', kind: 'menu' }, ); }; diff --git a/frontend/src/modules/common/data-table/table-filter-bar.tsx b/frontend/src/modules/common/data-table/table-filter-bar.tsx index 461ecdc05..0516d88d7 100755 --- a/frontend/src/modules/common/data-table/table-filter-bar.tsx +++ b/frontend/src/modules/common/data-table/table-filter-bar.tsx @@ -33,11 +33,7 @@ export function FilterBarActions({ children, className = '' }: FilterBarChildPro <motion.div animate={{ opacity: isFilterActive ? 0 : 1, x: isFilterActive ? -20 : 0 }} transition={{ duration: 0.15 }} - className={cn( - 'flex items-center gap-3 max-sm:shrink-0', - className, - isFilterActive && 'max-sm:pointer-events-none', - )} + className={cn('flex items-center gap-3 max-sm:shrink-0', className, isFilterActive && 'max-sm:pointer-events-none')} > {children} </motion.div> @@ -112,9 +108,7 @@ export function TableFilterBar({ onResetFilters, isFiltered, children }: TableFi return ( <div className="flex w-full items-center gap-2 max-sm:relative max-sm:flex-1"> - <TableFilterBarContext.Provider value={{ isFilterActive: effectiveFilterActive, setFilterActive }}> - {children} - </TableFilterBarContext.Provider> + <TableFilterBarContext.Provider value={{ isFilterActive: effectiveFilterActive, setFilterActive }}>{children}</TableFilterBarContext.Provider> <Button variant="secondary" diff --git a/frontend/src/modules/common/data-table/table-skeleton.tsx b/frontend/src/modules/common/data-table/table-skeleton.tsx index 7b8cb8b20..77cb4734e 100644 --- a/frontend/src/modules/common/data-table/table-skeleton.tsx +++ b/frontend/src/modules/common/data-table/table-skeleton.tsx @@ -2,6 +2,7 @@ import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { useMountedState } from '~/hooks/use-mounted-state'; import { Skeleton } from '~/modules/ui/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/modules/ui/table'; +import { cn } from '~/utils/cn'; interface DataTableSkeletonProps { rowCount?: number; @@ -26,21 +27,13 @@ export function DataTableSkeleton({ const effectiveColumnCount = isMobile ? Math.min(columnCount, 3) : columnCount; return ( - <div - className={`w-full space-y-3 overflow-auto transition-opacity duration-500 ${hasMounted ? 'opacity-100' : 'opacity-0'}`} - > + <div className={cn('w-full space-y-3 overflow-auto transition-opacity duration-500', hasMounted ? 'opacity-100' : 'opacity-0')}> <Table> <TableHeader> {Array.from({ length: 1 }).map((_, i) => ( <TableRow key={i.toString()} className="hover:bg-transparent"> {Array.from({ length: effectiveColumnCount }).map((_, j) => ( - <TableHead - key={j.toString()} - style={{ - width: cellsWidths[j] ? cellsWidths[j] : 'auto', - minWidth: shrinkTable ? cellsWidths[j] : 'auto', - }} - > + <TableHead key={j.toString()} style={{ width: cellsWidths[j] || 'auto', minWidth: shrinkTable ? cellsWidths[j] : 'auto' }}> <Skeleton className={'mt-2 mb-2 w-full'} style={{ height: `${renderCellHeight}px` }} /> </TableHead> ))} @@ -51,13 +44,7 @@ export function DataTableSkeleton({ {Array.from({ length: rowCount }).map((_, i) => ( <TableRow key={i.toString()} className="hover:bg-transparent"> {Array.from({ length: effectiveColumnCount }).map((_, j) => ( - <TableCell - key={j.toString()} - style={{ - width: cellsWidths[j] ? cellsWidths[j] : 'auto', - minWidth: shrinkTable ? cellsWidths[j] : 'auto', - }} - > + <TableCell key={j.toString()} style={{ width: cellsWidths[j] || 'auto', minWidth: shrinkTable ? cellsWidths[j] : 'auto' }}> <Skeleton className={'w-full'} style={{ height: `${renderCellHeight}px` }} /> </TableCell> ))} diff --git a/frontend/src/modules/common/data-table/tests/entity-columns.test.tsx b/frontend/src/modules/common/data-table/tests/entity-columns.test.tsx new file mode 100644 index 000000000..f6b839b5b --- /dev/null +++ b/frontend/src/modules/common/data-table/tests/entity-columns.test.tsx @@ -0,0 +1,346 @@ +import '~/query/tests/query-client-env'; +import '~/lib/dayjs'; +import { QueryClientProvider } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { createElement, Fragment, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { appConfig, hierarchy, isChannel } from 'shared'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { hiddenMemberCountColumns } from '~/members-config'; +import type { EllipsisOption } from '~/modules/common/data-table/table-ellipsis'; + +/** The options of the ellipsis cell rendered last. */ +const seen = vi.hoisted(() => ({ options: [] as EllipsisOption<{ id: string }>[] })); + +vi.mock('i18next', async (importOriginal) => { + const actual = await importOriginal<typeof import('i18next')>(); + const t = (key: string) => key; + return { ...actual, t, default: { ...actual.default, t } }; +}); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('~/modules/common/data-table/table-ellipsis', () => ({ + TableEllipsis: ({ options }: { options: EllipsisOption<{ id: string }>[] }) => { + seen.options = options; + return <span>{options.map(({ label }) => label).join('|')}</span>; + }, +})); + +const { queryClient } = await import('~/query/query-client'); +const { dateShort } = await import('~/utils/date-short'); +const { useDropdowner } = await import('~/modules/common/dropdowner/use-dropdowner'); +const { useSheeter } = await import('~/modules/common/sheeter/use-sheeter'); +const { useUserStore } = await import('~/modules/user/user-store'); +const users = await import('~/modules/user/table/users-columns'); +const organizations = await import('~/modules/organization/table/organizations-columns'); +const tenants = await import('~/modules/tenants/table/tenants-columns'); +const requests = await import('~/modules/requests/table/requests-columns'); +const members = await import('~/modules/memberships/members-table/members-columns'); +const pending = await import('~/modules/memberships/pending-table/pending-columns'); +const invitations = await import('~/modules/me/invitations-table/invitations-columns'); +const attachments = await import('~/modules/attachment/table/attachments-columns'); +const { exportToCsv } = await import('~/lib/export'); + +type Column = { key: string; renderCell?: (props: { row: never; tabIndex: number }) => ReactNode } & Record<string, unknown>; + +/** Runs a column hook inside a render and returns its columns. */ +function columnsOf(useHook: () => unknown): Column[] { + let result: unknown; + function Probe() { + result = useHook(); + return null; + } + renderToStaticMarkup( + <QueryClientProvider client={queryClient}> + <Probe /> + </QueryClientProvider>, + ); + const [first] = result as [unknown]; + return (Array.isArray(first) ? first : result) as Column[]; +} + +const column = (columns: Column[], key: string) => { + const found = columns.find((col) => col.key === key); + if (!found) throw new Error(`no ${key} column`); + return found; +}; + +/** The column without its renderer and export value, for comparing configuration. */ +const configOf = ({ renderCell, exportValue, ...config }: Column) => config; + +const cellMarkup = (col: Column, row: unknown) => + renderToStaticMarkup(createElement(Fragment, null, col.renderCell?.({ row: row as never, tabIndex: 0 }))); + +const created = '2026-05-04T10:00:00.000Z'; +const seenAt = '2026-06-01T08:30:00.000Z'; +const channel = { + id: 'org-1', + entityType: 'organization', + tenantId: 'tenant-1', + organizationId: 'org-1', + can: { attachment: { delete: true } }, +} as never; + +const listedDate = { sortable: true, sortDescendingFirst: true, minBreakpoint: 'md', minWidth: 120, placeholderValue: '-' }; + +afterEach(() => { + seen.options = []; + useSheeter.setState({ sheets: [] }); + vi.restoreAllMocks(); +}); + +describe('date columns', () => { + it.each([ + { + name: 'users created', + columns: () => columnsOf(users.useColumns), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate }, + row: { createdAt: created }, + value: created, + }, + { + name: 'users last seen, ascending on the first sort', + columns: () => columnsOf(users.useColumns), + key: 'lastSeenAt', + config: { key: 'lastSeenAt', name: 'c:last_seen_at', ...listedDate, sortDescendingFirst: undefined }, + row: { lastSeenAt: seenAt }, + value: seenAt, + }, + { + name: 'organizations created', + columns: () => columnsOf(organizations.useColumns), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate }, + row: { createdAt: created }, + value: created, + }, + { + name: 'tenants created', + columns: () => columnsOf(tenants.useColumns), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate }, + row: { createdAt: created }, + value: created, + }, + { + name: 'requests created', + columns: () => columnsOf(requests.useColumns), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate }, + row: { createdAt: created }, + value: created, + }, + { + name: 'members created, hidden in a sheet', + columns: () => columnsOf(() => members.useColumns(true, true, 'organization')), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate, hidden: true }, + row: { createdAt: created }, + value: created, + }, + { + name: 'members created, shown on a page', + columns: () => columnsOf(() => members.useColumns(true, false, 'organization')), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate, hidden: false }, + row: { createdAt: created }, + value: created, + }, + { + name: 'pending memberships invited', + columns: () => columnsOf(() => pending.useColumns({ tenantId: 'tenant-1', organizationId: 'org-1' }, true)), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:invited_at', ...listedDate }, + row: { createdAt: created }, + value: created, + }, + { + name: 'invitations invited, unsorted and nested', + columns: () => columnsOf(invitations.useColumns), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:invited_at', ...listedDate, sortable: undefined, sortDescendingFirst: undefined }, + row: { inactiveMembership: { createdAt: created } }, + value: created, + }, + { + name: 'attachments created, hidden in a sheet', + columns: () => columnsOf(() => attachments.useColumns(channel, true)), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate, hidden: true }, + row: { createdAt: created }, + value: created, + }, + { + name: 'attachments created, shown on a page', + columns: () => columnsOf(() => attachments.useColumns(channel, false)), + key: 'createdAt', + config: { key: 'createdAt', name: 'c:created_at', ...listedDate, hidden: false }, + row: { createdAt: created }, + value: created, + }, + ])('$name', ({ columns, key, config, row, value }) => { + const col = column(columns(), key); + + expect(configOf(col)).toEqual(config); + expect(cellMarkup(col, row)).toBe(dateShort(value)); + expect(cellMarkup(col, { ...row, createdAt: null, lastSeenAt: null })).toBe('inactiveMembership' in row ? dateShort(value) : ''); + }); +}); + +describe('ellipsis columns', () => { + // Opening a sheet checks the focused element. + beforeAll(() => { + vi.stubGlobal('document', { activeElement: null }); + vi.stubGlobal('HTMLButtonElement', class {}); + vi.stubGlobal('HTMLAnchorElement', class {}); + }); + + const rowAction = (label: string) => { + const option = seen.options.find((candidate) => candidate.label === label); + if (!option) throw new Error(`no ${label} option`); + return option; + }; + const trigger = { current: null }; + + it.each([ + { name: 'users', columns: () => columnsOf(users.useColumns), sheet: 'update-user' }, + { name: 'organizations', columns: () => columnsOf(organizations.useColumns), sheet: 'update-organization' }, + ])('$name: edit opens the edit sheet, delete swaps in a confirmation', ({ columns, sheet }) => { + const col = column(columns(), 'ellipsis'); + const row = { id: 'row-1', name: 'Row one', tenantId: 'tenant-1' }; + + expect(configOf(col)).toEqual({ key: 'ellipsis', name: '', width: 32 }); + expect(cellMarkup(col, row)).toBe('<span>c:edit|c:delete</span>'); + + const remove = vi.spyOn(useDropdowner.getState(), 'remove'); + rowAction('c:edit').onSelect(row, trigger); + expect(remove).toHaveBeenCalled(); + expect(useSheeter.getState().sheets.map(({ id }) => id)).toEqual([sheet]); + + const update = vi.spyOn(useDropdowner.getState(), 'update'); + rowAction('c:delete').onSelect(row, trigger); + const content = update.mock.calls[0][0].content as ReactElement<{ title: string }>; + expect(content.props.title).toBe('c:delete_confirm.text'); + }); + + it('tenants: edit only', () => { + const col = column(columnsOf(tenants.useColumns), 'ellipsis'); + + expect(configOf(col)).toEqual({ key: 'ellipsis', name: '', width: 32 }); + expect(cellMarkup(col, { id: 'tenant-1', name: 'One' })).toBe('<span>c:edit</span>'); + + rowAction('c:edit').onSelect({ id: 'tenant-1' }, trigger); + expect(useSheeter.getState().sheets.map(({ id }) => id)).toEqual(['update-tenant']); + }); + + it('attachments: delete only on small screens, with a cancel that closes the menu', () => { + const col = column( + columnsOf(() => attachments.useColumns(channel, false)), + 'ellipsis', + ); + const row = { id: 'att-1', name: 'File', createdBy: null, organizationId: 'org-1' }; + + expect(configOf(col)).toEqual({ key: 'ellipsis', name: '', width: 32, maxBreakpoint: 'sm' }); + expect(cellMarkup(col, row)).toBe('<span>c:delete</span>'); + + const update = vi.spyOn(useDropdowner.getState(), 'update'); + rowAction('c:delete').onSelect(row, trigger); + const content = update.mock.calls[0][0].content as ReactElement<{ + title: string; + children: ReactElement<{ callback: unknown; onCancel: unknown }>; + }>; + expect(content.props.title).toBe('c:delete_confirm.text'); + expect(content.props.children.props.onCancel).toBe(useDropdowner.getState().remove); + expect(content.props.children.props.callback).toBe(useDropdowner.getState().remove); + }); + + it('attachments: renders nothing without delete permission', () => { + useUserStore.setState({ user: { id: 'me' } as never }); + const noDelete = { ...(channel as object), can: { attachment: { delete: false } } } as never; + const col = column( + columnsOf(() => attachments.useColumns(noDelete, false)), + 'ellipsis', + ); + + expect(cellMarkup(col, { id: 'att-1', createdBy: null })).toBe(''); + useUserStore.setState({ user: null }); + }); +}); + +describe('csv export', () => { + /** The lines of the CSV file an export of these columns and rows downloads. */ + async function csvLines(columns: Column[], rows: Record<string, unknown>[]) { + let file: Blob | undefined; + vi.stubGlobal('document', { activeElement: null, createElement: () => ({ click: () => {} }) }); + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + file = blob as Blob; + return 'blob:export'; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + await exportToCsv(columns as never, rows, 'export.csv'); + return file ? (await file.text()).split('\n') : []; + } + + // The export writes dates in the long localized format; with a comma in it, the cell is quoted. + const dateCell = (value: string | number) => `"${dayjs.utc(value).local().format('lll')}"`; + + // fork: count columns follow the app's hierarchy, not the template's attachment-only one + const orgDescendants = hierarchy.getOrderedDescendants('organization'); + const orgCountTypes = orgDescendants.filter( + (type) => type === orgDescendants.filter((t) => isChannel(t)).at(-1) || type === orgDescendants.find((t) => !isChannel(t)), + ); + const statTypes: readonly string[] = appConfig.memberStatProductTypes; + const memberCountTypes = [...statTypes, ...orgDescendants.filter((type) => isChannel(type) && type !== 'organization')].filter( + (type) => !(hiddenMemberCountColumns as readonly string[]).includes(type), + ); + const cells = (types: readonly string[], cell: (type: string) => string) => types.map(cell).join(','); + + it('organizations: visible columns with names as text, the role, counts and dates, and a dash when missing', async () => { + const rows = [ + { + id: 'org-48', + name: 'Tenant 48', + createdAt: created, + membership: { role: 'admin' }, + // fork: one zero count per visible count column + included: { counts: { membership: { admin: 2, member: 5 }, entities: Object.fromEntries(orgCountTypes.map((type) => [type, 0])) } }, + }, + { id: 'org-12', name: 'Organization 12', createdAt: null, membership: null, included: {} }, + // A row fetched for the export carries the caller's membership under `included`. + { id: 'org-7', name: 'Seven', included: { membership: { role: 'member' } } }, + ]; + + // fork: count cells per visible count column + expect(await csvLines(columnsOf(organizations.useColumns), rows)).toEqual([ + `c:name,c:your_role,c:created_at,c:admin,c:member,${cells(orgCountTypes, (type) => `c:${type}`)}`, + `Tenant 48,admin,${dateCell(created)},2,5,${cells(orgCountTypes, () => '0')}`, + `Organization 12,-,-,-,-,${cells(orgCountTypes, () => '-')}`, + `Seven,member,-,-,-,${cells(orgCountTypes, () => '-')}`, + ]); + }); + + it('members: visible columns with the role, dates and per-member counts, and a dash when missing', async () => { + const postedAt = Date.parse(seenAt); + const rows = [ + { + id: 'user-48', + name: 'Tenant 48', + email: 'ada@example.com', + membership: { role: 'member' }, + createdAt: created, + lastSeenAt: seenAt, + // fork: the app's stat product types + counts: { memberships: {}, products: Object.fromEntries(statTypes.map((type) => [type, 3])), activity: { [statTypes[0]]: postedAt } }, + }, + { id: 'user-12', name: 'Organization 12', email: null, membership: null, createdAt: null, lastSeenAt: null }, + ]; + + const columns = columnsOf(() => members.useColumns(true, false, 'organization')); + // fork: count cells per visible count column; channel counts are absent from the row + expect(await csvLines(columns, rows)).toEqual([ + `c:name,c:email,c:role,c:created_at,c:last_seen_at,c:last_post,${cells(memberCountTypes, (type) => `c:${type}`)}`, + `Tenant 48,ada@example.com,member,${dateCell(created)},${dateCell(seenAt)},${dateCell(postedAt)},${cells(memberCountTypes, (type) => (statTypes.includes(type) ? '3' : '-'))}`, + `Organization 12,-,-,-,-,-,${cells(memberCountTypes, () => '-')}`, + ]); + }); +}); diff --git a/frontend/src/modules/common/data-table/tests/infinite-tables.test.tsx b/frontend/src/modules/common/data-table/tests/infinite-tables.test.tsx new file mode 100644 index 000000000..18721943c --- /dev/null +++ b/frontend/src/modules/common/data-table/tests/infinite-tables.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment jsdom +import { QueryClientProvider } from '@tanstack/react-query'; +import { act, type ComponentType } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type Row = { id: string; name: string }; +type TableProps = { + rows?: Row[]; + fetchMore?: () => Promise<void>; + hasNextPage?: boolean; + isFetching?: boolean; + isFiltered?: boolean; + selectedRows?: Set<string>; + onSelectedRowsChange?: (ids: Set<string>) => void; +}; +type BarProps = { selected?: Row[]; clearSelection?: () => void }; + +/** Props of every render of the data table and the bar, newest last. */ +const seen = vi.hoisted(() => ({ table: [] as unknown[], bar: [] as unknown[], search: {} as Record<string, unknown> })); + +/** Bumping `version` renames every row on the next fetch; while `hold` is pending, fetches wait for it. */ +const server = vi.hoisted(() => ({ version: 0, hold: undefined as Promise<void> | undefined })); + +// Five rows served two per page, so a table needs three pages to hold them all. +const pagedFetch = vi.hoisted(() => + vi.fn(async ({ query }: { query?: { offset?: string } }) => { + if (server.hold) await server.hold; + const all = Array.from({ length: 5 }, (_, i) => ({ id: `r${i}`, name: `row ${i} v${server.version}` })); + const offset = Number(query?.offset ?? 0); + return { items: all.slice(offset, offset + 2), total: all.length }; + }), +); + +vi.mock('sdk', async (importOriginal) => ({ + ...(await importOriginal<typeof import('sdk')>()), + getUsers: pagedFetch, + getOrganizations: pagedFetch, + getTenants: pagedFetch, + getRequests: pagedFetch, + getMembers: pagedFetch, + getPendingMemberships: pagedFetch, + getAttachments: pagedFetch, +})); +// The app client module writes HMR state on load, which this environment does not provide; the defaults match the app's. +vi.mock('~/query/query-client', async () => { + const { QueryClient } = await import('@tanstack/react-query'); + const queries = { networkMode: 'offlineFirst', refetchOnMount: false, retry: false } as const; + return { queryClient: new QueryClient({ defaultOptions: { queries } }) }; +}); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('~/hooks/use-search-params', () => ({ useSearchParams: () => ({ search: seen.search, setSearch: vi.fn() }) })); +vi.mock('~/hooks/use-route-context', () => ({ useOrganizationLayoutContext: () => ({ organizationId: 'org-1', tenantId: 'tenant-1' }) })); +vi.mock('~/modules/common/data-table/data-table', () => ({ + DataTable: (props: unknown) => { + seen.table.push(props); + return null; + }, +})); + +const captureBar = (props: unknown) => { + seen.bar.push(props); + return null; +}; +vi.mock('~/modules/user/table/users-bar', () => ({ UsersTableBar: captureBar })); +vi.mock('~/modules/user/table/users-columns', () => ({ useColumns: () => [[], vi.fn()] })); +vi.mock('~/modules/organization/table/organizations-bar', () => ({ OrganizationsTableBar: captureBar })); +vi.mock('~/modules/organization/table/organizations-columns', () => ({ useColumns: () => [[], vi.fn()] })); +vi.mock('~/modules/tenants/table/tenants-bar', () => ({ TenantsTableBar: captureBar })); +vi.mock('~/modules/tenants/table/tenants-columns', () => ({ useColumns: () => [[], vi.fn()] })); +vi.mock('~/modules/requests/table/requests-bar', () => ({ RequestsTableBar: captureBar })); +vi.mock('~/modules/requests/table/requests-columns', () => ({ useColumns: () => [[], vi.fn()] })); +vi.mock('~/modules/memberships/members-table/members-bar', () => ({ MembersTableBar: captureBar })); +vi.mock('~/modules/memberships/members-table/members-columns', () => ({ useColumns: () => [[], vi.fn()] })); +vi.mock('~/modules/memberships/pending-table/pending-bar', () => ({ PendingMembershipsTableBar: captureBar })); +vi.mock('~/modules/memberships/pending-table/pending-columns', () => ({ useColumns: () => [[], vi.fn()] })); +vi.mock('~/modules/attachment/table/attachments-bar', () => ({ AttachmentsTableBar: captureBar })); +vi.mock('~/modules/attachment/table/attachments-columns', () => ({ useColumns: () => [] })); + +const { queryClient } = await import('~/query/query-client'); +const { UsersTable } = await import('~/modules/user/table/users-table'); +const { OrganizationsTable } = await import('~/modules/organization/table/organizations-table'); +const { TenantsTable } = await import('~/modules/tenants/table/tenants-table'); +const { RequestsTable } = await import('~/modules/requests/table/requests-table'); +const { MembersTable } = await import('~/modules/memberships/members-table/members-table'); +const { PendingMembershipsTable } = await import('~/modules/memberships/pending-table/pending-memberships-table'); +const { AttachmentsTable } = await import('~/modules/attachment/table/attachments-table'); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const channel = { id: 'org-1', entityType: 'organization', tenantId: 'tenant-1', organizationId: 'org-1', can: {} }; +const lastTable = () => seen.table.at(-1) as TableProps; +const lastBar = () => seen.bar.at(-1) as BarProps; +const ids = (rows?: Row[]) => rows?.map((row) => row.id); + +let root: Root | undefined; + +async function render(Table: ComponentType<Record<string, unknown>>, props: Record<string, unknown> = {}) { + root = createRoot(document.createElement('div')); + await act(async () => + root?.render( + <QueryClientProvider client={queryClient}> + <Table {...props} /> + </QueryClientProvider>, + ), + ); + await vi.waitFor(() => expect(lastTable().rows).toBeDefined()); +} + +afterEach(async () => { + await act(async () => root?.unmount()); + queryClient.clear(); + seen.table.length = 0; + seen.bar.length = 0; + seen.search = {}; + server.version = 0; + server.hold = undefined; + pagedFetch.mockClear(); +}); + +const pagedTables: { name: string; Table: ComponentType<never>; props?: Record<string, unknown> }[] = [ + { name: 'users', Table: UsersTable }, + { name: 'organizations', Table: OrganizationsTable }, + { name: 'tenants', Table: TenantsTable }, + { name: 'requests', Table: RequestsTable }, + { name: 'members', Table: MembersTable, props: { channel } }, + { name: 'pending memberships', Table: PendingMembershipsTable, props: { channel } }, +]; + +describe.each(pagedTables)('$name table paging', ({ Table, props }) => { + const Rendered = Table as ComponentType<Record<string, unknown>>; + + it('flattens the loaded pages into rows and fetches the next page on demand', async () => { + await render(Rendered, props); + expect(ids(lastTable().rows)).toEqual(['r0', 'r1']); + expect(lastTable().hasNextPage).toBe(true); + + await act(async () => lastTable().fetchMore?.()); + await vi.waitFor(() => expect(ids(lastTable().rows)).toEqual(['r0', 'r1', 'r2', 'r3'])); + expect(pagedFetch).toHaveBeenLastCalledWith(expect.objectContaining({ query: expect.objectContaining({ offset: '2' }) })); + }); + + it('ignores fetchMore while a page is loading and once every page is in', async () => { + await render(Rendered, props); + + let release = () => {}; + server.hold = new Promise((resolve) => { + release = resolve; + }); + await act(async () => void lastTable().fetchMore?.()); + await vi.waitFor(() => expect(lastTable().isFetching).toBe(true)); + await act(async () => void lastTable().fetchMore?.()); + expect(pagedFetch).toHaveBeenCalledTimes(2); + + server.hold = undefined; + release(); + await vi.waitFor(() => expect(lastTable().isFetching).toBe(false)); + expect(pagedFetch).toHaveBeenCalledTimes(2); + + await act(async () => lastTable().fetchMore?.()); + await vi.waitFor(() => expect(lastTable().hasNextPage).toBe(false)); + const calls = pagedFetch.mock.calls.length; + await act(async () => lastTable().fetchMore?.()); + expect(pagedFetch).toHaveBeenCalledTimes(calls); + }); + + it('hands the data table a new fetchMore on every render', async () => { + await render(Rendered, props); + const renders = seen.table.length; + + await act(async () => lastTable().fetchMore?.()); + await vi.waitFor(() => expect(seen.table.length).toBeGreaterThan(renders)); + + const [previous, current] = seen.table.slice(-2) as TableProps[]; + expect(current.fetchMore).not.toBe(previous.fetchMore); + }); +}); + +const selectingTables: { name: string; Table: ComponentType<never>; props?: Record<string, unknown> }[] = [ + { name: 'users', Table: UsersTable }, + { name: 'organizations', Table: OrganizationsTable }, + { name: 'requests', Table: RequestsTable }, + { name: 'members', Table: MembersTable, props: { channel } }, + { name: 'attachments', Table: AttachmentsTable, props: { channel } }, +]; + +describe.each(selectingTables)('$name table selection', ({ Table, props }) => { + const Rendered = Table as ComponentType<Record<string, unknown>>; + + it('keeps snapshots of the selected rows, drops unknown ids and clears', async () => { + await render(Rendered, props); + const rows = lastTable().rows ?? []; + + await act(async () => lastTable().onSelectedRowsChange?.(new Set(['r1', 'missing']))); + expect(lastBar().selected).toEqual([rows[1]]); + expect(lastBar().selected?.[0]).toBe(rows[1]); + expect(lastTable().selectedRows).toEqual(new Set(['r1'])); + + // A refetch replaces the row; the selection keeps the object it was picked from. + server.version = 1; + await act(async () => { + for (const [key] of queryClient.getQueriesData({})) queryClient.invalidateQueries({ queryKey: key }); + }); + await vi.waitFor(() => expect(lastTable().rows?.[1]).not.toBe(rows[1])); + expect(lastBar().selected?.[0]).toBe(rows[1]); + + await act(async () => lastBar().clearSelection?.()); + expect(lastBar().selected).toEqual([]); + expect(lastTable().selectedRows).toEqual(new Set()); + }); +}); + +describe('filtered state', () => { + it.each([ + { name: 'users by role', Table: UsersTable, search: { role: 'admin' }, filtered: true }, + { name: 'users by search', Table: UsersTable, search: { q: 'ada' }, filtered: true }, + { name: 'users unfiltered', Table: UsersTable, search: {}, filtered: false }, + { name: 'members by role', Table: MembersTable, search: { role: 'member' }, filtered: true, props: { channel } }, + { name: 'organizations by search', Table: OrganizationsTable, search: { q: 'x' }, filtered: true }, + { name: 'organizations ignore role', Table: OrganizationsTable, search: { role: 'admin' }, filtered: false }, + ] as { name: string; Table: ComponentType<never>; search: Record<string, unknown>; filtered: boolean; props?: Record<string, unknown> }[])( + '$name', + async ({ Table, search, filtered, props }) => { + seen.search = search; + await render(Table as ComponentType<Record<string, unknown>>, props); + expect(lastTable().isFiltered).toBe(filtered); + }, + ); +}); diff --git a/frontend/src/modules/common/data-table/tree/expand-toggle-column.tsx b/frontend/src/modules/common/data-table/tree/expand-toggle-column.tsx index a0b8c324f..2a2d3c81b 100644 --- a/frontend/src/modules/common/data-table/tree/expand-toggle-column.tsx +++ b/frontend/src/modules/common/data-table/tree/expand-toggle-column.tsx @@ -35,8 +35,8 @@ const def: ColumnOrColumnGroup<AnyTreeRow> = { // Focus ring moves to the toggle button; leaf rows keep the cell outline. cellClass: (row) => row._hasChildren - ? 'flex items-center justify-center !p-0 aria-selected:outline-none aria-selected:[&_[data-slot=expand-toggle]]:ring-2 aria-selected:[&_[data-slot=expand-toggle]]:ring-ring aria-selected:[&_[data-slot=expand-toggle]]:ring-offset-2 aria-selected:[&_[data-slot=expand-toggle]]:ring-offset-background' - : 'flex items-center justify-center !p-0', + ? 'flex items-center justify-center p-0! aria-selected:outline-hidden aria-selected:[&_[data-slot=expand-toggle]]:ring-2 aria-selected:[&_[data-slot=expand-toggle]]:ring-ring aria-selected:[&_[data-slot=expand-toggle]]:ring-offset-2 aria-selected:[&_[data-slot=expand-toggle]]:ring-offset-background' + : 'flex items-center justify-center p-0!', renderCell: ({ row, tabIndex }) => <ExpandToggleCell row={row} tabIndex={tabIndex ?? -1} />, }; diff --git a/frontend/src/modules/common/data-table/tree/use-tree-rows.ts b/frontend/src/modules/common/data-table/tree/use-tree-rows.ts index 8789ce546..4dc74543c 100644 --- a/frontend/src/modules/common/data-table/tree/use-tree-rows.ts +++ b/frontend/src/modules/common/data-table/tree/use-tree-rows.ts @@ -74,28 +74,25 @@ export function useTreeRows<T extends TreeItem>(opts: UseTreeRowsOptions<T>) { [toggledIds], ); - const canDrop = useCallback( - (rows: readonly TreeRow<T>[] | undefined, { fromIdx, toIdx, zone }: CanDropArgs): boolean => { - if (!rows) return false; - const dragged = rows[fromIdx]; - const target = rows[toIdx]; - if (!dragged || !target) return false; - const o = optsRef.current; - const getId = o.getId ?? treeItemAccessors.getId; - const getParentId = o.getParentId ?? treeItemAccessors.getParentId; - - // Cycle prevention: target must not be the dragged row or any descendant. - const byId = new Map(rows.map((r) => [getId(r), r] as const)); - if (isSelfOrDescendantOf(getId(dragged), getId(target), byId, getParentId)) return false; - - // 'center' lands as a child, as does 'bottom' on an expanded parent; otherwise the target's depth is reused. - const landsAsChild = zone === 'center' || (zone === 'bottom' && target._hasChildren && target._isExpanded); - const targetDepth = landsAsChild ? target._depth + 1 : target._depth; - if (o.maxDepth !== undefined && targetDepth + dragged._subtreeHeight > o.maxDepth - 1) return false; - return true; - }, - [], - ); + const canDrop = useCallback((rows: readonly TreeRow<T>[] | undefined, { fromIdx, toIdx, zone }: CanDropArgs): boolean => { + if (!rows) return false; + const dragged = rows[fromIdx]; + const target = rows[toIdx]; + if (!dragged || !target) return false; + const o = optsRef.current; + const getId = o.getId ?? treeItemAccessors.getId; + const getParentId = o.getParentId ?? treeItemAccessors.getParentId; + + // Cycle prevention: target must not be the dragged row or any descendant. + const byId = new Map(rows.map((r) => [getId(r), r] as const)); + if (isSelfOrDescendantOf(getId(dragged), getId(target), byId, getParentId)) return false; + + // 'center' lands as a child, as does 'bottom' on an expanded parent; otherwise the target's depth is reused. + const landsAsChild = zone === 'center' || (zone === 'bottom' && target._hasChildren && target._isExpanded); + const targetDepth = landsAsChild ? target._depth + 1 : target._depth; + if (o.maxDepth !== undefined && targetDepth + dragged._subtreeHeight > o.maxDepth - 1) return false; + return true; + }, []); const onReorder = useCallback( (rows: readonly TreeRow<T>[] | undefined, fromIdx: number, toIdx: number, edge: 'top' | 'bottom') => { @@ -115,9 +112,7 @@ export function useTreeRows<T extends TreeItem>(opts: UseTreeRowsOptions<T>) { const siblings = rows.filter((r) => (getParentId(r) ?? null) === targetParentId); // `getRelativeOrder` only needs `{ id, displayOrder }` per item. const siblingItems = siblings.map((s) => ({ id: getId(s), displayOrder: getDisplayOrder(s) })); - const anchorOrder = dropAsFirstChild - ? Math.min(...siblings.map((s) => getDisplayOrder(s))) - : getDisplayOrder(target); + const anchorOrder = dropAsFirstChild ? Math.min(...siblings.map((s) => getDisplayOrder(s))) : getDisplayOrder(target); const anchorEdge = dropAsFirstChild ? 'top' : edge; const newOrder = getRelativeOrder(siblingItems, anchorOrder, getId(dragged), anchorEdge); diff --git a/frontend/src/modules/common/data-table/types.ts b/frontend/src/modules/common/data-table/types.ts index 9266320b9..126aab039 100644 --- a/frontend/src/modules/common/data-table/types.ts +++ b/frontend/src/modules/common/data-table/types.ts @@ -3,13 +3,13 @@ import type { Dispatch, SetStateAction } from 'react'; import type { ApiError } from 'sdk'; import type { ColumnOrColumnGroup as GridColumnOrColumnGroup } from '~/modules/common/data-grid'; -export type BaseTableSearchVariables<T> = T & { - limit: number; -}; +export type BaseTableSearchVariables<T> = T & { limit: number }; /** Grid columns narrowed to keyed entries for table chrome; the grid filters hidden entries. */ export type ColumnOrColumnGroup<TData> = GridColumnOrColumnGroup<TData> & { key: string; + /** The cell a CSV or PDF export writes; without it the export writes the row field named by the key. */ + exportValue?: (row: TData) => string | number | null | undefined; }; export type BaseTableBarProps<T, K> = { diff --git a/frontend/src/modules/common/data-table/use-fetch-more-on-demand.ts b/frontend/src/modules/common/data-table/use-fetch-more-on-demand.ts index d2dcf263e..cbb1284ce 100644 --- a/frontend/src/modules/common/data-table/use-fetch-more-on-demand.ts +++ b/frontend/src/modules/common/data-table/use-fetch-more-on-demand.ts @@ -11,13 +11,7 @@ interface UseFetchMoreOnDemandOptions { } /** Fulfills level-triggered load demand whenever the query can accept it, re-evaluating after background fetches. */ -export function useFetchMoreOnDemand({ - demand, - hasNextPage, - isFetching, - error, - fetchMore, -}: UseFetchMoreOnDemandOptions): void { +export function useFetchMoreOnDemand({ demand, hasNextPage, isFetching, error, fetchMore }: UseFetchMoreOnDemandOptions): void { useEffect(() => { if (!demand || !fetchMore || isFetching || !hasNextPage || error) return; fetchMore(); diff --git a/frontend/src/modules/common/data-table/use-infinite-rows.ts b/frontend/src/modules/common/data-table/use-infinite-rows.ts new file mode 100644 index 000000000..3d0345e2d --- /dev/null +++ b/frontend/src/modules/common/data-table/use-infinite-rows.ts @@ -0,0 +1,24 @@ +import { type InfiniteData, type QueryKey, type UseInfiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query'; +import type { QueryData } from '~/query/types'; + +/** Flattens a paged list query into table rows; fetchMore does nothing while a page loads or when none is left. */ +export function useInfiniteRows<TRow, TError, TQueryKey extends QueryKey, TPageParam>( + options: UseInfiniteQueryOptions<QueryData<TRow>, TError, InfiniteData<QueryData<TRow>>, TQueryKey, TPageParam>, +) { + const { + data: rows, + isLoading, + isFetching, + error, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery({ ...options, select: ({ pages }) => pages.flatMap(({ items }) => items) }); + + // A new function on every render: useFetchMoreOnDemand re-runs its effect when it changes. + const fetchMore = async () => { + if (!hasNextPage || isLoading || isFetching) return; + await fetchNextPage(); + }; + + return { rows, isLoading, isFetching, error, hasNextPage, fetchMore }; +} diff --git a/frontend/src/modules/common/data-table/use-row-selection.ts b/frontend/src/modules/common/data-table/use-row-selection.ts new file mode 100644 index 000000000..6bf37949c --- /dev/null +++ b/frontend/src/modules/common/data-table/use-row-selection.ts @@ -0,0 +1,14 @@ +import { useMemo, useState } from 'react'; + +/** Selected rows as the objects they were picked from, so bars read their fields even after the list refetches. */ +export function useRowSelection<TRow extends { id: string }>(rows: TRow[] | undefined) { + const [selected, setSelected] = useState<TRow[]>([]); + + const onSelectedRowsChange = (value: Set<string>) => { + if (rows) setSelected(rows.filter((row) => value.has(row.id))); + }; + + const selectedRowIds = useMemo(() => new Set(selected.map((s) => s.id)), [selected]); + + return { selected, selectedRowIds, onSelectedRowsChange, clearSelection: () => setSelected([]) }; +} diff --git a/frontend/src/modules/common/data-table/use-table-tooltip.ts b/frontend/src/modules/common/data-table/use-table-tooltip.ts index 5716aaf9e..59e828cb0 100644 --- a/frontend/src/modules/common/data-table/use-table-tooltip.ts +++ b/frontend/src/modules/common/data-table/use-table-tooltip.ts @@ -8,10 +8,7 @@ const skipDelayWindow = 500; const positionTooltip = (reference: HTMLElement, tooltip: HTMLElement, gap = 4) => { const rect = reference.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); - Object.assign(tooltip.style, { - left: `${rect.right + gap}px`, - top: `${rect.top + (rect.height - tooltipRect.height) / 2}px`, - }); + Object.assign(tooltip.style, { left: `${rect.right + gap}px`, top: `${rect.top + (rect.height - tooltipRect.height) / 2}px` }); }; /** Data grid tooltip driven by DOM listeners outside React, so hovering never re-renders the grid. */ @@ -21,12 +18,13 @@ export function useTableTooltip(gridRef: React.RefObject<HTMLDivElement | null>, const lastShownCellRef = useRef<HTMLElement | null>(null); const lastHiddenAtRef = useRef<number>(0); const observerRef = useRef<MutationObserver | null>(null); - const rafRef = useRef<number | null>(null); useEffect(() => { if (!gridRef?.current) return; const gridEl = gridRef.current; const tooltip = document.createElement('div'); + // A tap fires compatibility mousemoves, so hover only counts for a pointer that can hover (jsdom has no matchMedia). + const canHover = window.matchMedia?.('(hover: hover)'); tooltip.className = 'max-md:invisible bg-muted-foreground text-primary-foreground fixed pointer-events-none hidden rounded-md text-xs px-3 py-1.5 z-200'; @@ -40,26 +38,17 @@ export function useTableTooltip(gridRef: React.RefObject<HTMLDivElement | null>, tooltip.textContent = tooltipContent; tooltip.style.display = 'block'; lastShownCellRef.current = cell; - - if (rafRef.current) cancelAnimationFrame(rafRef.current); - - // Reposition on every frame, since virtualization recycles the cell element. positionTooltip(cell, tooltip); - const track = () => { - if (!cell.isConnected) return clearTooltip(); - positionTooltip(cell, tooltip); - rafRef.current = requestAnimationFrame(track); - }; - rafRef.current = requestAnimationFrame(track); + // Grid renders can recycle the cell (new content) or remove it (virtualization, row updates): follow it or clear. observerRef.current?.disconnect(); - observerRef.current = new MutationObserver(() => updateTooltipContent(cell)); + observerRef.current = new MutationObserver(() => { + if (!cell.isConnected) return clearTooltip(); + tooltip.textContent = cell.getAttribute('data-tooltip-content') || ''; + positionTooltip(cell, tooltip); + }); observerRef.current.observe(cell, { attributes: true, attributeFilter: ['data-tooltip-content'] }); - }; - - const updateTooltipContent = (cell: HTMLElement) => { - const tooltipContent = cell.getAttribute('data-tooltip-content') || ''; - tooltip.textContent = tooltipContent; + observerRef.current.observe(gridEl, { childList: true, subtree: true }); }; // `data-tooltip="true"` always qualifies; `data-tooltip="compact"` only inside a compacted grid. @@ -71,6 +60,7 @@ export function useTableTooltip(gridRef: React.RefObject<HTMLDivElement | null>, }; const handleMouseMove = (e: MouseEvent) => { + if (canHover && !canHover.matches) return; const cell = resolveTooltipCell(e.target as HTMLElement); if (!cell) return clearTooltip(); @@ -103,26 +93,29 @@ export function useTableTooltip(gridRef: React.RefObject<HTMLDivElement | null>, if (lastShownCellRef.current) lastHiddenAtRef.current = Date.now(); tooltip.style.display = 'none'; lastShownCellRef.current = null; - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } observerRef.current?.disconnect(); }; + // Window capture sees every scroll: the grid's own (or a nested one) clears, an ancestor's moves the tooltip with its cell. + const handleScroll = (e: Event) => { + if (e.target instanceof Node && gridEl.contains(e.target)) return clearTooltip(); + const cell = lastShownCellRef.current; + if (cell) positionTooltip(cell, tooltip); + }; + gridEl.addEventListener('mousemove', handleMouseMove); gridEl.addEventListener('mouseleave', handleMouseLeave); gridEl.addEventListener('focusin', handleFocus); gridEl.addEventListener('focusout', clearTooltip); - gridEl.addEventListener('scroll', clearTooltip, { capture: true, passive: true }); + window.addEventListener('scroll', handleScroll, { capture: true, passive: true }); return () => { gridEl.removeEventListener('mousemove', handleMouseMove); gridEl.removeEventListener('mouseleave', handleMouseLeave); gridEl.removeEventListener('focusin', handleFocus); gridEl.removeEventListener('focusout', clearTooltip); - gridEl.removeEventListener('scroll', clearTooltip, { capture: true }); - if (rafRef.current) cancelAnimationFrame(rafRef.current); + window.removeEventListener('scroll', handleScroll, { capture: true }); + if (timeoutRef.current) clearTimeout(timeoutRef.current); observerRef.current?.disconnect(); tooltip.remove(); }; diff --git a/frontend/src/modules/common/debug-dropdown.tsx b/frontend/src/modules/common/debug-dropdown.tsx index 23a0fb14e..222d7ac66 100644 --- a/frontend/src/modules/common/debug-dropdown.tsx +++ b/frontend/src/modules/common/debug-dropdown.tsx @@ -1,7 +1,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { appConfig } from 'shared'; +import { create } from 'zustand'; import { SyncDevtools } from '~/modules/common/devtools'; import { Button } from '~/modules/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '~/modules/ui/dropdown-menu'; @@ -9,14 +10,6 @@ import { queryClient } from '~/query/query-client'; import { router } from '~/routes/router'; import { cn } from '~/utils/cn'; -interface DebugItem { - url?: string; - id: string; - icon: string; - parent?: string; - element?: string; -} - interface DebugDropdownProps { className?: string; } @@ -25,14 +18,9 @@ interface DebugDropdownProps { const drizzleStudioPort = Number(new URL(appConfig.backendUrl).port) + 983; const drizzleStudioUrl = `https://local.drizzle.studio?port=${drizzleStudioPort}`; -const debugOptions: DebugItem[] = [ - { id: 'drizzle-studio', icon: '💦', url: drizzleStudioUrl }, - { id: 'storybook', icon: '📖', url: 'http://localhost:6006/' }, - { id: 'tanstack-router', icon: '🌴', parent: '.TanStackRouterDevtools', element: ':scope > button' }, - { id: 'react-query', icon: '📡', parent: '.tsqd-parent-container', element: '.tsqd-open-btn' }, - { id: 'react-scan', icon: '⏱️' }, - { id: 'sync-devtools', icon: '⚡' }, -]; +const reactScanKey = 'react-scan-enabled'; + +const useSyncDevtoolsStore = create<{ open: boolean }>(() => ({ open: false })); /** Imported on demand so react-scan and its bundled Preact stay out of the eager chunk graph. */ const runScan = async (enabled: boolean) => { @@ -40,66 +28,63 @@ const runScan = async (enabled: boolean) => { scan({ showToolbar: enabled, enabled }); }; -function DebugDropdown({ className }: DebugDropdownProps) { - const [syncDevtoolsOpen, setSyncDevtoolsOpen] = useState(false); - - const debugToggle = (item: DebugItem) => { - if (item.id === 'sync-devtools') { - setSyncDevtoolsOpen((prev) => !prev); - return; - } - - if (item.url) return window.open(item.url, '_self'); - - if (item.id === 'react-scan') { - const prev = localStorage.getItem('react-scan-enabled') === 'true'; - const enable = !prev; - localStorage.setItem('react-scan-enabled', JSON.stringify(enable)); +// The library panels open through their own toggle buttons, which tailwind.css hides +const clickHidden = (selector: string) => document.querySelector<HTMLElement>(selector)?.click(); + +const debugOptions = [ + { id: 'drizzle-studio', icon: '💦', onSelect: () => window.open(drizzleStudioUrl, '_self') }, + { id: 'storybook', icon: '📖', onSelect: () => window.open('http://localhost:6006/', '_self') }, + { id: 'tanstack-router', icon: '🌴', onSelect: () => clickHidden('.TanStackRouterDevtools > button') }, + { id: 'react-query', icon: '📡', onSelect: () => clickHidden('.tsqd-parent-container .tsqd-open-btn') }, + { + id: 'react-scan', + icon: '⏱️', + onSelect: () => { + const enable = localStorage.getItem(reactScanKey) !== 'true'; + localStorage.setItem(reactScanKey, String(enable)); void runScan(enable); - return; - } - - if (!item.parent || !item.element) return; - - const parent = document.querySelector<HTMLElement>(item.parent); - if (!parent) return; - - const htmlElement = parent.querySelector<HTMLButtonElement>(item.element); - if (!htmlElement) return; + }, + }, + { id: 'sync-devtools', icon: '⚡', onSelect: () => useSyncDevtoolsStore.setState((state) => ({ open: !state.open })) }, +]; - htmlElement.click(); - }; +/** + * Devtools panels, mounted once at the root. Inside a sheet, the drawer's transform would contain their + * fixed-position panels and add blank scroll to it. + */ +function Devtools() { + const syncDevtoolsOpen = useSyncDevtoolsStore((state) => state.open); useEffect(() => { - const enabled = localStorage.getItem('react-scan-enabled') === 'true'; - if (enabled) { - void runScan(true); - } + if (localStorage.getItem(reactScanKey) === 'true') void runScan(true); }, []); return ( <> <TanStackRouterDevtools router={router} /> <ReactQueryDevtools client={queryClient} /> - <SyncDevtools isOpen={syncDevtoolsOpen} onClose={() => setSyncDevtoolsOpen(false)} /> - - <DropdownMenu> - <DropdownMenuTrigger - render={<Button variant="ghost" className={cn('h-12 w-12', className)} aria-label="toggle debug toolbar" />} - > - 🐞 - </DropdownMenuTrigger> - <DropdownMenuContent side="right" align="end" sideOffset={24} className="z-300 w-48 p-1"> - {debugOptions.map((item) => ( - <DropdownMenuItem key={item.id} onClick={() => debugToggle(item)}> - <span className="mr-2">{item.icon}</span> - <span>{item.id}</span> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> + {syncDevtoolsOpen && <SyncDevtools onClose={() => useSyncDevtoolsStore.setState({ open: false })} />} </> ); } -export { DebugDropdown }; +/** 🐞 menu that toggles the panels mounted by `Devtools` and links to local dev tools. */ +function DebugDropdown({ className }: DebugDropdownProps) { + return ( + <DropdownMenu> + <DropdownMenuTrigger render={<Button variant="ghost" className={cn('size-12', className)} aria-label="toggle debug toolbar" />}> + 🐞 + </DropdownMenuTrigger> + <DropdownMenuContent side="right" align="end" sideOffset={24} positionerClassName="z-300" className="w-48 p-1"> + {debugOptions.map(({ id, icon, onSelect }) => ( + <DropdownMenuItem key={id} onClick={onSelect}> + <span className="mr-2">{icon}</span> + <span>{id}</span> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ); +} + +export { DebugDropdown, Devtools }; diff --git a/frontend/src/modules/common/delete-form.tsx b/frontend/src/modules/common/delete-form.tsx index aada9e209..21eaed7ce 100644 --- a/frontend/src/modules/common/delete-form.tsx +++ b/frontend/src/modules/common/delete-form.tsx @@ -1,6 +1,7 @@ import { TrashIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Button, SubmitButton } from '~/modules/ui/button'; +import { SubmitButton } from '~/modules/common/form-fields/submit-button'; +import { Button } from '~/modules/ui/button'; interface DeleteFormProps { onDelete: () => void; @@ -14,17 +15,10 @@ export function DeleteForm({ onDelete, onCancel, pending, allowOfflineDelete = f return ( <div className="flex flex-col gap-2 sm:flex-row"> - <SubmitButton - variant="destructive" - icon={<TrashIcon />} - allowOfflineDelete={allowOfflineDelete} - onClick={onDelete} - aria-label="Delete" - loading={pending} - > + <SubmitButton variant="destructive" icon={<TrashIcon />} allowOfflineDelete={allowOfflineDelete} onClick={onDelete} loading={pending}> {t('c:delete')} </SubmitButton> - <Button type="reset" variant="secondary" aria-label="Cancel" onClick={onCancel}> + <Button type="reset" variant="secondary" data-autofocus onClick={onCancel}> {t('c:cancel')} </Button> </div> diff --git a/frontend/src/modules/common/delete-items.tsx b/frontend/src/modules/common/delete-items.tsx new file mode 100644 index 000000000..da6a9ec76 --- /dev/null +++ b/frontend/src/modules/common/delete-items.tsx @@ -0,0 +1,42 @@ +import { onlineManager } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import type { CallbackArgs } from '~/modules/common/data-table/types'; +import { DeleteForm } from '~/modules/common/delete-form'; +import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; +import { toaster } from '~/modules/common/toaster/toaster'; + +export interface DeleteItemsProps<TItem, TVariables> { + items: TItem[]; + /** The delete mutation, created by the caller inside the dropdowner or dialog content that renders this form. */ + mutation: { mutate: (variables: TVariables, options: { onSuccess: () => void }) => void; isPending: boolean }; + toVariables: (items: TItem[]) => TVariables; + dialog?: boolean; + callback?: (args: CallbackArgs<TItem[]>) => void; + /** Warn and send nothing while offline. */ + onlineOnly?: boolean; + /** Report to `callback` before closing the dialog; by default the dialog closes first. */ + callbackFirst?: boolean; + /** Replaces the default cancel, which settles `callback` and closes a dialog. */ + onCancel?: () => void; +} + +/** Delete confirmation for a list of items: on success it closes a dialog and reports the items to `callback`. */ +export function DeleteItems<TItem, TVariables>(props: DeleteItemsProps<TItem, TVariables>) { + const { items, callback, callbackFirst, mutation } = props; + const { t } = useTranslation(); + const removeDialog = useDialoger((state) => state.remove); + + const finish = (args: CallbackArgs<TItem[]>) => { + if (callbackFirst) callback?.(args); + if (props.dialog) removeDialog(); + if (!callbackFirst) callback?.(args); + }; + + const onDelete = () => { + if (props.onlineOnly && !onlineManager.isOnline()) return toaster.warning(t('c:action.offline.text')); + mutation.mutate(props.toVariables(items), { onSuccess: () => finish({ data: items, status: 'success' }) }); + }; + + const onCancel = props.onCancel ?? (() => finish({ status: 'settle' })); + return <DeleteForm onDelete={onDelete} onCancel={onCancel} pending={mutation.isPending} />; +} diff --git a/frontend/src/modules/common/devtools/sync-devtools.tsx b/frontend/src/modules/common/devtools/sync-devtools.tsx index 17cf68f03..ea3dd72ce 100644 --- a/frontend/src/modules/common/devtools/sync-devtools.tsx +++ b/frontend/src/modules/common/devtools/sync-devtools.tsx @@ -5,10 +5,9 @@ import { useEffect, useState } from 'react'; import { isDebugMode } from '~/env'; -import { clearSpans, getSpanStats, type SpanData, subscribeToSpans } from '~/lib/tracing'; +import { clearSpans, getSpanStats, getSpans, type SpanData, subscribeToSpans } from '~/lib/tracing'; interface SyncDevtoolsState { - isOpen: boolean; activeTab: 'spans' | 'stats' | 'timeline'; filter: string; } @@ -89,15 +88,8 @@ const styles = { alignItems: 'center', justifyContent: 'space-between', }, - title: { - color: '#f1f5f9', - fontWeight: 600, - fontSize: '13px', - }, - tabs: { - display: 'flex', - gap: '4px', - }, + title: { color: '#f1f5f9', fontWeight: 600, fontSize: '13px' }, + tabs: { display: 'flex', gap: '4px' }, tab: { padding: '4px 8px', background: 'transparent', @@ -107,15 +99,8 @@ const styles = { borderRadius: '4px', fontSize: '11px', }, - tabActive: { - background: '#334155', - color: '#f1f5f9', - }, - content: { - flex: 1, - overflow: 'auto', - padding: '8px', - }, + tabActive: { background: '#334155', color: '#f1f5f9' }, + content: { flex: 1, overflow: 'auto', padding: '8px' }, spanRow: { display: 'flex', alignItems: 'center', @@ -125,60 +110,16 @@ const styles = { marginBottom: '4px', background: '#1e293b', }, - spanDot: { - width: '8px', - height: '8px', - borderRadius: '50%', - }, - spanName: { - flex: 1, - color: '#e2e8f0', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' as const, - }, - spanDuration: { - color: '#94a3b8', - fontSize: '11px', - }, - spanTime: { - color: '#64748b', - fontSize: '10px', - }, - statCard: { - background: '#1e293b', - borderRadius: '6px', - padding: '12px', - marginBottom: '8px', - }, - statLabel: { - color: '#94a3b8', - fontSize: '10px', - textTransform: 'uppercase' as const, - letterSpacing: '0.5px', - }, - statValue: { - color: '#f1f5f9', - fontSize: '20px', - fontWeight: 600, - marginTop: '4px', - }, - statGrid: { - display: 'grid', - gridTemplateColumns: 'repeat(2, 1fr)', - gap: '8px', - }, - empty: { - color: '#64748b', - textAlign: 'center' as const, - padding: '24px', - }, - actions: { - display: 'flex', - gap: '8px', - padding: '8px', - borderTop: '1px solid #334155', - }, + spanDot: { width: '8px', height: '8px', borderRadius: '50%' }, + spanName: { flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }, + spanDuration: { color: '#94a3b8', fontSize: '11px' }, + spanTime: { color: '#64748b', fontSize: '10px' }, + statCard: { background: '#1e293b', borderRadius: '6px', padding: '12px', marginBottom: '8px' }, + statLabel: { color: '#94a3b8', fontSize: '10px', textTransform: 'uppercase' as const, letterSpacing: '0.5px' }, + statValue: { color: '#f1f5f9', fontSize: '20px', fontWeight: 600, marginTop: '4px' }, + statGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }, + empty: { color: '#64748b', textAlign: 'center' as const, padding: '24px' }, + actions: { display: 'flex', gap: '8px', padding: '8px', borderTop: '1px solid #334155' }, button: { padding: '6px 12px', background: '#334155', @@ -213,19 +154,8 @@ function SpanList({ spans, filter }: { spans: SpanData[]; filter: string }) { <div> {sorted.map((span) => ( <div key={span.spanId} style={styles.spanRow}> - <div - style={{ - ...styles.spanDot, - background: getStatusColor(span.status), - }} - /> - <div - style={{ - ...styles.spanName, - color: getCategoryColor(span.name), - }} - title={span.name} - > + <div style={{ ...styles.spanDot, background: getStatusColor(span.status) }} /> + <div style={{ ...styles.spanName, color: getCategoryColor(span.name) }} title={span.name}> {span.name.replace('sync.', '')} </div> <div style={styles.spanDuration}>{formatDuration(span.duration)}</div> @@ -248,9 +178,7 @@ function StatsView({ spans: _spans }: { spans: SpanData[] }) { </div> <div style={styles.statCard}> <div style={styles.statLabel}>Errors</div> - <div style={{ ...styles.statValue, color: stats.errorCount > 0 ? '#ef4444' : '#22c55e' }}> - {stats.errorCount} - </div> + <div style={{ ...styles.statValue, color: stats.errorCount > 0 ? '#ef4444' : '#22c55e' }}>{stats.errorCount}</div> </div> </div> @@ -258,15 +186,7 @@ function StatsView({ spans: _spans }: { spans: SpanData[] }) { <div style={styles.statLabel}>Spans by Type</div> <div style={{ marginTop: '8px' }}> {Object.entries(stats.byPrefix).map(([prefix, count]) => ( - <div - key={prefix} - style={{ - display: 'flex', - justifyContent: 'space-between', - padding: '4px 0', - color: '#e2e8f0', - }} - > + <div key={prefix} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', color: '#e2e8f0' }}> <span style={{ color: getCategoryColor(prefix) }}>{prefix}</span> <span>{count}</span> </div> @@ -278,15 +198,7 @@ function StatsView({ spans: _spans }: { spans: SpanData[] }) { <div style={styles.statLabel}>Avg Duration by Type</div> <div style={{ marginTop: '8px' }}> {Object.entries(stats.avgDurationMs).map(([prefix, avgMs]) => ( - <div - key={prefix} - style={{ - display: 'flex', - justifyContent: 'space-between', - padding: '4px 0', - color: '#e2e8f0', - }} - > + <div key={prefix} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', color: '#e2e8f0' }}> <span style={{ color: getCategoryColor(prefix) }}>{prefix}</span> <span>{formatDuration(avgMs)}</span> </div> @@ -305,9 +217,7 @@ function TimelineView({ spans }: { spans: SpanData[] }) { <div style={styles.empty}> No end-to-end latency data yet. <br /> - <span style={{ fontSize: '10px', color: '#64748b' }}> - Latency is calculated from CDC timestamp to frontend processing. - </span> + <span style={{ fontSize: '10px', color: '#64748b' }}>Latency is calculated from CDC timestamp to frontend processing.</span> </div> ); } @@ -334,26 +244,11 @@ function TimelineView({ spans }: { spans: SpanData[] }) { return ( <div key={span.spanId} style={{ marginBottom: '8px' }}> - <div - style={{ - display: 'flex', - justifyContent: 'space-between', - fontSize: '10px', - color: '#94a3b8', - marginBottom: '2px', - }} - > + <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: '#94a3b8', marginBottom: '2px' }}> <span>{String(span.attributes['sync.entityType'] || 'unknown')}</span> <span>{formatDuration(latency)}</span> </div> - <div - style={{ - height: '4px', - background: '#334155', - borderRadius: '2px', - overflow: 'hidden', - }} - > + <div style={{ height: '4px', background: '#334155', borderRadius: '2px', overflow: 'hidden' }}> <div style={{ width: `${width}%`, @@ -373,25 +268,21 @@ function TimelineView({ spans }: { spans: SpanData[] }) { } interface SyncDevtoolsProps { - isOpen: boolean; onClose: () => void; } -export function SyncDevtools({ isOpen, onClose }: SyncDevtoolsProps) { - const [state, setState] = useState<SyncDevtoolsState>({ - isOpen: true, - activeTab: 'spans', - filter: '', - }); +export function SyncDevtools({ onClose }: SyncDevtoolsProps) { + const [state, setState] = useState<SyncDevtoolsState>({ activeTab: 'spans', filter: '' }); - const [spans, setSpans] = useState<SpanData[]>([]); + // Mounted only while open, so seed from the buffer: subscribers get no replay + const [spans, setSpans] = useState<SpanData[]>(getSpans); useEffect(() => { const unsubscribe = subscribeToSpans(setSpans); return unsubscribe; }, []); - if (!isDebugMode || !isOpen) return null; + if (!isDebugMode) return null; return ( <div style={styles.container}> @@ -403,10 +294,7 @@ export function SyncDevtools({ isOpen, onClose }: SyncDevtoolsProps) { <button key={tab} type="button" - style={{ - ...styles.tab, - ...(state.activeTab === tab ? styles.tabActive : {}), - }} + style={{ ...styles.tab, ...(state.activeTab === tab ? styles.tabActive : {}) }} onClick={() => setState((s) => ({ ...s, activeTab: tab }))} > {tab} diff --git a/frontend/src/modules/common/dialoger/dialog.tsx b/frontend/src/modules/common/dialoger/dialog.tsx index ee9ddc371..9b6c27a80 100644 --- a/frontend/src/modules/common/dialoger/dialog.tsx +++ b/frontend/src/modules/common/dialoger/dialog.tsx @@ -3,6 +3,7 @@ import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { useLatestRef } from '~/hooks/use-latest-ref'; import { type InternalDialog, useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; +import { useRemoveAfterExit } from '~/modules/common/overlay-store-helpers'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '~/modules/ui/dialog'; import { cn } from '~/utils/cn'; @@ -27,7 +28,13 @@ export function DialogerDialog({ dialog }: { dialog: InternalDialog }) { const modal = !container; const containerElement = container?.ref?.current ?? undefined; - const closeDialog = () => useDialoger.getState().remove(dialog.id); + const removeDialog = () => useDialoger.getState().remove(dialog.id); + + // The dialog animates out before its entry (and onClose) is removed. + const { close: closeDialog, onOpenChangeComplete } = useRemoveAfterExit( + () => useDialoger.getState().update(dialog.id, { open: false }), + removeDialog, + ); const onOpenChange = (nextOpen: boolean, eventDetails: { reason: string }) => { // An outside press landing on a dropdown must not close the dialog @@ -36,40 +43,28 @@ export function DialogerDialog({ dialog }: { dialog: InternalDialog }) { if (dropdown || !modal) return; } - // URL-driven dialogs remove in the same tick, so the 200ms exit gap cannot reopen them + // URL-driven dialogs remove in the same tick, so the exit animation cannot reopen them if (!nextOpen && dialog.instantClose) { - closeDialog(); + removeDialog(); return; } - useDialoger.getState().update(dialog.id, { open: nextOpen }); - if (!nextOpen) { - setTimeout(closeDialog, 200); - } + if (nextOpen) useDialoger.getState().update(dialog.id, { open: true }); + else closeDialog(); }; const finalFocusRef = useLatestRef(triggerRef?.current ?? null); return ( - <Dialog key={id} open={open} onOpenChange={onOpenChange} modal={modal}> + <Dialog key={id} open={open} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete} modal={modal}> {container?.overlay && (container.overlayRef?.current ? ( createPortal( - <div - className={cn( - 'absolute inset-0 z-30 bg-background/75 duration-200', - open ? 'fade-in-0 animate-in' : 'fade-out-0 animate-out', - )} - />, + <div className={cn('absolute inset-0 z-30 bg-background/75 duration-200', open ? 'fade-in-0 animate-in' : 'fade-out-0 animate-out')} />, container.overlayRef.current, ) ) : ( - <div - className={cn( - 'fixed inset-0 z-30 bg-background/75 duration-200', - open ? 'fade-in-0 animate-in' : 'fade-out-0 animate-out', - )} - /> + <div className={cn('fixed inset-0 z-30 bg-background/75 duration-200', open ? 'fade-in-0 animate-in' : 'fade-out-0 animate-out')} /> ))} <DialogContent id={String(id)} @@ -81,23 +76,11 @@ export function DialogerDialog({ dialog }: { dialog: InternalDialog }) { > {/* An empty header would overlap the content, e.g. in the fullscreen attachment dialog */} {(title || description) && ( - <DialogHeader - sticky - className={cn( - isMobile && drawerOnMobile ? headerClassName?.replace('with-close-btn', '') : headerClassName, - )} - > - {title ? ( - <DialogTitle className="h-6 leading-6">{titleContent}</DialogTitle> - ) : ( - <DialogTitle className="hidden" /> - )} + <DialogHeader sticky className={cn(isMobile && drawerOnMobile ? headerClassName?.replace('with-close-btn', '') : headerClassName)}> + {title && <DialogTitle className="h-6 leading-6">{titleContent}</DialogTitle>} {description && <DialogDescription>{description}</DialogDescription>} </DialogHeader> )} - - {/* Guarantee an accessible name without a visible header */} - {!title && !description && <DialogTitle className="hidden" />} {content} </DialogContent> </Dialog> diff --git a/frontend/src/modules/common/dialoger/drawer.tsx b/frontend/src/modules/common/dialoger/drawer.tsx index a47dc9aab..628d8b1fd 100644 --- a/frontend/src/modules/common/dialoger/drawer.tsx +++ b/frontend/src/modules/common/dialoger/drawer.tsx @@ -1,5 +1,6 @@ import { type InternalDialog, useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; +import { useRemoveAfterExit } from '~/modules/common/overlay-store-helpers'; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; export function DialogerDrawer({ dialog }: { dialog: InternalDialog }) { @@ -8,21 +9,28 @@ export function DialogerDrawer({ dialog }: { dialog: InternalDialog }) { const updateDialog = useDialoger((state) => state.update); // An open dropdown makes the drawer non-dismissible - const isDropdownOpen = useDropdowner((state) => state.dropdown); + const isDropdownOpen = useDropdowner((state) => !!state.dropdown); - const closeDialog = () => useDialoger.getState().remove(dialog.id); + // The drawer slides out before its entry is removed; onClose still runs as the close starts. + const { close: closeDialog, onOpenChangeComplete } = useRemoveAfterExit( + () => { + updateDialog(dialog.id, { open: false, onClose: undefined }); + dialog.onClose?.(); + }, + () => useDialoger.getState().remove(dialog.id), + ); const onOpenChange = (open: boolean) => { - updateDialog(dialog.id, { open }); - if (!open) closeDialog(); + if (open) updateDialog(dialog.id, { open }); + else closeDialog(); }; return ( - <Drawer key={id} open={open} disablePointerDismissal={!!isDropdownOpen} onOpenChange={onOpenChange}> + <Drawer key={id} open={open} disablePointerDismissal={isDropdownOpen} onOpenChange={onOpenChange} onOpenChangeComplete={onOpenChangeComplete}> <DrawerContent id={String(id)} className={className}> <DrawerHeader data-overlay="dialog" className={title || description ? headerClassName : 'hidden'}> - <DrawerTitle className={`${title ? '' : 'hidden'}`}>{titleContent}</DrawerTitle> - <DrawerDescription className={`${description ? '' : 'hidden'}`}>{description}</DrawerDescription> + {title && <DrawerTitle>{titleContent}</DrawerTitle>} + {description && <DrawerDescription>{description}</DrawerDescription>} </DrawerHeader> <div className="px-3 pb-3">{content}</div> </DrawerContent> diff --git a/frontend/src/modules/common/dialoger/provider.tsx b/frontend/src/modules/common/dialoger/provider.tsx index e1c7e4a38..299f2e7ed 100644 --- a/frontend/src/modules/common/dialoger/provider.tsx +++ b/frontend/src/modules/common/dialoger/provider.tsx @@ -1,10 +1,9 @@ import { useEffect } from 'react'; -import { useBodyClass } from '~/hooks/use-body-class'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { DialogerDialog } from '~/modules/common/dialoger/dialog'; import { DialogerDrawer } from '~/modules/common/dialoger/drawer'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; -import { useUIStore } from '~/modules/ui/ui-store'; +import { useOverlayLock } from '~/modules/common/overlay-store-helpers'; import { getRouter } from '~/routes/-router-instance'; /** @@ -13,21 +12,12 @@ import { getRouter } from '~/routes/-router-instance'; export function Dialoger() { const isMobile = useBreakpointBelow('sm'); const dialogs = useDialoger((state) => state.dialogs); - const lockUI = useUIStore((state) => state.lockUI); - const unlockUI = useUIStore((state) => state.unlockUI); - useBodyClass({ 'dialoger-open': dialogs.length > 0 }); - - useEffect(() => { - if (dialogs.length > 0) { - lockUI('dialoger'); - return () => unlockUI('dialoger'); - } - }, [dialogs.length > 0]); + useOverlayLock('dialoger', dialogs.length > 0); useEffect(() => { return getRouter().subscribe('onBeforeLoad', ({ pathChanged }) => { - if (pathChanged) useDialoger.getState().remove(); + if (pathChanged) useDialoger.getState().remove(undefined, { isCleanup: true }); }); }, []); diff --git a/frontend/src/modules/common/dialoger/use-dialoger.test.ts b/frontend/src/modules/common/dialoger/use-dialoger.test.ts new file mode 100644 index 000000000..cfed8e625 --- /dev/null +++ b/frontend/src/modules/common/dialoger/use-dialoger.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { createRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fallbackContentRef } from '~/utils/fallback-content-ref'; +import { type DialogData, useDialoger } from './use-dialoger'; + +const dialog = (id: number | string, data: Partial<DialogData> = {}): DialogData => ({ id, triggerRef: createRef(), ...data }); + +const openIds = () => useDialoger.getState().dialogs.map((d) => d.id); + +describe('dialoger store', () => { + beforeEach(() => { + useDialoger.setState({ dialogs: [] }); + fallbackContentRef.current = null; + }); + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('opens a dialog with its defaults and returns its id', () => { + const id = useDialoger.getState().create('content', dialog('a', { title: 'A' })); + + expect(id).toBe('a'); + expect(useDialoger.getState().get('a')).toMatchObject({ + id: 'a', + title: 'A', + content: 'content', + open: true, + drawerOnMobile: true, + headerClassName: 'with-close-btn', + }); + }); + + it('lets data override the defaults', () => { + useDialoger.getState().create(null, dialog('a', { drawerOnMobile: false, headerClassName: 'custom' })); + + expect(useDialoger.getState().get('a')).toMatchObject({ drawerOnMobile: false, headerClassName: 'custom' }); + }); + + it('keeps the defaults for options passed as undefined', () => { + useDialoger.getState().create(null, dialog('a', { drawerOnMobile: undefined, headerClassName: undefined })); + + expect(useDialoger.getState().get('a')).toMatchObject({ drawerOnMobile: true, headerClassName: 'with-close-btn' }); + }); + + it('replaces a dialog opened with the same id and moves it last', () => { + useDialoger.getState().create('first', dialog('a')); + useDialoger.getState().create(null, dialog(2)); + useDialoger.getState().create('second', dialog('a')); + + expect(openIds()).toEqual([2, 'a']); + expect(useDialoger.getState().get('a')?.content).toBe('second'); + }); + + it('merges updates into one dialog', () => { + useDialoger.getState().create(null, dialog('a', { title: 'A' })); + useDialoger.getState().create(null, dialog('b', { title: 'B' })); + + useDialoger.getState().update('a', { open: false, title: 'A2' }); + + expect(useDialoger.getState().get('a')).toMatchObject({ open: false, title: 'A2' }); + expect(useDialoger.getState().get('b')).toMatchObject({ open: true, title: 'B' }); + }); + + it('removes the dialog from the store before onClose runs', () => { + let idsDuringClose: (string | number)[] | null = null; + useDialoger.getState().create(null, dialog('a', { onClose: () => (idsDuringClose = openIds()) })); + useDialoger.getState().create(null, dialog('b')); + + useDialoger.getState().remove('a'); + + expect(idsDuringClose).toEqual(['b']); + expect(openIds()).toEqual(['b']); + }); + + it('passes isCleanup to onClose', () => { + const onClose = vi.fn(); + useDialoger.getState().create(null, dialog('a', { onClose })); + useDialoger.getState().create(null, dialog('b', { onClose })); + + useDialoger.getState().remove('a', { isCleanup: true }); + useDialoger.getState().remove('b'); + + expect(onClose.mock.calls).toEqual([[true], [undefined]]); + }); + + it('keeps a dialog that onClose opens', () => { + useDialoger.getState().create(null, dialog('a', { onClose: () => useDialoger.getState().create(null, dialog('b')) })); + + useDialoger.getState().remove('a'); + + expect(openIds()).toEqual(['b']); + }); + + it('removes every dialog without an id and calls each onClose in order', () => { + const closed: string[] = []; + useDialoger.getState().create(null, dialog('a', { onClose: () => closed.push('a') })); + useDialoger.getState().create(null, dialog('b', { onClose: () => closed.push('b') })); + + useDialoger.getState().remove(); + + expect(openIds()).toEqual([]); + expect(closed).toEqual(['a', 'b']); + }); + + it('removes only the dialog with id 0', () => { + useDialoger.getState().create(null, dialog(0)); + useDialoger.getState().create(null, dialog(1)); + + useDialoger.getState().remove(0); + + expect(openIds()).toEqual([1]); + }); + + it('leaves the store untouched when nothing matches', () => { + useDialoger.getState().create(null, dialog('a')); + const before = useDialoger.getState().dialogs; + + useDialoger.getState().remove('missing'); + + expect(useDialoger.getState().dialogs).toBe(before); + }); + + it('blurs a focused button and stashes it as the focus fallback', () => { + const button = document.body.appendChild(document.createElement('button')); + button.focus(); + + useDialoger.getState().create(null, dialog('a')); + + expect(fallbackContentRef.current).toBe(button); + expect(document.activeElement).not.toBe(button); + }); + + it('leaves a focused input focused and the fallback unchanged', () => { + const input = document.body.appendChild(document.createElement('input')); + input.focus(); + + useDialoger.getState().create(null, dialog('a')); + + expect(fallbackContentRef.current).toBeNull(); + expect(document.activeElement).toBe(input); + }); +}); diff --git a/frontend/src/modules/common/dialoger/use-dialoger.ts b/frontend/src/modules/common/dialoger/use-dialoger.ts index a3345fc40..ea50ce745 100644 --- a/frontend/src/modules/common/dialoger/use-dialoger.ts +++ b/frontend/src/modules/common/dialoger/use-dialoger.ts @@ -1,12 +1,8 @@ import type { ReactNode, RefObject } from 'react'; import { create } from 'zustand'; -import { fallbackContentRef } from '~/utils/fallback-content-ref'; +import { blurAndStashTrigger, removeAndNotify, withDefaults } from '~/modules/common/overlay-store-helpers'; -type DialogContainerOptions = { - ref: RefObject<HTMLDivElement | null>; - overlay?: boolean; - overlayRef?: RefObject<HTMLDivElement | null>; -}; +type DialogContainerOptions = { ref: RefObject<HTMLDivElement | null>; overlay?: boolean; overlayRef?: RefObject<HTMLDivElement | null> }; export type TriggerRef = RefObject<HTMLButtonElement | HTMLAnchorElement | null>; @@ -26,11 +22,7 @@ export type DialogData = { onClose?: (isCleanup?: boolean) => void; }; -export type InternalDialog = DialogData & { - key: number; - open?: boolean; - content: ReactNode; -}; +export type InternalDialog = DialogData & { open?: boolean; content: ReactNode }; interface DialogStoreState { dialogs: InternalDialog[]; @@ -41,55 +33,34 @@ interface DialogStoreState { get: (id: number | string) => InternalDialog | undefined; scrollToTop: (id: number | string) => void; - triggerRefs: Record<string, TriggerRef | null>; - + /** @deprecated No-op: focus returns through `triggerRef` or the focus fallback. Removed in the next release. */ setTriggerRef: (id: string, ref: TriggerRef) => void; - getTriggerRef: (id: string) => TriggerRef | null; } // Manages one or multiple dialogs; on mobile they render as drawers. export const useDialoger = create<DialogStoreState>((set, get) => ({ dialogs: [], - triggerRefs: {}, create: (content, data) => { - // Blur the active element: a modal sets aria-hidden on ancestors and would trap focus there - if (document.activeElement instanceof HTMLButtonElement || document.activeElement instanceof HTMLAnchorElement) { - fallbackContentRef.current = document.activeElement; - document.activeElement.blur(); - } - - const defaults = { - drawerOnMobile: true, - headerClassName: 'with-close-btn', - open: true, - modal: true, - key: Date.now(), - }; + blurAndStashTrigger(); + + const defaults = { drawerOnMobile: true, headerClassName: 'with-close-btn', open: true }; set((state) => ({ - dialogs: [...state.dialogs.filter((d) => d.id !== data.id), { ...defaults, ...data, content }], + dialogs: [...state.dialogs.filter((d) => d.id !== data.id), { ...withDefaults(defaults, data), content }], })); return data.id; }, update: (id, updates) => { - set((state) => ({ - dialogs: state.dialogs.map((dialog) => (dialog.id === id ? { ...dialog, ...updates } : dialog)), - })); + set((state) => ({ dialogs: state.dialogs.map((dialog) => (dialog.id === id ? { ...dialog, ...updates } : dialog)) })); }, remove: (id, opts) => { const { dialogs } = get(); - const dialogsToRemove = id ? dialogs.filter((d) => d.id === id) : dialogs; - if (!dialogsToRemove.length) return; - - // Update the store before onClose: a callback that navigates from inside set() would - // interleave a router update with this one and render a stale frame of the dialog. - set({ dialogs: dialogs.filter((d) => !dialogsToRemove.some((r) => r.id === d.id)) }); - - for (const dialog of dialogsToRemove) dialog.onClose?.(opts?.isCleanup); + const toRemove = id === undefined ? dialogs : dialogs.filter((d) => d.id === id); + removeAndNotify((remaining) => set({ dialogs: remaining }), dialogs, toRemove, opts); }, get: (id) => get().dialogs.find((d) => d.id === id), @@ -99,13 +70,5 @@ export const useDialoger = create<DialogStoreState>((set, get) => ({ popup?.closest('[data-slot="dialog-viewport"]')?.scrollTo({ top: 0 }); }, - setTriggerRef: (id, ref) => { - set((state) => ({ - triggerRefs: { ...state.triggerRefs, [id]: ref }, - })); - }, - - getTriggerRef: (id) => { - return get().triggerRefs[id] ?? null; - }, + setTriggerRef: () => {}, })); diff --git a/frontend/src/modules/common/drop-indicator.tsx b/frontend/src/modules/common/drop-indicator.tsx index e06229775..cadc9b720 100644 --- a/frontend/src/modules/common/drop-indicator.tsx +++ b/frontend/src/modules/common/drop-indicator.tsx @@ -10,18 +10,10 @@ interface Props { export function DropIndicator({ edge, className = '', gap = 0 }: Props) { const dropIndicatorEdgeStyles = { - top: { - top: `${-gap / 2}rem`, - }, - bottom: { - bottom: `${-gap / 2}rem`, - }, - left: { - left: `${-gap / 2}rem`, - }, - right: { - right: `${-gap / 2}rem`, - }, + top: { top: `${-gap / 2}rem` }, + bottom: { bottom: `${-gap / 2}rem` }, + left: { left: `${-gap / 2}rem` }, + right: { right: `${-gap / 2}rem` }, }; return ( diff --git a/frontend/src/modules/common/dropdowner/drawer.tsx b/frontend/src/modules/common/dropdowner/drawer.tsx index 1e18b14ec..e10bc586e 100644 --- a/frontend/src/modules/common/dropdowner/drawer.tsx +++ b/frontend/src/modules/common/dropdowner/drawer.tsx @@ -1,9 +1,9 @@ import { useEventListener } from '~/hooks/use-event-listener'; import { type InternalDropdown, useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; -import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; export function DropdownerDrawer({ dropdown }: { dropdown: InternalDropdown }) { - const { id, content } = dropdown; + const { id, content, triggerLabel } = dropdown; const closeDialog = () => { useDropdowner.getState().remove(); @@ -19,10 +19,7 @@ export function DropdownerDrawer({ dropdown }: { dropdown: InternalDropdown }) { <Drawer key={id} open={true} onOpenChange={onOpenChange}> <DrawerContent id={String(id)} className="max-h-[70dvh]"> <DrawerHeader data-overlay="dropdown" className="p-0"> - <span className="sr-only"> - <DrawerTitle>Choose</DrawerTitle> - <DrawerDescription>Select an option</DrawerDescription> - </span> + {triggerLabel && <DrawerTitle className="sr-only">{triggerLabel}</DrawerTitle>} </DrawerHeader> <div className="flex flex-col gap-2 p-4">{content}</div> </DrawerContent> diff --git a/frontend/src/modules/common/dropdowner/dropdown-action-item.tsx b/frontend/src/modules/common/dropdowner/dropdown-action-item.tsx index 348cb2bdf..7b4539c97 100644 --- a/frontend/src/modules/common/dropdowner/dropdown-action-item.tsx +++ b/frontend/src/modules/common/dropdowner/dropdown-action-item.tsx @@ -18,20 +18,12 @@ interface Props { closeOnSelect?: boolean; } -export function DropdownActionItem({ - isMobile, - onSelect, - icon: Icon, - children, - variant = 'secondary', - className, - closeOnSelect = true, -}: Props) { +export function DropdownActionItem({ isMobile, onSelect, icon: Icon, children, variant = 'secondary', className, closeOnSelect = true }: Props) { if (isMobile) { return ( <div className="sm:p-1"> <Button onClick={onSelect} variant={variant} className={cn('flex w-full items-center', className)}> - {Icon && <Icon className="mr-2" />} + {Icon && <Icon />} {children} </Button> </div> @@ -44,8 +36,7 @@ export function DropdownActionItem({ onClick={onSelect} className={cn( 'relative flex min-h-10 w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground', - variant === 'destructive' && - 'text-destructive data-highlighted:bg-destructive data-highlighted:text-destructive-foreground', + variant === 'destructive' && 'text-destructive data-highlighted:bg-destructive data-highlighted:text-destructive-foreground', className, )} > diff --git a/frontend/src/modules/common/dropdowner/dropdown-select-item.tsx b/frontend/src/modules/common/dropdowner/dropdown-select-item.tsx index fb51f138d..b1882fa65 100644 --- a/frontend/src/modules/common/dropdowner/dropdown-select-item.tsx +++ b/frontend/src/modules/common/dropdowner/dropdown-select-item.tsx @@ -22,10 +22,7 @@ export function DropdownSelectItem({ isMobile, selected, onSelect, icon, childre <span className="min-w-0 grow truncate text-left">{children}</span> <span aria-hidden="true" - className={cn( - 'pointer-events-none ml-auto flex size-4 items-center justify-center text-success', - !selected && 'invisible', - )} + className={cn('pointer-events-none ml-auto flex size-4 items-center justify-center text-success', !selected && 'invisible')} > <CheckIcon className="size-4" /> </span> diff --git a/frontend/src/modules/common/dropdowner/dropdown.tsx b/frontend/src/modules/common/dropdowner/dropdown.tsx index 32d648ea7..3b11ff378 100644 --- a/frontend/src/modules/common/dropdowner/dropdown.tsx +++ b/frontend/src/modules/common/dropdowner/dropdown.tsx @@ -62,11 +62,16 @@ export function DropdownerDropdown({ dropdown }: { dropdown: InternalDropdown }) return <PanelDropdown dropdown={dropdown} triggerEl={triggerEl} />; } +/** Closes the dropdown unless it was already swapped for another (e.g. a menu item opening a confirmation panel). */ +function removeIfCurrent(key: number) { + if (useDropdowner.getState().dropdown?.key === key) useDropdowner.getState().remove(); +} + function MenuDropdown({ dropdown, triggerEl }: { dropdown: InternalDropdown; triggerEl: HTMLElement }) { const triggerFocusRef = useLatestRef(triggerEl); const onOpenChange = (nextOpen: boolean) => { - if (!nextOpen) useDropdowner.getState().remove(); + if (!nextOpen) removeIfCurrent(dropdown.key); }; return ( @@ -74,10 +79,7 @@ function MenuDropdown({ dropdown, triggerEl }: { dropdown: InternalDropdown; tri <Menu.Portal> <Menu.Positioner anchor={triggerEl} align={dropdown.align} sideOffset={4} className="z-301"> <Menu.Popup - className={cn( - 'min-w-32 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden', - dropdown.popupClassName, - )} + className={cn('min-w-32 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden', dropdown.popupClassName)} finalFocus={triggerFocusRef} > {dropdown.content} @@ -92,14 +94,14 @@ function PanelDropdown({ dropdown, triggerEl }: { dropdown: InternalDropdown; tr const triggerFocusRef = useLatestRef(triggerEl); const onOpenChange = (nextOpen: boolean) => { - if (!nextOpen) useDropdowner.getState().remove(); + if (!nextOpen) removeIfCurrent(dropdown.key); }; return ( <Popover key={dropdown.key} open={true} onOpenChange={onOpenChange} modal={false}> - <PopoverContent anchor={triggerEl} align={dropdown.align} className="z-301 p-0" finalFocus={triggerFocusRef}> + <PopoverContent anchor={triggerEl} align={dropdown.align} positionerClassName="z-301" className="p-0" finalFocus={triggerFocusRef}> <FocusTrap active initialFocus returnFocus containFocus> - <div style={{ display: 'contents' }}>{dropdown.content}</div> + <div className="contents">{dropdown.content}</div> </FocusTrap> </PopoverContent> </Popover> diff --git a/frontend/src/modules/common/dropdowner/provider.tsx b/frontend/src/modules/common/dropdowner/provider.tsx index 08cb8e9e1..bfb9556ca 100644 --- a/frontend/src/modules/common/dropdowner/provider.tsx +++ b/frontend/src/modules/common/dropdowner/provider.tsx @@ -1,10 +1,8 @@ -import { useEffect } from 'react'; -import { useBodyClass } from '~/hooks/use-body-class'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { DropdownerDrawer } from '~/modules/common/dropdowner/drawer'; import { DropdownerDropdown } from '~/modules/common/dropdowner/dropdown'; import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; -import { useUIStore } from '~/modules/ui/ui-store'; +import { useOverlayLock } from '~/modules/common/overlay-store-helpers'; /** * Renders dropdowns as drawers on mobile and popovers on desktop. @@ -12,17 +10,8 @@ import { useUIStore } from '~/modules/ui/ui-store'; export function Dropdowner() { const dropdown = useDropdowner((state) => state.dropdown); const isMobile = useBreakpointBelow('sm'); - const lockUI = useUIStore((state) => state.lockUI); - const unlockUI = useUIStore((state) => state.unlockUI); - useBodyClass({ 'dropdowner-open': !!dropdown }); - - useEffect(() => { - if (dropdown) { - lockUI('dropdowner'); - return () => unlockUI('dropdowner'); - } - }, [!!dropdown]); + useOverlayLock('dropdowner', !!dropdown); if (!dropdown) return null; if (isMobile) return <DropdownerDrawer dropdown={dropdown} />; diff --git a/frontend/src/modules/common/dropdowner/use-dropdowner.test.ts b/frontend/src/modules/common/dropdowner/use-dropdowner.test.ts new file mode 100644 index 000000000..34346863d --- /dev/null +++ b/frontend/src/modules/common/dropdowner/use-dropdowner.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { createRef, type RefObject } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fallbackContentRef } from '~/utils/fallback-content-ref'; +import { type DropdownData, useDropdowner } from './use-dropdowner'; + +const trigger = (): RefObject<HTMLButtonElement | null> => { + const ref = createRef<HTMLButtonElement>() as { current: HTMLButtonElement | null }; + ref.current = document.body.appendChild(document.createElement('button')); + return ref; +}; + +const dropdown = (triggerId: string, data: Partial<DropdownData> = {}): DropdownData => ({ + id: triggerId, + triggerId, + triggerRef: trigger(), + ...data, +}); + +const isActive = (ref: RefObject<HTMLButtonElement | null>) => ref.current?.hasAttribute('data-dropdowner-active'); + +describe('dropdowner store', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + useDropdowner.setState({ dropdown: null, lastRemovedTriggerId: null, lastRemovedAt: 0 }); + fallbackContentRef.current = null; + }); + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('opens one dropdown with its defaults and marks the trigger active', () => { + const data = dropdown('t1'); + + const id = useDropdowner.getState().create('menu', data); + + expect(id).toBe('t1'); + expect(useDropdowner.getState().get()).toMatchObject({ triggerId: 't1', content: 'menu', align: 'start', modal: true, kind: 'panel' }); + expect(isActive(data.triggerRef)).toBe(true); + }); + + it('lets data override the defaults', () => { + useDropdowner.getState().create(null, dropdown('t1', { align: 'end', modal: false, kind: 'menu' })); + + expect(useDropdowner.getState().dropdown).toMatchObject({ align: 'end', modal: false, kind: 'menu' }); + }); + + it('keeps the defaults for options passed as undefined', () => { + useDropdowner.getState().create(null, dropdown('t1', { align: undefined, modal: undefined, kind: undefined })); + + expect(useDropdowner.getState().dropdown).toMatchObject({ align: 'start', modal: true, kind: 'panel' }); + }); + + it('closes when the same trigger opens it again', () => { + const data = dropdown('t1'); + useDropdowner.getState().create(null, data); + + useDropdowner.getState().create(null, { ...data, id: 'other-id' }); + + expect(useDropdowner.getState().dropdown).toBeNull(); + expect(isActive(data.triggerRef)).toBe(false); + expect(useDropdowner.getState()).toMatchObject({ lastRemovedTriggerId: 't1', lastRemovedAt: 10_000 }); + }); + + it('moves the active mark to a different trigger', () => { + const first = dropdown('t1'); + const second = dropdown('t2'); + useDropdowner.getState().create(null, first); + + useDropdowner.getState().create('second', second); + + expect(useDropdowner.getState().dropdown?.triggerId).toBe('t2'); + expect(isActive(first.triggerRef)).toBe(false); + expect(isActive(second.triggerRef)).toBe(true); + }); + + it('ignores a reopen from the same trigger within 300 ms of a removal, once', () => { + const data = dropdown('t1'); + useDropdowner.getState().create(null, data); + useDropdowner.getState().remove(); + expect(isActive(data.triggerRef)).toBe(false); + + vi.advanceTimersByTime(299); + useDropdowner.getState().create(null, data); + expect(useDropdowner.getState().dropdown).toBeNull(); + expect(useDropdowner.getState().lastRemovedTriggerId).toBeNull(); + + // The guard is spent: the next click opens + useDropdowner.getState().create(null, data); + expect(useDropdowner.getState().dropdown?.triggerId).toBe('t1'); + }); + + it('reopens from the same trigger after 300 ms', () => { + const data = dropdown('t1'); + useDropdowner.getState().create(null, data); + useDropdowner.getState().remove(); + + vi.advanceTimersByTime(300); + useDropdowner.getState().create(null, data); + + expect(useDropdowner.getState().dropdown?.triggerId).toBe('t1'); + }); + + it('opens a different trigger right after a removal', () => { + useDropdowner.getState().create(null, dropdown('t1')); + useDropdowner.getState().remove(); + + useDropdowner.getState().create(null, dropdown('t2')); + + expect(useDropdowner.getState().dropdown?.triggerId).toBe('t2'); + }); + + it('lets a programmatic open bypass the reopen guard', () => { + const data = dropdown('t1'); + useDropdowner.getState().create(null, data); + useDropdowner.getState().remove(); + + useDropdowner.getState().create(null, { ...data, programmatic: true }); + + expect(useDropdowner.getState().dropdown?.triggerId).toBe('t1'); + expect(isActive(data.triggerRef)).toBe(true); + }); + + it('merges updates into the open dropdown and ignores them when closed', () => { + useDropdowner.getState().update({ align: 'end' }); + expect(useDropdowner.getState().dropdown).toBeNull(); + + useDropdowner.getState().create(null, dropdown('t1')); + useDropdowner.getState().update({ align: 'end', content: 'next' }); + + expect(useDropdowner.getState().dropdown).toMatchObject({ align: 'end', content: 'next', triggerId: 't1' }); + }); + + it('records the removed trigger and clears its active mark', () => { + const data = dropdown('t1'); + useDropdowner.getState().create(null, data); + + useDropdowner.getState().remove(); + + expect(useDropdowner.getState()).toMatchObject({ dropdown: null, lastRemovedTriggerId: 't1', lastRemovedAt: 10_000 }); + expect(isActive(data.triggerRef)).toBe(false); + }); + + it('blurs any focused element without touching the focus fallback', () => { + const input = document.body.appendChild(document.createElement('input')); + input.focus(); + + useDropdowner.getState().create(null, dropdown('t1')); + + expect(document.activeElement).not.toBe(input); + expect(fallbackContentRef.current).toBeNull(); + }); +}); diff --git a/frontend/src/modules/common/dropdowner/use-dropdowner.ts b/frontend/src/modules/common/dropdowner/use-dropdowner.ts index 273e2f926..86bbfc12e 100644 --- a/frontend/src/modules/common/dropdowner/use-dropdowner.ts +++ b/frontend/src/modules/common/dropdowner/use-dropdowner.ts @@ -1,5 +1,6 @@ import type { ReactNode, RefObject } from 'react'; import { create } from 'zustand'; +import { withDefaults } from '~/modules/common/overlay-store-helpers'; export type DropdownKind = 'menu' | 'panel'; @@ -23,6 +24,8 @@ export type DropdownData = { export type InternalDropdown = DropdownData & { key: number; content: ReactNode; + /** The trigger's accessible name at open time; names the mobile drawer that stands in for the dropdown. */ + triggerLabel?: string; align: 'start' | 'center' | 'end'; modal: boolean; kind: DropdownKind; @@ -69,9 +72,10 @@ export const useDropdowner = create<DropdownStoreState>((set, get) => ({ // Blur active element to prevent aria-hidden conflict when modal sets aria-hidden on ancestors if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - set({ - dropdown: { content, align: 'start', modal: true, kind: 'panel', ...data, key: Date.now() }, - }); + const defaults: Pick<InternalDropdown, 'align' | 'modal' | 'kind'> = { align: 'start', modal: true, kind: 'panel' }; + const trigger = data.triggerRef.current; + const triggerLabel = trigger?.getAttribute('aria-label') || trigger?.textContent?.trim() || undefined; + set({ dropdown: { ...withDefaults(defaults, data), content, triggerLabel, key: Date.now() } }); return data.id; }, @@ -80,9 +84,7 @@ export const useDropdowner = create<DropdownStoreState>((set, get) => ({ const current = get().dropdown; if (!current) return; - set({ - dropdown: { ...current, ...updates }, - }); + set({ dropdown: { ...current, ...updates } }); }, remove: () => { diff --git a/frontend/src/modules/common/entity-avatar.tsx b/frontend/src/modules/common/entity-avatar.tsx index 7539e4fe7..1f009b7ca 100644 --- a/frontend/src/modules/common/entity-avatar.tsx +++ b/frontend/src/modules/common/entity-avatar.tsx @@ -19,10 +19,7 @@ function EntityAvatarBase({ type, id, name, icon: Icon, url, className, ...props return ( <Avatar {...props} - className={cn( - 'group flex items-center justify-center overflow-hidden rounded-md bg-background data-[type=user]:rounded-full', - className, - )} + className={cn('flex items-center justify-center overflow-hidden rounded-md bg-background data-[type=user]:rounded-full', className)} > <Icon className="size-[70%] fill-accent opacity-70" strokeWidth={1.5} /> </Avatar> @@ -31,11 +28,7 @@ function EntityAvatarBase({ type, id, name, icon: Icon, url, className, ...props const avatarBackground = numberToColorClass(id); return ( - <Avatar - {...props} - data-type={type} - className={cn('group overflow-hidden rounded-md data-[type=user]:rounded-full', className)} - > + <Avatar {...props} data-type={type} className={cn('overflow-hidden rounded-md data-[type=user]:rounded-full', className)}> {url && <AvatarImage src={url} draggable={false} />} <AvatarFallback className={avatarBackground}> <span className="sr-only">{name}</span> diff --git a/frontend/src/modules/common/error-helpers.ts b/frontend/src/modules/common/error-helpers.ts index e0211434c..b2801e487 100644 --- a/frontend/src/modules/common/error-helpers.ts +++ b/frontend/src/modules/common/error-helpers.ts @@ -18,8 +18,7 @@ function getErrorLocaleKey(error?: ErrorNoticeError, errorFromQuery?: string): s if (error instanceof SearchParamError) return 'invalid_param'; - if (error instanceof ApiError) - return error.entityType && error.type ? `resource_${error.type}` : error.type || error.name; + if (error instanceof ApiError) return error.entityType && error.type ? `resource_${error.type}` : error.type || error.name; return error.name; } diff --git a/frontend/src/modules/common/error-notice.tsx b/frontend/src/modules/common/error-notice.tsx index fceb46e1c..ed5dedae2 100644 --- a/frontend/src/modules/common/error-notice.tsx +++ b/frontend/src/modules/common/error-notice.tsx @@ -1,7 +1,7 @@ import { useRouter, useRouterState } from '@tanstack/react-router'; import { ChevronUpIcon, HouseIcon, MessageCircleQuestionMarkIcon, RefreshCwIcon } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; -import { useEffect, useRef, useState } from 'react'; +import { Fragment, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AppFooter } from '~/modules/common/app/app-footer'; import { Dialoger } from '~/modules/common/dialoger/provider'; @@ -9,6 +9,7 @@ import { type ErrorNoticeError, getErrorInfo, handleAskForHelp } from '~/modules import { Button } from '~/modules/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '~/modules/ui/card'; import type { BoundaryType } from '~/routes/types'; +import { cn } from '~/utils/cn'; export type { ErrorNoticeError } from '~/modules/common/error-helpers'; @@ -26,18 +27,15 @@ interface ErrorNoticeProps { export function ErrorNotice({ error, children, resetErrorBoundary, boundary, homePath = '/' }: ErrorNoticeProps) { const { t } = useTranslation(); const router = useRouter(); - const { location } = useRouterState(); + const errorFromQuery = useRouterState({ select: (s) => s.location.search.error }); + const severityFromQuery = useRouterState({ select: (s) => s.location.search.severity }); const contactButtonRef = useRef<HTMLButtonElement>(null); - const { error: errorFromQuery, severity: severityFromQuery } = location.search; - const [showError, setShowError] = useState(false); const severity = error && 'severity' in error ? error.severity : severityFromQuery; const { title, message } = getErrorInfo({ error, errorFromQuery }); - const dateNow = new Date().toUTCString(); - // Reset before a route change so the error state is not retained useEffect(() => { const unsub = router.subscribe('onBeforeRouteMount', () => { @@ -73,16 +71,16 @@ export function ErrorNotice({ error, children, resetErrorBoundary, boundary, hom </CardDescription> </CardHeader> {error && 'status' in error && ( - <CardContent className="whitespace-pre-wrap px-0 py-4 font-mono text-red-600"> + <CardContent className="whitespace-pre-wrap px-0 py-4 font-mono text-destructive"> {error.type && ( <Button variant="link" size="sm" onClick={() => setShowError((prev) => !prev)} - className="flex w-full items-center whitespace-pre-wrap text-red-600" + className="flex w-full items-center whitespace-pre-wrap text-destructive" > <span>{showError ? t('c:hide_details') : t('c:show_details')}</span> - {<ChevronUpIcon className={`ml-2 transition-transform ${showError ? 'rotate-0' : 'rotate-180'}`} />} + {<ChevronUpIcon className={cn('transition-transform', showError ? 'rotate-0' : 'rotate-180')} />} </Button> )} @@ -96,24 +94,25 @@ export function ErrorNotice({ error, children, resetErrorBoundary, boundary, hom className="overflow-hidden" > <div className="grid grid-cols-[auto_1fr] place-items-start gap-1 pb-4 text-sm"> - <div className="place-self-end pr-4 font-medium">Log ID</div> - <div>{error.logId || 'na'}</div> - <div className="place-self-end pr-4 font-medium">Timestamp</div> - <div>{dateNow}</div> - <div className="place-self-end pr-4 font-medium">Message</div> - <div>{error.message || 'na'}</div> - <div className="place-self-end pr-4 font-medium">Type</div> - <div>{error.type || 'na'}</div> - <div className="place-self-end pr-4 font-medium">Resource type</div> - <div>{error.entityType || 'na'}</div> - <div className="place-self-end pr-4 font-medium">HTTP status</div> - <div>{error.status || 'na'}</div> - <div className="place-self-end pr-4 font-medium">Severity</div> - <div>{error.severity || 'na'}</div> - <div className="place-self-end pr-4 font-medium">User ID</div> - <div>{error.userId || 'na'}</div> - <div className="place-self-end pr-4 font-medium">Organization ID</div> - <div>{error.organizationId || 'na'}</div> + {( + [ + ['c:request_id', error.requestId], + // A server error carries the moment it was raised; one made in the browser shows the time it renders. + ['c:timestamp', new Date(error.timestamp ?? Date.now()).toUTCString()], + ['c:message', error.message], + ['c:type', error.type], + ['c:resource_type', error.entityType], + ['c:http_status', error.status], + ['c:severity', error.severity], + ['c:user_id', error.userId], + ['c:organization_id', error.organizationId], + ] as const + ).map(([label, value]) => ( + <Fragment key={label}> + <div className="place-self-end pr-4 font-medium">{t(label)}</div> + <div>{value || 'na'}</div> + </Fragment> + ))} </div> </motion.div> )} @@ -126,12 +125,12 @@ export function ErrorNotice({ error, children, resetErrorBoundary, boundary, hom ) : ( <> <Button onClick={handleGoToHome} variant="secondary"> - <HouseIcon className="mr-2" /> + <HouseIcon /> {t('c:home')} </Button> {!location.pathname.endsWith('/error') && severity !== 'info' && ( <Button onClick={handleReload}> - <RefreshCwIcon className="mr-2" /> + <RefreshCwIcon /> {t('c:reload')} </Button> )} @@ -139,7 +138,7 @@ export function ErrorNotice({ error, children, resetErrorBoundary, boundary, hom )} {severity && ['warn', 'error'].includes(severity) && ( <Button ref={contactButtonRef} variant="plain" onClick={() => handleAskForHelp(contactButtonRef)}> - <MessageCircleQuestionMarkIcon className="mr-2" /> + <MessageCircleQuestionMarkIcon /> {t('c:contact_support')} </Button> )} diff --git a/frontend/src/modules/common/expandable-list.tsx b/frontend/src/modules/common/expandable-list.tsx index 592239077..322c766fb 100644 --- a/frontend/src/modules/common/expandable-list.tsx +++ b/frontend/src/modules/common/expandable-list.tsx @@ -14,13 +14,7 @@ interface ExpandableListProps<T> { expandText: TKey; } -export function ExpandableList<T>({ - items, - renderItem, - initialDisplayCount, - alwaysShowAll = false, - expandText, -}: ExpandableListProps<T>) { +export function ExpandableList<T>({ items, renderItem, initialDisplayCount, alwaysShowAll = false, expandText }: ExpandableListProps<T>) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(alwaysShowAll); const hasExpandedOnce = useRef(false); @@ -57,11 +51,11 @@ export function ExpandableList<T>({ setExpanded(true); }} > - <Badge size="sm" className="mr-2 aspect-square px-1 py-0"> + <Badge size="sm" className="aspect-square px-1 py-0"> {items.length - initialDisplayCount} </Badge> {t(expandText)} - <ChevronDownIcon className="ml-2 opacity-50 transition-opacity group-hover:opacity-100" /> + <ChevronDownIcon className="opacity-50 transition-opacity group-hover:opacity-100" /> </Button> )} </> diff --git a/frontend/src/modules/common/focus-trap.tsx b/frontend/src/modules/common/focus-trap.tsx index 0d670f239..0feb7f592 100644 --- a/frontend/src/modules/common/focus-trap.tsx +++ b/frontend/src/modules/common/focus-trap.tsx @@ -76,8 +76,10 @@ export function FocusTrap({ initialFocus.current.focus({ preventScroll: true }); return; } + // A `data-autofocus` element wins, so a confirmation can start on its safe option. + const preferred = trap.querySelector<HTMLElement>('[data-autofocus]'); const focusable = getFocusableElements(trap); - (focusable[0] ?? trap).focus({ preventScroll: true }); + (preferred ?? focusable[0] ?? trap).focus({ preventScroll: true }); }); } diff --git a/frontend/src/modules/common/focus-view.tsx b/frontend/src/modules/common/focus-view.tsx index e74fdb4c9..d0cc449d5 100644 --- a/frontend/src/modules/common/focus-view.tsx +++ b/frontend/src/modules/common/focus-view.tsx @@ -41,13 +41,16 @@ export function FocusView({ className = '', iconOnly }: FocusViewProps) { <TooltipButton toolTipContent={t('c:focus_view')} disabled={!iconOnly} className="max-lg:hidden"> <Button variant={'outline'} className={cn('flex max-lg:hidden', className)} onClick={toggleFocus}> {focusView ? <ShrinkIcon /> : <ExpandIcon />} - {!iconOnly && <span className="ml-1">{focusView ? t('c:leave_focus_view') : t('c:focus_view')}</span>} + {!iconOnly && <span>{focusView ? t('c:leave_focus_view') : t('c:focus_view')}</span>} </Button> </TooltipButton> ); } -/** Applies focus view styles while the mode is active. Wraps the page's main content. */ +/** + * Wraps the page's main content. While focus view is active, everything else in the enclosing `.focus-view-scope` + * is hidden; chrome outside a scope opts in with the `focus-view:hidden` variant. + */ export function FocusViewContainer({ children, className = '', disabled }: FocusViewContainerProps) { const focusView = useUIStore((state) => state.focusView); @@ -60,7 +63,7 @@ export function FocusViewContainer({ children, className = '', disabled }: Focus className={cn( 'focus-view-container container flex min-h-svh flex-col gap-2 pt-3', className, - isActive ? 'focused min-h-full w-full min-w-full max-w-none' : '', + isActive && 'min-h-full w-full min-w-full max-w-none', )} > {children} diff --git a/frontend/src/modules/common/form-draft/draft-store.ts b/frontend/src/modules/common/form-draft/draft-store.ts index 5050fc166..6042c016a 100644 --- a/frontend/src/modules/common/form-draft/draft-store.ts +++ b/frontend/src/modules/common/form-draft/draft-store.ts @@ -44,12 +44,7 @@ export const useDraftStore = create<DraftStoreState>()( }, isFormDirty: (key: string) => !!get().dirtyForms[key], }), - { - version: 1, - name: 'drafts', - skipHydration: true, - storage: createJSONStorage(() => idbKvStorage('drafts')), - }, + { version: 1, name: 'drafts', skipHydration: true, storage: createJSONStorage(() => idbKvStorage('drafts')) }, ), ), ); diff --git a/frontend/src/modules/common/form-draft/use-draft-form.tsx b/frontend/src/modules/common/form-draft/use-draft-form.tsx index bd63f007a..41ebc240a 100644 --- a/frontend/src/modules/common/form-draft/use-draft-form.tsx +++ b/frontend/src/modules/common/form-draft/use-draft-form.tsx @@ -13,15 +13,8 @@ import { defaultOnInvalid } from '~/utils/form-on-invalid'; // biome-ignore lint/suspicious/noExplicitAny: Can be any form context export function useFormWithDraft<TFieldValues extends FieldValues = FieldValues, TContext = any>( formId: string, - opt?: { - formOptions?: UseFormProps<TFieldValues, TContext>; - formContainerId?: string; - }, -): UseFormReturn<TFieldValues, TContext, TFieldValues> & { - unsavedChanges: boolean; - isDirty: boolean; - loading: boolean; -} { + opt?: { formOptions?: UseFormProps<TFieldValues, TContext>; formContainerId?: string }, +): UseFormReturn<TFieldValues, TContext, TFieldValues> & { unsavedChanges: boolean; isDirty: boolean; loading: boolean } { const { formOptions, formContainerId } = opt || {}; const getDraftForm = useDraftStore((state) => state.getForm); @@ -127,8 +120,7 @@ export function useFormWithDraft<TFieldValues extends FieldValues = FieldValues, const draftData = getDraftForm<TFieldValues>(formId); if (draftData) { - for (const [key, value] of Object.entries(draftData)) - form.setValue(key as FieldPath<TFieldValues>, value, { shouldDirty: true }); + for (const [key, value] of Object.entries(draftData)) form.setValue(key as FieldPath<TFieldValues>, value, { shouldDirty: true }); } setLoading(false); diff --git a/frontend/src/modules/common/form-fields/avatar.tsx b/frontend/src/modules/common/form-fields/avatar.tsx index 2d0aeafc5..e2ff2c472 100644 --- a/frontend/src/modules/common/form-fields/avatar.tsx +++ b/frontend/src/modules/common/form-fields/avatar.tsx @@ -15,10 +15,7 @@ interface Props { form: UseFormReturn<any>; name: string; label: string; - entity: { - id?: string; - name?: string | null; - }; + entity: { id?: string; name?: string | null }; type: EntityAvatarProps['type']; } @@ -62,20 +59,18 @@ export function AvatarFormField({ form, label, name, entity, type }: Props) { <FormItem> <FormLabel>{label}</FormLabel> <div className="flex gap-4"> - <EntityAvatar type={type} className="h-16 w-16 text-3xl" id={entity.id} name={entity.name} url={url} /> + <EntityAvatar type={type} className="size-16 text-3xl" id={entity.id} name={entity.name} url={url} /> <div className="flex flex-col gap-2"> {appConfig.has.uploadEnabled ? ( <p className="text-xs sm:text-sm">{t('c:upload_img_max_10mb.text')}</p> ) : ( - appConfig.mode === 'development' && ( - <p className="text-muted-foreground text-xs sm:text-sm">{t('c:restrict_image_upload')}</p> - ) + appConfig.mode === 'development' && <p className="text-muted-foreground text-xs sm:text-sm">{t('c:restrict_image_upload')}</p> )} <div className="flex items-center gap-2"> {appConfig.has.uploadEnabled && ( <Button ref={uploadButtonRef} variant="plain" type="button" size="sm" onClick={openUploadDialog}> - <UploadIcon className="mr-2" /> + <UploadIcon /> <span>{t('c:upload')}</span> </Button> )} diff --git a/frontend/src/modules/common/form-fields/blocknote.tsx b/frontend/src/modules/common/form-fields/blocknote.tsx index 53ff0b599..68f3d2fa9 100644 --- a/frontend/src/modules/common/form-fields/blocknote.tsx +++ b/frontend/src/modules/common/form-fields/blocknote.tsx @@ -4,10 +4,7 @@ import type { BaseUppyFilePanelProps, CommonBlockNoteProps } from '~/modules/com import type { BaseFormFieldProps } from '~/modules/common/form-fields/type'; import { FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/field'; -type BaseBlockNoteProps = Omit< - CommonBlockNoteProps, - 'defaultValue' | 'updateData' | 'filePanel' | 'baseFilePanelProps' -> & { +type BaseBlockNoteProps = Omit<CommonBlockNoteProps, 'defaultValue' | 'updateData' | 'filePanel' | 'baseFilePanelProps'> & { /** Omit to disable file/media uploads (the editor renders no file panel without it). */ baseFilePanelProps?: BaseUppyFilePanelProps; }; @@ -49,11 +46,7 @@ function BlockNoteContentFormField<TFieldValues extends FieldValues>({ </FormLabel> )} {/* The filePanel/baseFilePanelProps union needs two branches: a conditional spread widens it */} - {baseFilePanelProps ? ( - <BlockNote {...editorProps} baseFilePanelProps={baseFilePanelProps} /> - ) : ( - <BlockNote {...editorProps} /> - )} + {baseFilePanelProps ? <BlockNote {...editorProps} baseFilePanelProps={baseFilePanelProps} /> : <BlockNote {...editorProps} />} <FormMessage /> </FormItem> ); diff --git a/frontend/src/modules/ui/responsive-select.tsx b/frontend/src/modules/common/form-fields/responsive-select.tsx similarity index 100% rename from frontend/src/modules/ui/responsive-select.tsx rename to frontend/src/modules/common/form-fields/responsive-select.tsx diff --git a/frontend/src/modules/common/form-fields/select-combobox/combobox-select.tsx b/frontend/src/modules/common/form-fields/select-combobox/combobox-select.tsx new file mode 100644 index 000000000..1e1261935 --- /dev/null +++ b/frontend/src/modules/common/form-fields/select-combobox/combobox-select.tsx @@ -0,0 +1,111 @@ +import { ChevronDownIcon, SearchIcon } from 'lucide-react'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TKey } from '~/lib/i18n-locales'; +import { ContentPlaceholder } from '~/modules/common/content-placeholder'; +import { EntityAvatar } from '~/modules/common/entity-avatar'; +import { Button } from '~/modules/ui/button'; +import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxPrimitive } from '~/modules/ui/combobox'; + +export interface ComboBoxOption { + value: string; + label: string; + url?: string | null; +} + +export interface ComboboxSelectProps { + options: ComboBoxOption[]; + value: string; + onChange: (newValue: string) => void; + renderOption?: (option: ComboBoxOption) => React.ReactNode; + renderAvatar?: boolean; + clearable?: boolean; + disabled?: boolean; + searchableTrigger?: boolean; + placeholders?: { trigger?: TKey; search?: TKey; notFound?: TKey; resource?: TKey }; +} + +/** Single-value combobox form control: a button (or searchable input) trigger over a filterable option list. */ +export function ComboboxSelect({ + options, + value, + onChange, + renderOption, + renderAvatar = false, + clearable = false, + disabled = false, + searchableTrigger = false, + placeholders: passedPlaceholders, +}: ComboboxSelectProps) { + const { t } = useTranslation(); + + const placeholders = { + trigger: 'c:select' as TKey, + search: 'c:placeholder.search' as TKey, + notFound: 'c:no_resource_found' as TKey, + resource: 'c:item' as TKey, + ...passedPlaceholders, + }; + + const selectedOption = options.find((o) => o.value === value) ?? null; + const anchorRef = React.useRef<HTMLDivElement>(null); + + return ( + <Combobox<ComboBoxOption> + items={options} + itemToStringLabel={(item) => item.label} + itemToStringValue={(item) => item.value} + value={selectedOption} + onValueChange={(item) => { + if (item) onChange(item.value); + else if (clearable) onChange(''); + }} + disabled={disabled} + > + {searchableTrigger ? ( + <div ref={anchorRef}> + <ComboboxInput + placeholder={t(placeholders.trigger, { resource: t(placeholders.resource).toLowerCase() })} + showTrigger + showClear={clearable && !!selectedOption} + disabled={disabled} + className="w-full" + /> + </div> + ) : ( + <ComboboxPrimitive.Trigger + data-slot="combobox-trigger" + render={<Button variant="input" aria-haspopup="listbox" className="w-full justify-between truncate font-normal" disabled={disabled} />} + > + {selectedOption ? ( + <div className="flex items-center gap-2 truncate"> + {renderAvatar && ( + <EntityAvatar className="size-6 shrink-0 text-xs" id={selectedOption.value} name={selectedOption.label} url={selectedOption.url} /> + )} + {renderOption ? renderOption(selectedOption) : <span className="truncate">{selectedOption.label}</span>} + </div> + ) : ( + <span className="truncate text-muted-foreground">{t(placeholders.trigger, { resource: t(placeholders.resource).toLowerCase() })}</span> + )} + <ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" /> + </ComboboxPrimitive.Trigger> + )} + <ComboboxContent anchor={searchableTrigger ? anchorRef : undefined}> + {!searchableTrigger && <ComboboxInput placeholder={t(placeholders.search)} showTrigger={false} />} + <ComboboxList> + {(item) => ( + <ComboboxItem key={item.value} value={item}> + <div className="flex items-center gap-2"> + {renderAvatar && <EntityAvatar id={item.value} name={item.label} url={item.url} />} + {renderOption ? renderOption(item) : <span className="truncate">{item.label}</span>} + </div> + </ComboboxItem> + )} + </ComboboxList> + <ComboboxEmpty> + <ContentPlaceholder icon={SearchIcon} title={placeholders.notFound} titleProps={{ resource: t(placeholders.resource).toLowerCase() }} /> + </ComboboxEmpty> + </ComboboxContent> + </Combobox> + ); +} diff --git a/frontend/src/modules/common/form-fields/select-combobox/country.tsx b/frontend/src/modules/common/form-fields/select-combobox/country.tsx index 7f8211e54..ef7cbbd72 100644 --- a/frontend/src/modules/common/form-fields/select-combobox/country.tsx +++ b/frontend/src/modules/common/form-fields/select-combobox/country.tsx @@ -1,17 +1,11 @@ import type { FieldValues } from 'react-hook-form'; import countries from '#json/countries.json'; import { CountryFlag } from '~/modules/common/country-flag'; +import { ComboboxSelect, type ComboboxSelectProps } from '~/modules/common/form-fields/select-combobox/combobox-select'; import type { BaseFormFieldProps } from '~/modules/common/form-fields/type'; -import { ComboboxSelect, type ComboboxSelectProps } from '~/modules/ui/combobox'; import { FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/field'; -export function SelectCountry<TFieldValues extends FieldValues>({ - control, - name, - disabled, - label, - required, -}: BaseFormFieldProps<TFieldValues>) { +export function SelectCountry<TFieldValues extends FieldValues>({ control, name, disabled, label, required }: BaseFormFieldProps<TFieldValues>) { const options = countries.map(({ code, name }) => ({ value: code, label: name })); const renderCountryOption: ComboboxSelectProps['renderOption'] = ({ value, label }) => ( diff --git a/frontend/src/modules/common/form-fields/select-combobox/parent.tsx b/frontend/src/modules/common/form-fields/select-combobox/parent.tsx index 350bef110..d75346a56 100644 --- a/frontend/src/modules/common/form-fields/select-combobox/parent.tsx +++ b/frontend/src/modules/common/form-fields/select-combobox/parent.tsx @@ -3,8 +3,8 @@ import type { FieldValues } from 'react-hook-form'; import type { ChannelBase } from 'sdk'; import type { ChannelEntityType } from 'shared'; import { channelListQueriesByType } from '~/list-queries-config'; +import { ComboboxSelect, type ComboboxSelectProps } from '~/modules/common/form-fields/select-combobox/combobox-select'; import type { BaseFormFieldProps } from '~/modules/common/form-fields/type'; -import { ComboboxSelect, type ComboboxSelectProps } from '~/modules/ui/combobox'; import { FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/field'; import { useCurrentUser } from '~/modules/user/user-store'; import { flattenInfiniteData } from '~/query/basic/flatten'; @@ -43,13 +43,7 @@ export function SelectParentFormField<TFieldValues extends FieldValues>({ }: SelectParentProps<TFieldValues>) { const { items } = useParentChannels(parentType, organizationId, !disabled); - const options = - opts ?? - items.map((i) => ({ - value: i.id, - label: i.name, - url: i.thumbnailUrl ?? undefined, - })); + const options = opts ?? items.map((i) => ({ value: i.id, label: i.name, url: i.thumbnailUrl ?? undefined })); return ( <FormField diff --git a/frontend/src/modules/common/form-fields/select-combobox/timezone.tsx b/frontend/src/modules/common/form-fields/select-combobox/timezone.tsx index 72f610648..5ad0e5d7e 100644 --- a/frontend/src/modules/common/form-fields/select-combobox/timezone.tsx +++ b/frontend/src/modules/common/form-fields/select-combobox/timezone.tsx @@ -1,16 +1,10 @@ import type { FieldValues } from 'react-hook-form'; import timezones from '#json/timezones.json'; +import { ComboboxSelect } from '~/modules/common/form-fields/select-combobox/combobox-select'; import type { BaseFormFieldProps } from '~/modules/common/form-fields/type'; -import { ComboboxSelect } from '~/modules/ui/combobox'; import { FormField, FormItem, FormLabel, FormMessage } from '~/modules/ui/field'; -export function SelectTimezone<TFieldValues extends FieldValues>({ - control, - name, - disabled, - label, - required, -}: BaseFormFieldProps<TFieldValues>) { +export function SelectTimezone<TFieldValues extends FieldValues>({ control, name, disabled, label, required }: BaseFormFieldProps<TFieldValues>) { const seen = new Set<string>(); const options = timezones.reduce<{ value: string; label: string }[]>((acc, { utc, text }) => { const value = utc[0]; diff --git a/frontend/src/modules/common/form-fields/select-emails.test.ts b/frontend/src/modules/common/form-fields/select-emails.test.ts new file mode 100644 index 000000000..6b843a961 --- /dev/null +++ b/frontend/src/modules/common/form-fields/select-emails.test.ts @@ -0,0 +1,81 @@ +import { zMembershipInviteBody, zSystemInviteBody } from 'sdk/zod.gen'; +import { describe, expect, it } from 'vitest'; +import { isInviteEmail } from '~/modules/common/form-fields/select-emails'; + +const accepted = [ + 'user@example.com', + 'User@Example.COM', + 'user+tag@example.com', + 'first.last@sub.example.co.uk', + "o'brien@example.ie", + 'user@EXAMPLE.COM', + 'user-@example.com', + 'user_@example.com', + 'user@xn--bcher-kva.de', + 'a@b.co', + 'user@1example.com', + 'user@123.com', + 'user@EXAMPLE.co', + 'a.b-c_d+e@x-y.io', + `${'a'.repeat(64)}@example.com`, + `a@${'b'.repeat(63)}.com`, + `a@${'b'.repeat(64)}.com`, + `a@${'b.'.repeat(124)}com`, + `${'a'.repeat(64)}@${'b'.repeat(60)}.${'c'.repeat(60)}.${'d'.repeat(60)}.com`, + // The next three were refused as chips although a submit accepts them. + 'user@example-.com', + 'user@sub-.example.com', + `${'a'.repeat(65)}@example.com`, +]; + +const rejected = [ + 'user@example.C0M', + 'user@example', + 'user@localhost', + 'user@example.com.', + 'user.@example.com', + '.user@example.com', + 'us..er@example.com', + 'user@-example.com', + 'user@exa_mple.com', + 'user@[192.168.0.1]', + 'user@192.168.0.1', + 'John Doe <john@example.com>', + 'x@y.z', + ' user@example.com', + 'user @example.com', + 'user@@example.com', + 'user@exa mple.com', + 'user@example.c', + 'user@example.123', + // The rest became chips that a submit then refused. + 'us..er@gmail.com', + '.user@gmail.com', + '"john doe"@example.com', + '"a@b"@example.com', + 'jöhn@example.com', + 'ÄBC@example.com', + 'user@bücher.de', + 'user@example.xn--p1ai', + 'user@пример.рф', + '用户@例子.广告', + 'a#b$c%d&@example.com', + 'a/b=c?d^e`f{g|h}i~@example.com', + 'a*b!@example.com', + `${'a'.repeat(64)}@${'b'.repeat(60)}.${'c'.repeat(60)}.${'d'.repeat(60)}.${'e'.repeat(20)}.com`, +]; + +const submits = (email: string) => + zSystemInviteBody.safeParse({ emails: [email] }).success && zMembershipInviteBody.shape.emails.safeParse([email]).success; + +describe('isInviteEmail', () => { + it.each(accepted)('accepts %s', (email) => { + expect(isInviteEmail(email)).toBe(true); + expect(submits(email)).toBe(true); + }); + + it.each(rejected)('rejects %s', (email) => { + expect(isInviteEmail(email)).toBe(false); + expect(submits(email)).toBe(false); + }); +}); diff --git a/frontend/src/modules/common/form-fields/select-emails.tsx b/frontend/src/modules/common/form-fields/select-emails.tsx index 150d86b70..8e63b6744 100644 --- a/frontend/src/modules/common/form-fields/select-emails.tsx +++ b/frontend/src/modules/common/form-fields/select-emails.tsx @@ -1,70 +1,100 @@ -import { TagInput, type TagInputProps } from '~/modules/ui/tag-input'; -import { isEmail } from '~/utils/is-email'; +import { XIcon } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { zMembershipInviteBody } from 'sdk/zod.gen'; +import { toaster } from '~/modules/common/toaster/toaster'; +import { Badge } from '~/modules/ui/badge'; +import { Button } from '~/modules/ui/button'; +import { Input } from '~/modules/ui/input'; +import { cn } from '~/utils/cn'; -interface SelectEmailsProps extends Omit<TagInputProps, 'tags' | 'setTags' | 'validateTag' | 'delimiter' | 'onChange'> { +const delimiter = /[,;\s]+/; + +/** True for exactly the addresses an invite submit accepts, so every chip passes the form. */ +export const isInviteEmail = (value: string) => zMembershipInviteBody.shape.emails.element.safeParse(value).success; + +interface SelectEmailsProps { emails?: string[]; onValueChange?: (emails: string[]) => void; - /** Allow display name format like "Name <email@example.com>" */ - allowDisplayName?: boolean; - /** Extract just the email from display name format */ - stripDisplayName?: boolean; - allowDuplicate?: boolean; - /** Custom regex delimiter for splitting pasted content. Defaults to /[,;\s]+/ */ - delimiter?: RegExp; + placeholder?: string; + inputProps?: React.InputHTMLAttributes<HTMLInputElement>; } -const defaultEmailDelimiter = /[,;\s]+/; - -/** Extracts the address from "Name <email@domain.com>", or returns the value as-is. */ -const extractEmail = (value: string, stripDisplayName: boolean): string => { - if (!stripDisplayName) return value.trim(); - const match = value.match(/<([^>]+)>/); - return match ? match[1].trim() : value.trim(); -}; +/** + * Email chip input. Enter, comma, semicolon, space, paste and blur turn the typed text into chips. An address that + * already is a chip in any letter case is skipped; text that is not an address stays in the input with a warning. + */ +export function SelectEmails({ emails = [], onValueChange, placeholder, inputProps }: SelectEmailsProps) { + const { t } = useTranslation(); + const inputRef = useRef<HTMLInputElement>(null); + const [text, setText] = useState(''); -export function SelectEmails({ - emails, - onValueChange, - allowDisplayName = false, - stripDisplayName = false, - allowDuplicate = false, - delimiter = defaultEmailDelimiter, - ...tagInputProps -}: SelectEmailsProps) { - const tags = emails ?? []; + const commit = (value: string) => { + const next = [...emails]; + const rejected: string[] = []; + for (const email of value.split(delimiter)) { + if (!email || next.some((chip) => chip.toLowerCase() === email.toLowerCase())) continue; + (isInviteEmail(email) ? next : rejected).push(email); + } - const validateEmail = (value: string): boolean => { - const email = extractEmail(value, stripDisplayName); - return isEmail(email, { allowDisplayName }); + if (rejected.length) toaster.warning(t('error:invalid_email'), { description: rejected.join(', ') }); + if (next.length > emails.length) onValueChange?.(next); + setText(rejected.join(' ')); }; - const handleSetTags: React.Dispatch<React.SetStateAction<string[]>> = (newTagsOrFn) => { - const newTags = typeof newTagsOrFn === 'function' ? newTagsOrFn(tags) : newTagsOrFn; - - let processedTags = stripDisplayName ? newTags.map((tag) => extractEmail(tag, true)) : newTags; + const remove = (email: string) => onValueChange?.(emails.filter((chip) => chip !== email)); - if (!allowDuplicate) { - const seen = new Set<string>(); - processedTags = processedTags.filter((email) => { - const lower = email.toLowerCase(); - if (seen.has(lower)) return false; - seen.add(lower); - return true; - }); + const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.key === 'Enter') { + // Also on an empty input, so Enter never submits the surrounding form. + event.preventDefault(); + if (text.trim()) commit(text); + } else if (event.key === 'Backspace' && !text && emails.length) { + event.preventDefault(); + remove(emails[emails.length - 1]); } - - onValueChange?.(processedTags); }; return ( - <TagInput - tags={tags} - setTags={handleSetTags} - validateTag={validateEmail} - delimiter={delimiter} - addOnPaste - addTagsOnBlur - {...tagInputProps} - /> + // biome-ignore lint/a11y/useKeyWithClickEvents: a click on the padding forwards focus to the input, which takes the keys + <div + onClick={(event) => event.target === event.currentTarget && inputRef.current?.focus()} + className="focus-effect flex flex-row flex-wrap items-center rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs ring-offset-background sm:focus-within:ring-2 sm:focus-within:ring-ring sm:focus-within:ring-offset-2" + > + {emails.length > 0 && ( + <div className="flex flex-row flex-wrap gap-1 rounded-md pr-1"> + {emails.map((email) => ( + <Badge key={email} className="gap-0.5 pr-0"> + {email} + <Button + type="button" + variant="ghost" + size="micro" + aria-label={t('c:remove_resource', { resource: email })} + onClick={() => remove(email)} + className="size-4.5 cursor-pointer rounded-full p-0 ring-inset sm:focus-visible:ring-2" + press={false} + > + <XIcon /> + </Button> + </Badge> + ))} + </div> + )} + <Input + ref={inputRef} + type="text" + placeholder={placeholder} + value={text} + {...inputProps} + onChange={({ target }) => (delimiter.test(target.value) ? commit(target.value) : setText(target.value))} + onKeyDown={onKeyDown} + onBlur={() => text.trim() && commit(text)} + className={cn( + '-my-px h-8 w-auto grow border-0 bg-transparent px-0 py-0 shadow-none focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0', + emails.length && 'ml-1', + )} + /> + </div> ); } diff --git a/frontend/src/modules/common/form-fields/select-language.tsx b/frontend/src/modules/common/form-fields/select-language.tsx index cb38e379e..ae895d6bf 100644 --- a/frontend/src/modules/common/form-fields/select-language.tsx +++ b/frontend/src/modules/common/form-fields/select-language.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; import { appConfig, type Language } from 'shared'; -import { ResponsiveSelect } from '~/modules/ui/responsive-select'; +import { ResponsiveSelect } from '~/modules/common/form-fields/responsive-select'; interface SelectLanguageProps { value: Language; @@ -11,10 +11,7 @@ interface SelectLanguageProps { export function SelectLanguage({ value, options, onChange }: SelectLanguageProps) { const { t } = useTranslation(); - const selectOptions = options.map((lang) => ({ - value: lang, - label: t(`c:${lang}`), - })); + const selectOptions = options.map((lang) => ({ value: lang, label: t(`c:${lang}`) })); return ( <ResponsiveSelect diff --git a/frontend/src/modules/common/form-fields/select-languages.tsx b/frontend/src/modules/common/form-fields/select-languages.tsx index bf690aa03..b00e5b91d 100644 --- a/frontend/src/modules/common/form-fields/select-languages.tsx +++ b/frontend/src/modules/common/form-fields/select-languages.tsx @@ -5,6 +5,7 @@ import { appConfig, type Language } from 'shared'; import { useMeasure } from '~/hooks/use-measure'; import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; import { Button } from '~/modules/ui/button'; +import { cn } from '~/utils/cn'; interface SelectLanguagesProps { value: Language[]; @@ -56,7 +57,7 @@ function SelectLanguagesContent({ initialValue, onChange, triggerWidth = 240 }: <div className="flex flex-nowrap items-center truncate"> <span className="truncate">{option.label}</span> </div> - <CheckIcon strokeWidth={3} className={`text-success ${!selected.includes(option.value) && 'invisible'}`} /> + <CheckIcon strokeWidth={3} className={cn('text-success', !selected.includes(option.value) && 'invisible')} /> </div> ))} </div> @@ -69,13 +70,11 @@ export function SelectLanguages({ value, onChange }: SelectLanguagesProps) { const { ref: triggerRef, bounds } = useMeasure<HTMLButtonElement>(); const openDropdown = () => { - useDropdowner - .getState() - .create(<SelectLanguagesContent initialValue={value} onChange={onChange} triggerWidth={bounds.width} />, { - id: 'select-languages', - triggerId: 'select-languages', - triggerRef, - }); + useDropdowner.getState().create(<SelectLanguagesContent initialValue={value} onChange={onChange} triggerWidth={bounds.width} />, { + id: 'select-languages', + triggerId: 'select-languages', + triggerRef, + }); }; return ( @@ -100,7 +99,7 @@ export function SelectLanguages({ value, onChange }: SelectLanguagesProps) { ) : ( <span className="text-muted-foreground">{t('c:placeholder.select_languages')}</span> )} - <ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" /> + <ChevronDownIcon className="size-4 shrink-0 opacity-50" /> </Button> ); } diff --git a/frontend/src/modules/common/form-fields/select-role-radio.tsx b/frontend/src/modules/common/form-fields/select-role-radio.tsx index 46e163dd9..338293ce2 100644 --- a/frontend/src/modules/common/form-fields/select-role-radio.tsx +++ b/frontend/src/modules/common/form-fields/select-role-radio.tsx @@ -1,3 +1,5 @@ +import { Fieldset } from '@base-ui/react/fieldset'; +import { type ReactNode, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { type ChannelEntityType, type EntityRole, hierarchy, roles } from 'shared'; import { RadioGroup, RadioGroupItem } from '~/modules/ui/radio-group'; @@ -8,29 +10,42 @@ interface Props { value?: EntityRole; /** Restrict options to this channel entity's role vocabulary (e.g. course → staff/student/guest). */ entityType?: ChannelEntityType; + /** Group name, rendered as the legend. A `FormLabel` would name every option after the group. */ + label?: ReactNode; className?: string; } -export function SelectRoleRadio({ onValueChange, value, entityType, className }: Props) { +export function SelectRoleRadio({ onValueChange, value, entityType, label, className }: Props) { const { t } = useTranslation(); + // Inside a form Field every radio would take the field's label; an explicit id per option keeps their own names. + const labelIdPrefix = useId(); const roleOptions = entityType ? hierarchy.getRoles(entityType) : roles.all; return ( - <RadioGroup - value={value} - onValueChange={(v) => onValueChange(v as EntityRole)} - className={cn('inline-flex items-center gap-4', className)} + <Fieldset.Root + render={ + <RadioGroup + // Null selects no role and keeps the group controlled: an undefined value would make it uncontrolled until a role is picked. + value={value ?? null} + onValueChange={(v) => onValueChange(v as EntityRole)} + className={cn('inline-flex items-center gap-4', className)} + /> + } > + {label && <Fieldset.Legend className="font-medium text-sm">{label}</Fieldset.Legend>} {roleOptions.map((role) => ( - // biome-ignore lint/a11y/noLabelWithoutControl: label is for visual grouping only, no input needed + // biome-ignore lint/a11y/noLabelWithoutControl: the Base UI radio renders the hidden input this label wraps <label key={role} className="inline-flex cursor-pointer items-center gap-2"> - <RadioGroupItem key={role} value={role} /> - <span className="font-normal text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> + <RadioGroupItem value={role} aria-labelledby={`${labelIdPrefix}-${role}`} className="peer" /> + <span + id={`${labelIdPrefix}-${role}`} + className="font-normal text-sm leading-none peer-data-disabled:cursor-not-allowed peer-data-disabled:opacity-70" + > {t(role)} </span> </label> ))} - </RadioGroup> + </Fieldset.Root> ); } diff --git a/frontend/src/modules/common/form-fields/select-role.tsx b/frontend/src/modules/common/form-fields/select-role.tsx index f39e411b5..bbe9001a0 100644 --- a/frontend/src/modules/common/form-fields/select-role.tsx +++ b/frontend/src/modules/common/form-fields/select-role.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { appConfig, type ChannelEntityType, hierarchy } from 'shared'; import { useOnlineManager } from '~/hooks/use-online-manager'; -import { ResponsiveSelect } from '~/modules/ui/responsive-select'; +import { ResponsiveSelect } from '~/modules/common/form-fields/responsive-select'; interface SelectRoleProps { /** Restrict options to this channel entity's role vocabulary; omit for system roles. */ @@ -18,13 +18,7 @@ export function SelectRole({ entityType, onChange, value, className }: SelectRol const roleOptions = entityType ? hierarchy.getRoles(entityType) : appConfig.systemRoles; - const options = [ - { value: 'all', label: t('c:all') }, - ...roleOptions.map((role) => ({ - value: role, - label: t(role), - })), - ]; + const options = [{ value: 'all', label: t('c:all') }, ...roleOptions.map((role) => ({ value: role, label: t(role) }))]; return ( <ResponsiveSelect diff --git a/frontend/src/modules/common/form-fields/select-roles.tsx b/frontend/src/modules/common/form-fields/select-roles.tsx index a5effcad2..ba0296725 100644 --- a/frontend/src/modules/common/form-fields/select-roles.tsx +++ b/frontend/src/modules/common/form-fields/select-roles.tsx @@ -1,3 +1,5 @@ +import { Fieldset } from '@base-ui/react/fieldset'; +import { type ReactNode, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { type EntityRole, roles } from 'shared'; import { Checkbox } from '~/modules/ui/checkbox'; @@ -6,13 +8,17 @@ import { cn } from '~/utils/cn'; interface SelectRoleProps { onValueChange: (value: EntityRole[]) => void; value?: EntityRole[]; + /** Group name, rendered as the legend. A `FormLabel` would name every option after the group. */ + label?: ReactNode; className?: string; } const EMPTY_ROLES: EntityRole[] = []; -export function SelectRoles({ onValueChange, value = EMPTY_ROLES, className }: SelectRoleProps) { +export function SelectRoles({ onValueChange, value = EMPTY_ROLES, label, className }: SelectRoleProps) { const { t } = useTranslation(); + // Inside a form Field every checkbox would take the field's label; an explicit id per option keeps their own names. + const labelIdPrefix = useId(); const handleCheckboxChange = (role: EntityRole) => { const newValue = value.includes(role) @@ -22,20 +28,27 @@ export function SelectRoles({ onValueChange, value = EMPTY_ROLES, className }: S }; return ( - <div className={cn('inline-flex items-center gap-2', className)}> - {roles.all.map((role) => ( - // biome-ignore lint/a11y/noLabelWithoutControl: label is for visual grouping only, no input needed - <label key={role} className="inline-flex cursor-pointer items-center gap-2"> - <Checkbox - checked={value.includes(role)} - onCheckedChange={() => handleCheckboxChange(role)} - className="size-5" - /> - <span className="font-normal text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> - {t(role)} - </span> - </label> - ))} - </div> + <Fieldset.Root className="flex flex-col gap-2"> + {label && <Fieldset.Legend className="font-medium text-sm">{label}</Fieldset.Legend>} + <div className={cn('inline-flex items-center gap-2', className)}> + {roles.all.map((role) => ( + // biome-ignore lint/a11y/noLabelWithoutControl: the Base UI checkbox renders the hidden input this label wraps + <label key={role} className="inline-flex cursor-pointer items-center gap-2"> + <Checkbox + checked={value.includes(role)} + onCheckedChange={() => handleCheckboxChange(role)} + aria-labelledby={`${labelIdPrefix}-${role}`} + className="size-5" + /> + <span + id={`${labelIdPrefix}-${role}`} + className="font-normal text-sm leading-none peer-data-disabled:cursor-not-allowed peer-data-disabled:opacity-70" + > + {t(role)} + </span> + </label> + ))} + </div> + </Fieldset.Root> ); } diff --git a/frontend/src/modules/common/form-fields/select-sort.tsx b/frontend/src/modules/common/form-fields/select-sort.tsx index 76add1fd3..7eade4185 100644 --- a/frontend/src/modules/common/form-fields/select-sort.tsx +++ b/frontend/src/modules/common/form-fields/select-sort.tsx @@ -5,11 +5,7 @@ import type { IconComponent } from '~/modules/common/icons/types'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/modules/ui/select'; import { cn } from '~/utils/cn'; -type SortOptionBase = { - name: TKey; - value: string; - icon: IconComponent; -}; +type SortOptionBase = { name: TKey; value: string; icon: IconComponent }; interface SelectSortProps<T extends readonly SortOptionBase[]> { sortOptions: T; @@ -19,21 +15,19 @@ interface SelectSortProps<T extends readonly SortOptionBase[]> { iconOnly?: boolean; } -export function SelectSort<T extends readonly SortOptionBase[]>({ - sortOptions, - onChange, - value, - className, - iconOnly = true, -}: SelectSortProps<T>) { +export function SelectSort<T extends readonly SortOptionBase[]>({ sortOptions, onChange, value, className, iconOnly = true }: SelectSortProps<T>) { const { t } = useTranslation(); const isOnline = useOnlineManager(); const selected = sortOptions.find((option) => option.value === value) ?? sortOptions[0]; return ( - <Select value={value} onValueChange={onChange}> - <SelectTrigger disabled={!isOnline} className={cn('w-auto', className)}> + <Select value={value} onValueChange={onChange} items={sortOptions.map((option) => ({ value: option.value, label: t(option.name) }))}> + <SelectTrigger + disabled={!isOnline} + aria-label={iconOnly ? `${t('c:sort')}: ${t(selected.name)}` : undefined} + className={cn('w-auto', className)} + > {iconOnly ? <selected.icon /> : <SelectValue />} </SelectTrigger> <SelectContent align="end" className="min-w-48"> diff --git a/frontend/src/modules/common/form-fields/slug.tsx b/frontend/src/modules/common/form-fields/slug.tsx index 2cbf85b0d..0cbd6bccf 100644 --- a/frontend/src/modules/common/form-fields/slug.tsx +++ b/frontend/src/modules/common/form-fields/slug.tsx @@ -54,8 +54,8 @@ export function SlugFormField<TFieldValues extends FieldValues>({ const inputClassName = cn({ 'ring-2 sm:focus-visible:ring-2': isSlugAvailable !== 'blank', - 'ring-green-500 focus-visible:ring-green-500': isSlugAvailable === 'available', - 'ring-red-500 focus-visible:ring-red-500': isSlugAvailable === 'notAvailable', + 'ring-success focus-visible:ring-success': isSlugAvailable === 'available', + 'ring-destructive focus-visible:ring-destructive': isSlugAvailable === 'notAvailable', }); const form = useFormContext<{ slug: string }>(); @@ -121,7 +121,7 @@ export function SlugFormField<TFieldValues extends FieldValues>({ {label} <span className="ml-1 opacity-50">*</span> </FormLabel> - <InputGroup className={cn('', inputClassName)}> + <InputGroup className={inputClassName}> <SlugInput type={entityType} onFocus={() => setDeviating(true)} value={formFieldValue || ''} {...rest} /> {prefix && ( <InputGroupAddon> @@ -133,14 +133,8 @@ export function SlugFormField<TFieldValues extends FieldValues>({ {previousSlug && previousSlug !== slug && ( <InputGroupAddon align="inline-end"> - <Button - variant="ghost" - size="sm" - aria-label={t('c:revert_handle')} - onClick={revertSlug} - className="h-full" - > - <UndoIcon /> <span className="ml-1 max-sm:hidden">{t('c:revert')}</span> + <Button variant="ghost" size="sm" aria-label={t('c:revert_handle')} onClick={revertSlug} className="h-full"> + <UndoIcon /> <span className="max-sm:hidden">{t('c:revert')}</span> </Button> </InputGroupAddon> )} diff --git a/frontend/src/modules/common/form-fields/submit-button.tsx b/frontend/src/modules/common/form-fields/submit-button.tsx new file mode 100644 index 000000000..82bd7e7ce --- /dev/null +++ b/frontend/src/modules/common/form-fields/submit-button.tsx @@ -0,0 +1,57 @@ +import { t } from 'i18next'; +import { LoaderCircleIcon, TriangleAlertIcon } from 'lucide-react'; +import type * as React from 'react'; +import { useOnlineManager } from '~/hooks/use-online-manager'; +import { toaster } from '~/modules/common/toaster/toaster'; +import { TooltipButton } from '~/modules/common/tooltip-button'; +import { Button, type ButtonProps } from '~/modules/ui/button'; + +type SubmitButtonProps = Omit<ButtonProps, 'type'> & { allowOfflineDelete?: boolean; icon?: React.ReactNode }; + +/** Form submit button that warns when offline; `icon` swaps to a spinner on loading, otherwise the spinner overlays the button. */ +export function SubmitButton({ onClick, children, allowOfflineDelete = false, loading, disabled, icon, className, ...props }: SubmitButtonProps) { + const isOnline = useOnlineManager(); + + const isDisabled = disabled || loading; + const showOfflineWarning = !allowOfflineDelete && !isOnline; + + const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => { + if (isDisabled) { + e.preventDefault(); + return; + } + if (showOfflineWarning) { + e.preventDefault(); + return toaster.warning(t('c:action.offline.text')); + } + onClick?.(e); + }; + + const resolvedIcon = loading ? <LoaderCircleIcon className="animate-spin" /> : showOfflineWarning ? <TriangleAlertIcon /> : icon; + + const buttonContent = ( + <Button + type="submit" + onClick={handleClick} + disabled={isDisabled} + aria-busy={loading || undefined} + loading={!icon && loading} + className={className} + {...props} + > + {icon ? ( + <> + {resolvedIcon} + {children} + </> + ) : ( + <> + {showOfflineWarning && <TriangleAlertIcon />} + {children} + </> + )} + </Button> + ); + + return showOfflineWarning ? <TooltipButton toolTipContent={t('c:offline.text_with_info')}>{buttonContent}</TooltipButton> : buttonContent; +} diff --git a/frontend/src/modules/common/form-fields/type.ts b/frontend/src/modules/common/form-fields/type.ts index f9e23f594..055e3d2cd 100644 --- a/frontend/src/modules/common/form-fields/type.ts +++ b/frontend/src/modules/common/form-fields/type.ts @@ -4,13 +4,4 @@ export type BaseFormFieldProps<TFieldValues extends FieldValues> = { control: Control<TFieldValues>; name: Path<TFieldValues>; disabled?: boolean; -} & ( - | { - label: string; - required?: boolean; - } - | { - label?: never; - required?: never; - } -); +} & ({ label: string; required?: boolean } | { label?: never; required?: never }); diff --git a/frontend/src/modules/common/gleap-support.tsx b/frontend/src/modules/common/gleap-support.tsx index 597ab2976..005a13442 100644 --- a/frontend/src/modules/common/gleap-support.tsx +++ b/frontend/src/modules/common/gleap-support.tsx @@ -35,11 +35,7 @@ function setGleapUser(user: User) { if (window.Gleap.isUserIdentified()) { window.Gleap.updateContact({ email: user.email, name: user.name || user.email }); } else { - window.Gleap.identify(user.id, { - email: user.email, - name: user.name || user.email, - createdAt: new Date(user.createdAt), - }); + window.Gleap.identify(user.id, { email: user.email, name: user.name || user.email, createdAt: new Date(user.createdAt) }); } } diff --git a/frontend/src/modules/common/help-text.tsx b/frontend/src/modules/common/help-text.tsx index ba464c1cd..7ae391ec2 100644 --- a/frontend/src/modules/common/help-text.tsx +++ b/frontend/src/modules/common/help-text.tsx @@ -19,23 +19,10 @@ export function HelpText({ content, children, className, type }: HelpTextProps) <div className={cn('mb-4 flex items-center gap-2', className)}> {children} <Popover> - <PopoverTrigger - render={ - <Button - variant="ghost" - size="icon" - className="size-6 opacity-50 hover:opacity-100 active:translate-y-0!" - /> - } - > + <PopoverTrigger render={<Button variant="ghost" size="icon" press={false} className="size-6 opacity-50 hover:opacity-100" />}> <CircleQuestionMarkIcon /> </PopoverTrigger> - <PopoverContent - className="w-80 max-w-full text-muted-foreground text-sm" - align="start" - side="top" - collisionPadding={8} - > + <PopoverContent className="w-80 max-w-full text-muted-foreground text-sm" align="start" side="top" collisionPadding={8}> {content} </PopoverContent> </Popover> @@ -52,7 +39,8 @@ export function HelpText({ content, children, className, type }: HelpTextProps) variant="ghost" size="icon" onClick={() => setCollapsed(!collapsed)} - className="size-6 opacity-50 hover:opacity-100 active:translate-y-0!" + press={false} + className="size-6 opacity-50 hover:opacity-100" > {collapsed && <CircleQuestionMarkIcon />} {!collapsed && <ChevronUpIcon />} diff --git a/frontend/src/modules/common/icon-picker/icon-picker.tsx b/frontend/src/modules/common/icon-picker/icon-picker.tsx index 3bef59159..a4fbd1e31 100644 --- a/frontend/src/modules/common/icon-picker/icon-picker.tsx +++ b/frontend/src/modules/common/icon-picker/icon-picker.tsx @@ -46,18 +46,13 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) { aria-selected={name === value} title={name} onClick={() => onChange(name)} - className={cn( - 'flex items-center justify-center rounded-md p-1.5 hover:bg-accent', - name === value && 'bg-accent ring-1 ring-ring', - )} + className={cn('flex items-center justify-center rounded-md p-1.5 hover:bg-accent', name === value && 'bg-accent ring-1 ring-ring')} > - <SpriteIcon name={name} className="icon-lg" /> + <SpriteIcon name={name} className="size-5" /> </button> ))} </div> - {matches.length > maxVisible && ( - <span className="px-1 text-muted-foreground text-xs">{`${matches.length - maxVisible}+`}</span> - )} + {matches.length > maxVisible && <span className="px-1 text-muted-foreground text-xs">{`${matches.length - maxVisible}+`}</span>} </div> ); } diff --git a/frontend/src/modules/common/icons/element.tsx b/frontend/src/modules/common/icons/element.tsx index d04f1024c..495df1520 100644 --- a/frontend/src/modules/common/icons/element.tsx +++ b/frontend/src/modules/common/icons/element.tsx @@ -3,14 +3,7 @@ import { cn } from '~/utils/cn'; export function ElementIcon({ className, size: _, strokeWidth: __, absoluteStrokeWidth: ___, ...props }: LucideProps) { return ( - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - role="img" - aria-label="Element" - className={cn('lucide', className)} - {...props} - > + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="Element" className={cn('lucide', className)} {...props}> <path fill="currentColor" d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12s12-5.373 12-12S18.627 0 12 0m-1.314 4.715c3.289 0 5.956 2.66 5.956 5.943a.879.879 0 0 1-1.758 0a4.194 4.194 0 0 0-4.198-4.189a.878.878 0 1 1 0-1.754m-5.092 9.504a.88.88 0 0 1-.879-.877a5.95 5.95 0 0 1 5.956-5.945a.878.878 0 1 1 0 1.753a4.195 4.195 0 0 0-4.198 4.191a.88.88 0 0 1-.879.878m7.735 5.067c-3.29 0-5.957-2.662-5.957-5.944a.88.88 0 0 1 1.758 0a4.194 4.194 0 0 0 4.199 4.189a.879.879 0 1 1 0 1.755m0-2.683a.88.88 0 0 1-.88-.876a.88.88 0 0 1 .88-.878a4.195 4.195 0 0 0 4.199-4.19a.878.878 0 0 1 1.758 0c0 3.282-2.667 5.944-5.957 5.944" diff --git a/frontend/src/modules/common/icons/github.tsx b/frontend/src/modules/common/icons/github.tsx index 6a7d171a5..72d8e5e21 100644 --- a/frontend/src/modules/common/icons/github.tsx +++ b/frontend/src/modules/common/icons/github.tsx @@ -3,14 +3,7 @@ import { cn } from '~/utils/cn'; export function GithubIcon({ className, size: _, strokeWidth: __, absoluteStrokeWidth: ___, ...props }: LucideProps) { return ( - <svg - viewBox="0 0 98 96" - xmlns="http://www.w3.org/2000/svg" - fill="currentColor" - aria-hidden="true" - className={cn('lucide', className)} - {...props} - > + <svg viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" className={cn('lucide', className)} {...props}> <path fillRule="evenodd" clipRule="evenodd" diff --git a/frontend/src/modules/common/icons/lucide-icons.gen.json b/frontend/src/modules/common/icons/lucide-icons.gen.json index 9b893f96c..f77a91321 100644 --- a/frontend/src/modules/common/icons/lucide-icons.gen.json +++ b/frontend/src/modules/common/icons/lucide-icons.gen.json @@ -32,19 +32,7 @@ "synthesiser", "music" ], - "ad": [ - "advert", - "affiliate", - "brand", - "campaign", - "commercial", - "marketing", - "monetize", - "paid", - "partner", - "promo", - "sponsor" - ], + "ad": ["advert", "affiliate", "brand", "campaign", "commercial", "marketing", "monetize", "paid", "partner", "promo", "sponsor"], "air-vent": ["air conditioner", "ac", "central air", "cooling", "climate-control"], "airplay": ["stream", "cast", "mirroring", "screen", "monitor", "macos", "osx"], "alarm-clock": ["morning"], @@ -76,18 +64,7 @@ "align-vertical-justify-start": ["top", "items", "flex", "justify", "distribute", "between"], "align-vertical-space-around": ["center", "items", "flex", "justify", "distribute", "between"], "align-vertical-space-between": ["center", "items", "flex", "justify", "distribute", "between"], - "ambulance": [ - "ambulance", - "emergency", - "medical", - "vehicle", - "siren", - "healthcare", - "transportation", - "rescue", - "urgent", - "first aid" - ], + "ambulance": ["ambulance", "emergency", "medical", "vehicle", "siren", "healthcare", "transportation", "rescue", "urgent", "first aid"], "ampersand": ["and", "typography", "operator", "join", "concatenate", "code", "&"], "ampersands": ["and", "operator", "then", "code", "&&"], "amphora": ["pottery", "artifact", "artefact", "vase", "ceramics", "clay", "archaeology", "museum", "wine", "oil"], @@ -153,80 +130,17 @@ "arrow-big-right": ["next", "forward", "direction", "east", "indicate turn"], "arrow-big-right-dash": ["next", "forward", "direction", "east", "turn", "corner"], "arrow-big-up": ["shift", "keyboard", "button", "mac", "capitalize", "capitalise", "forward", "direction", "north"], - "arrow-big-up-dash": [ - "caps lock", - "capitals", - "keyboard", - "button", - "mac", - "forward", - "direction", - "north", - "faster", - "speed", - "boost" - ], + "arrow-big-up-dash": ["caps lock", "capitals", "keyboard", "button", "mac", "forward", "direction", "north", "faster", "speed", "boost"], "arrow-down": ["backwards", "reverse", "direction", "south"], - "arrow-down-0-1": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "numerical" - ], - "arrow-down-1-0": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "numerical" - ], - "arrow-down-a-z": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "alphabetical" - ], + "arrow-down-0-1": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "numerical"], + "arrow-down-1-0": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "numerical"], + "arrow-down-a-z": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "alphabetical"], "arrow-down-from-line": ["backwards", "reverse", "direction", "south", "download", "expand", "fold", "vertical"], "arrow-down-left": ["direction", "south-west", "diagonal"], - "arrow-down-narrow-wide": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling" - ], + "arrow-down-narrow-wide": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling"], "arrow-down-right": ["direction", "south-east", "diagonal"], "arrow-down-to-dot": ["direction", "south", "waypoint", "location", "step", "into"], - "arrow-down-to-line": [ - "behind", - "direction", - "south", - "download", - "save", - "git", - "version control", - "pull", - "collapse", - "fold", - "vertical" - ], + "arrow-down-to-line": ["behind", "direction", "south", "download", "save", "git", "version control", "pull", "collapse", "fold", "vertical"], "arrow-down-up": [ "bidirectional", "two-way", @@ -242,189 +156,32 @@ "reorder", "move" ], - "arrow-down-wide-narrow": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling" - ], - "arrow-down-z-a": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "alphabetical", - "reverse" - ], + "arrow-down-wide-narrow": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling"], + "arrow-down-z-a": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "alphabetical", "reverse"], "arrow-left": ["previous", "back", "direction", "west", "<-"], "arrow-left-from-line": ["previous", "back", "direction", "west", "expand", "fold", "horizontal", "<-|"], - "arrow-left-right": [ - "bidirectional", - "two-way", - "2-way", - "swap", - "switch", - "transaction", - "reorder", - "move", - "<-", - "->" - ], + "arrow-left-right": ["bidirectional", "two-way", "2-way", "swap", "switch", "transaction", "reorder", "move", "<-", "->"], "arrow-left-to-line": ["previous", "back", "direction", "west", "collapse", "fold", "horizontal", "|<-"], "arrow-right": ["forward", "next", "direction", "east", "->"], "arrow-right-from-line": ["next", "forward", "direction", "east", "export", "expand", "fold", "horizontal", "|->"], - "arrow-right-left": [ - "bidirectional", - "two-way", - "2-way", - "swap", - "switch", - "transaction", - "reorder", - "move", - "<-", - "->" - ], - "arrow-right-to-line": [ - "next", - "forward", - "direction", - "east", - "tab", - "keyboard", - "mac", - "indent", - "collapse", - "fold", - "horizontal", - "->|" - ], + "arrow-right-left": ["bidirectional", "two-way", "2-way", "swap", "switch", "transaction", "reorder", "move", "<-", "->"], + "arrow-right-to-line": ["next", "forward", "direction", "east", "tab", "keyboard", "mac", "indent", "collapse", "fold", "horizontal", "->|"], "arrow-up": ["forward", "direction", "north"], - "arrow-up-0-1": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "numerical" - ], - "arrow-up-1-0": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "numerical" - ], - "arrow-up-a-z": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "alphabetical" - ], - "arrow-up-down": [ - "bidirectional", - "two-way", - "2-way", - "swap", - "switch", - "network", - "mobile data", - "internet", - "sort", - "reorder", - "move" - ], + "arrow-up-0-1": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "numerical"], + "arrow-up-1-0": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "numerical"], + "arrow-up-a-z": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "alphabetical"], + "arrow-up-down": ["bidirectional", "two-way", "2-way", "swap", "switch", "network", "mobile data", "internet", "sort", "reorder", "move"], "arrow-up-from-dot": ["direction", "north", "step", "out"], - "arrow-up-from-line": [ - "forward", - "direction", - "north", - "upload", - "git", - "version control", - "push", - "expand", - "fold", - "vertical" - ], + "arrow-up-from-line": ["forward", "direction", "north", "upload", "git", "version control", "push", "expand", "fold", "vertical"], "arrow-up-left": ["direction", "north-west", "diagonal"], - "arrow-up-narrow-wide": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling" - ], + "arrow-up-narrow-wide": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling"], "arrow-up-right": ["direction", "north-east", "diagonal"], "arrow-up-to-line": ["forward", "direction", "north", "upload", "collapse", "fold", "vertical"], - "arrow-up-wide-narrow": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling" - ], - "arrow-up-z-a": [ - "filter", - "sort", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling", - "alphabetical", - "reverse" - ], - "arrows-up-from-line": [ - "direction", - "orientation", - "this way up", - "vertical", - "package", - "box", - "fragile", - "postage", - "shipping" - ], + "arrow-up-wide-narrow": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling"], + "arrow-up-z-a": ["filter", "sort", "ascending", "descending", "increasing", "decreasing", "rising", "falling", "alphabetical", "reverse"], + "arrows-up-from-line": ["direction", "orientation", "this way up", "vertical", "package", "box", "fragile", "postage", "shipping"], "asterisk": ["reference", "times", "multiply", "multiplication", "operator", "code", "glob pattern", "wildcard", "*"], - "astroid": [ - "star", - "math", - "shape", - "curve", - "sharp", - "four-pointed", - "hypocycloid", - "ai", - "artificial intelligence" - ], + "astroid": ["star", "math", "shape", "curve", "sharp", "four-pointed", "hypocycloid", "ai", "artificial intelligence"], "at-sign": ["mention", "at", "email", "message", "@"], "atom": ["atomic", "nuclear", "physics", "particle", "element", "molecule", "electricity", "energy", "chemistry"], "audio-lines": [ @@ -477,20 +234,7 @@ "song" ], "award": ["achievement", "badge", "rosette", "prize", "winner"], - "axe": [ - "hatchet", - "weapon", - "chop", - "sharp", - "equipment", - "fireman", - "firefighter", - "brigade", - "lumberjack", - "woodcutter", - "logger", - "forestry" - ], + "axe": ["hatchet", "weapon", "chop", "sharp", "equipment", "fireman", "firefighter", "brigade", "lumberjack", "woodcutter", "logger", "forestry"], "axis-3d": ["gizmo", "coordinates"], "baby": ["child", "childproof", "children"], "backpack": ["bag", "hiking", "travel", "camping", "school", "childhood"], @@ -664,22 +408,7 @@ "event", "entertainment" ], - "ban": [ - "cancel", - "no", - "stop", - "forbidden", - "prohibited", - "error", - "incorrect", - "mistake", - "wrong", - "failure", - "circle", - "slash", - "null", - "void" - ], + "ban": ["cancel", "no", "stop", "forbidden", "prohibited", "error", "incorrect", "mistake", "wrong", "failure", "circle", "slash", "null", "void"], "banana": ["fruit", "food"], "bandage": [ "plaster", @@ -773,19 +502,7 @@ "delete", "remove" ], - "barcode": [ - "scan", - "checkout", - "till", - "cart", - "transaction", - "purchase", - "buy", - "product", - "packaging", - "retail", - "consumer" - ], + "barcode": ["scan", "checkout", "till", "cart", "transaction", "purchase", "buy", "product", "packaging", "retail", "consumer"], "barrel": [ "keg", "drum", @@ -1040,40 +757,13 @@ "restaurant" ], "blinds": ["shades", "screen", "curtain", "shutter", "roller blind", "window", "lighting", "household", "home"], - "blocks": [ - "addon", - "plugin", - "integration", - "extension", - "package", - "build", - "stack", - "toys", - "kids", - "children", - "learning", - "squares", - "corner" - ], + "blocks": ["addon", "plugin", "integration", "extension", "package", "build", "stack", "toys", "kids", "children", "learning", "squares", "corner"], "bluetooth": ["wireless"], "bluetooth-connected": ["paired"], "bluetooth-off": ["lost"], "bluetooth-searching": ["pairing"], "bold": ["text", "strong", "format"], - "bolt": [ - "nut", - "screw", - "settings", - "preferences", - "configuration", - "controls", - "edit", - "diy", - "fixed", - "build", - "construction", - "parts" - ], + "bolt": ["nut", "screw", "settings", "preferences", "configuration", "controls", "edit", "diy", "fixed", "build", "construction", "parts"], "bomb": ["fatal", "error", "crash", "blockbuster", "mine", "explosion", "explode", "explosive"], "bone": ["health", "skeleton", "skull", "death", "pets", "dog"], "bone-fracture": [ @@ -1360,20 +1050,7 @@ "favourite", "high school" ], - "book-image": [ - "images", - "pictures", - "photos", - "album", - "collection", - "event", - "magazine", - "catalog", - "catalogue", - "brochure", - "browse", - "gallery" - ], + "book-image": ["images", "pictures", "photos", "album", "collection", "event", "magazine", "catalog", "catalogue", "brochure", "browse", "gallery"], "book-key": [ "code", "coding", @@ -1577,19 +1254,7 @@ "upgrade", "level up" ], - "book-search": [ - "reading", - "library", - "study", - "education", - "research", - "knowledge", - "discover", - "browsing", - "lookup", - "finding", - "scanning" - ], + "book-search": ["reading", "library", "study", "education", "research", "knowledge", "discover", "browsing", "lookup", "finding", "scanning"], "book-text": [ "reading", "booklet", @@ -1699,35 +1364,10 @@ "re-education", "unlearn" ], - "bookmark": [ - "save", - "favorite", - "mark", - "label", - "attachment", - "file", - "stick", - "pin", - "read", - "clip", - "marker", - "tag" - ], + "bookmark": ["save", "favorite", "mark", "label", "attachment", "file", "stick", "pin", "read", "clip", "marker", "tag"], "bookmark-check": ["read", "finished", "complete", "clip", "marker", "tag", "task", "todo"], "bookmark-minus": ["delete", "remove"], - "bookmark-off": [ - "unsaved", - "unfavorite", - "unmarked", - "unlabel", - "disabled", - "removed", - "unpin", - "unread", - "unclip", - "marker", - "untag" - ], + "bookmark-off": ["unsaved", "unfavorite", "unmarked", "unlabel", "disabled", "removed", "unpin", "unread", "unclip", "marker", "untag"], "bookmark-plus": ["add"], "bookmark-x": ["read", "clip", "marker", "tag", "cancel", "close", "delete", "remove", "clear"], "boom-box": ["radio", "speakers", "audio", "music", "sound", "broadcast", "live", "frequency"], @@ -1777,28 +1417,9 @@ "intelligent", "smart" ], - "brain-circuit": [ - "mind", - "intellect", - "artificial intelligence", - "ai", - "deep learning", - "machine learning", - "computing" - ], + "brain-circuit": ["mind", "intellect", "artificial intelligence", "ai", "deep learning", "machine learning", "computing"], "brain-cog": ["mind", "intellect", "artificial intelligence", "ai", "deep learning", "machine learning", "computing"], - "brick-wall": [ - "bricks", - "mortar", - "cement", - "materials", - "construction", - "builder", - "labourer", - "quantity surveyor", - "blocks", - "stone" - ], + "brick-wall": ["bricks", "mortar", "cement", "materials", "construction", "builder", "labourer", "quantity surveyor", "blocks", "stone"], "brick-wall-fire": [ "firewall", "security", @@ -1956,31 +1577,8 @@ "floating", "drop" ], - "bug": [ - "issue", - "error", - "defect", - "testing", - "troubleshoot", - "problem", - "report", - "debug", - "code", - "insect", - "beetle" - ], - "bug-off": [ - "issue", - "fixed", - "resolved", - "testing", - "debug", - "code", - "insect", - "kill", - "exterminate", - "pest control" - ], + "bug": ["issue", "error", "defect", "testing", "troubleshoot", "problem", "report", "debug", "code", "insect", "beetle"], + "bug-off": ["issue", "fixed", "resolved", "testing", "debug", "code", "insect", "kill", "exterminate", "pest control"], "bug-play": ["issue", "testing", "debug", "reproduce", "code", "insect"], "building": ["organisation", "organization"], "building-2": ["business", "company", "enterprise", "skyscraper", "organisation", "organization", "city"], @@ -2018,19 +1616,7 @@ "optical" ], "cable-car": ["ski lift", "winter holiday", "alpine", "resort", "mountains"], - "cake": [ - "birthday", - "birthdate", - "celebration", - "party", - "surprise", - "gateaux", - "dessert", - "fondant", - "icing sugar", - "sweet", - "baking" - ], + "cake": ["birthday", "birthdate", "celebration", "party", "surprise", "gateaux", "dessert", "fondant", "icing sugar", "sweet", "baking"], "cake-slice": [ "birthday", "birthdate", @@ -2077,36 +1663,8 @@ "rising", "falling" ], - "calendar-check": [ - "date", - "day", - "month", - "year", - "event", - "confirm", - "subscribe", - "schedule", - "done", - "todo", - "tick", - "complete", - "task" - ], - "calendar-check-2": [ - "date", - "day", - "month", - "year", - "event", - "confirm", - "subscribe", - "schedule", - "done", - "todo", - "tick", - "complete", - "task" - ], + "calendar-check": ["date", "day", "month", "year", "event", "confirm", "subscribe", "schedule", "done", "todo", "tick", "complete", "task"], + "calendar-check-2": ["date", "day", "month", "year", "event", "confirm", "subscribe", "schedule", "done", "todo", "tick", "complete", "task"], "calendar-clock": ["date", "day", "month", "year", "event", "clock", "hour"], "calendar-cog": ["date", "day", "month", "year", "events", "settings", "gear", "cog"], "calendar-days": ["date", "month", "year", "event"], @@ -2139,20 +1697,7 @@ "calendar-x": ["date", "day", "month", "year", "event", "remove", "busy"], "calendar-x-2": ["date", "day", "month", "year", "event", "remove"], "calendars": ["date", "month", "year", "event", "dates", "months", "years", "events"], - "camera": [ - "photography", - "lens", - "focus", - "capture", - "shot", - "visual", - "image", - "device", - "equipment", - "photo", - "webcam", - "video" - ], + "camera": ["photography", "lens", "focus", "capture", "shot", "visual", "image", "device", "equipment", "photo", "webcam", "video"], "camera-off": ["photo", "webcam", "video"], "candy": ["sugar", "food", "sweet"], "candy-cane": ["sugar", "food", "sweet", "christmas", "xmas"], @@ -2160,15 +1705,7 @@ "cannabis": ["cannabis", "weed", "leaf"], "cannabis-off": ["cannabis", "weed", "leaf"], "captions": ["closed captions", "subtitles", "subhead", "transcription", "transcribe", "dialogue", "accessibility"], - "captions-off": [ - "closed captions", - "subtitles", - "subhead", - "transcription", - "transcribe", - "dialogue", - "accessibility" - ], + "captions-off": ["closed captions", "subtitles", "subhead", "transcription", "transcribe", "dialogue", "accessibility"], "car": ["vehicle", "drive", "trip", "journey"], "car-front": ["vehicle", "drive", "trip", "journey"], "car-taxi-front": ["cab", "vehicle", "drive", "trip", "journey"], @@ -2195,19 +1732,7 @@ "circuit board", "chip" ], - "carrot": [ - "orange", - "healthy", - "nature", - "fresh", - "root", - "produce", - "organic", - "nutrition", - "vegetable", - "food", - "eat" - ], + "carrot": ["orange", "healthy", "nature", "fresh", "root", "produce", "organic", "nutrition", "vegetable", "food", "eat"], "case-lower": ["text", "letters", "characters", "font", "typography"], "case-sensitive": ["text", "letters", "characters", "font", "typography"], "case-upper": ["text", "letters", "characters", "font", "typography"], @@ -2251,18 +1776,7 @@ "chart-column-stacked": ["statistics", "analytics", "diagram", "graph", "multivariate", "categorical", "comparison"], "chart-gantt": ["diagram", "graph", "timeline", "planning"], "chart-line": ["statistics", "analytics", "diagram", "graph"], - "chart-network": [ - "statistics", - "analytics", - "diagram", - "graph", - "topology", - "cluster", - "web", - "nodes", - "connections", - "edges" - ], + "chart-network": ["statistics", "analytics", "diagram", "graph", "topology", "cluster", "web", "nodes", "connections", "edges"], "chart-no-axes-column": ["statistics", "analytics", "diagram", "graph"], "chart-no-axes-column-decreasing": ["statistics", "analytics", "diagram", "graph", "trending down"], "chart-no-axes-column-increasing": ["statistics", "analytics", "diagram", "graph", "trending up"], @@ -2316,34 +1830,8 @@ "chevron-first": ["previous", "music"], "chevron-last": ["skip", "next", "music"], "chevron-left": ["back", "previous", "less than", "fewer", "menu", "<"], - "chevron-right": [ - "forward", - "next", - "more than", - "greater", - "menu", - "code", - "coding", - "command line", - "terminal", - "prompt", - "shell", - ">" - ], - "chevron-up": [ - "caret", - "keyboard", - "mac", - "control", - "ctrl", - "superscript", - "exponential", - "power", - "ahead", - "fast", - "^", - "dropdown" - ], + "chevron-right": ["forward", "next", "more than", "greater", "menu", "code", "coding", "command line", "terminal", "prompt", "shell", ">"], + "chevron-up": ["caret", "keyboard", "mac", "control", "ctrl", "superscript", "exponential", "power", "ahead", "fast", "^", "dropdown"], "chevrons-down": ["backwards", "reverse", "slower"], "chevrons-down-up": ["collapse", "fold", "vertical"], "chevrons-left": ["turn", "corner"], @@ -2401,18 +1889,7 @@ "circle-dashed": ["pending", "dot", "progress", "issue", "draft", "code", "coding", "version control"], "circle-divide": ["calculate", "math", "÷", "/"], "circle-dollar-sign": ["monetization", "marketing", "currency", "money", "payment"], - "circle-dot": [ - "pending", - "dot", - "progress", - "issue", - "code", - "coding", - "version control", - "choices", - "multiple choice", - "choose" - ], + "circle-dot": ["pending", "dot", "progress", "issue", "code", "coding", "version control", "choices", "multiple choice", "choose"], "circle-dot-dashed": ["pending", "dot", "progress", "issue", "draft", "code", "coding", "version control"], "circle-ellipsis": [ "ellipsis", @@ -2438,38 +1915,11 @@ "..." ], "circle-equal": ["calculate", "shape", "="], - "circle-euro": [ - "symbol", - "economy", - "banking", - "europe", - "€", - "euro", - "currency", - "money", - "payment", - "coin", - "finance", - "financial", - "exchange" - ], + "circle-euro": ["symbol", "economy", "banking", "europe", "€", "euro", "currency", "money", "payment", "coin", "finance", "financial", "exchange"], "circle-fading-arrow-up": ["north", "up", "upgrade", "improve", "circle", "button"], "circle-fading-plus": ["stories", "social media", "instagram", "facebook", "meta", "snapchat", "sharing", "content"], "circle-gauge": ["dashboard", "dial", "meter", "speed", "pressure", "measure", "level"], - "circle-minus": [ - "subtract", - "remove", - "decrease", - "reduce", - "calculate", - "line", - "operator", - "code", - "coding", - "minimum", - "downgrade", - "-" - ], + "circle-minus": ["subtract", "remove", "decrease", "reduce", "calculate", "line", "operator", "code", "coding", "minimum", "downgrade", "-"], "circle-off": [ "diameter", "zero", @@ -2536,32 +1986,8 @@ "coding", "+" ], - "circle-pound-sterling": [ - "monetization", - "coin", - "penny", - "marketing", - "currency", - "money", - "payment", - "british", - "gbp", - "£" - ], - "circle-power": [ - "on", - "off", - "device", - "switch", - "toggle", - "binary", - "boolean", - "reboot", - "restart", - "button", - "keyboard", - "troubleshoot" - ], + "circle-pound-sterling": ["monetization", "coin", "penny", "marketing", "currency", "money", "payment", "british", "gbp", "£"], + "circle-power": ["on", "off", "device", "switch", "toggle", "binary", "boolean", "reboot", "restart", "button", "keyboard", "troubleshoot"], "circle-question-mark": ["question mark"], "circle-slash": [ "diameter", @@ -2607,21 +2033,7 @@ "normal" ], "circle-small": ["shape", "bullet", "gender", "genderless"], - "circle-star": [ - "badge", - "medal", - "honour", - "decoration", - "order", - "pin", - "laurel", - "trophy", - "medallion", - "insignia", - "bronze", - "silver", - "gold" - ], + "circle-star": ["badge", "medal", "honour", "decoration", "order", "pin", "laurel", "trophy", "medallion", "insignia", "bronze", "silver", "gold"], "circle-stop": ["media", "music"], "circle-user": ["person", "account", "contact"], "circle-user-round": ["person", "account", "contact"], @@ -2643,19 +2055,7 @@ ], "circuit-board": ["computing", "electricity", "electronics"], "citrus": ["lemon", "orange", "grapefruit", "fruit"], - "clapperboard": [ - "movie", - "film", - "video", - "camera", - "cinema", - "cut", - "action", - "television", - "tv", - "show", - "entertainment" - ], + "clapperboard": ["movie", "film", "video", "camera", "cinema", "cut", "action", "television", "tv", "show", "entertainment"], "clipboard": ["copy", "paste"], "clipboard-check": ["copied", "pasted", "done", "todo", "tick", "complete", "task"], "clipboard-clock": [ @@ -2701,49 +2101,14 @@ "clock-8": ["time", "watch", "alarm"], "clock-9": ["time", "watch", "alarm"], "clock-alert": ["time", "watch", "alarm", "warning", "wrong"], - "clock-arrow-down": [ - "time", - "watch", - "alarm", - "sort", - "order", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling" - ], + "clock-arrow-down": ["time", "watch", "alarm", "sort", "order", "ascending", "descending", "increasing", "decreasing", "rising", "falling"], "clock-arrow-left": ["time", "watch", "alarm", "assign", "range"], "clock-arrow-right": ["time", "watch", "alarm", "range", "unassign"], - "clock-arrow-up": [ - "time", - "watch", - "alarm", - "sort", - "order", - "ascending", - "descending", - "increasing", - "decreasing", - "rising", - "falling" - ], + "clock-arrow-up": ["time", "watch", "alarm", "sort", "order", "ascending", "descending", "increasing", "decreasing", "rising", "falling"], "clock-check": ["time", "watch", "alarm"], "clock-fading": ["time", "watch", "alarm"], "clock-plus": ["time", "watch", "alarm", "add", "create", "new"], - "closed-caption": [ - "tv", - "movie", - "video", - "closed captions", - "subtitles", - "subhead", - "transcription", - "transcribe", - "dialogue", - "accessibility" - ], + "closed-caption": ["tv", "movie", "video", "closed captions", "subtitles", "subhead", "transcription", "transcribe", "dialogue", "accessibility"], "cloud": ["weather"], "cloud-alert": ["weather", "danger", "warning", "alert", "error", "sync", "network", "exclamation"], "cloud-backup": [ @@ -2802,20 +2167,7 @@ "code": ["source", "programming", "html", "xml"], "code-xml": ["source", "programming", "html", "xml"], "coffee": ["drink", "cup", "mug", "tea", "cafe", "hot", "beverage"], - "cog": [ - "computing", - "settings", - "cog", - "edit", - "gear", - "preferences", - "controls", - "configuration", - "fixed", - "build", - "construction", - "parts" - ], + "cog": ["computing", "settings", "cog", "edit", "gear", "preferences", "controls", "configuration", "fixed", "build", "construction", "parts"], "coins": ["money", "cash", "finance", "gamble"], "columns-2": [ "lines", @@ -2918,20 +2270,7 @@ "container": ["storage", "shipping", "freight", "supply chain", "docker", "environment", "devops", "code", "coding"], "contrast": ["display", "accessibility"], "cookie": ["biscuit", "privacy", "legal", "food"], - "cooking-pot": [ - "pod", - "cooking", - "recipe", - "food", - "kitchen", - "chef", - "restaurant", - "dinner", - "lunch", - "breakfast", - "meal", - "eat" - ], + "cooking-pot": ["pod", "cooking", "recipe", "food", "kitchen", "chef", "restaurant", "dinner", "lunch", "breakfast", "meal", "eat"], "copy": ["clone", "duplicate", "multiple"], "copy-check": ["clone", "duplicate", "done", "multiple"], "copy-minus": ["clone", "duplicate", "remove", "delete", "collapse", "subtract", "multiple", "-"], @@ -2964,19 +2303,7 @@ "corner-right-up": ["arrow"], "corner-up-left": ["arrow"], "corner-up-right": ["arrow"], - "cpu": [ - "processor", - "cores", - "technology", - "computer", - "chip", - "circuit", - "memory", - "ram", - "specs", - "gigahertz", - "ghz" - ], + "cpu": ["processor", "cores", "technology", "computer", "chip", "circuit", "memory", "ram", "specs", "gigahertz", "ghz"], "creative-commons": ["licence", "license"], "credit-card": ["bank", "purchase", "payment", "cc"], "croissant": ["bakery", "cooking", "food", "pastry"], @@ -3014,62 +2341,14 @@ "database-arrow-down": ["storage", "memory", "bytes", "server", "export", "download", "backup", "pull", "downsize"], "database-arrow-up": ["storage", "memory", "bytes", "server", "import", "upload", "backup", "push", "upscale"], "database-backup": ["storage", "memory", "bytes", "servers", "backup", "timemachine", "rotate", "arrow", "left"], - "database-check": [ - "storage", - "memory", - "bytes", - "server", - "check", - "success", - "valid", - "verified", - "confirmed", - "complete" - ], + "database-check": ["storage", "memory", "bytes", "server", "check", "success", "valid", "verified", "confirmed", "complete"], "database-minus": ["storage", "memory", "bytes", "server", "minus", "remove", "delete", "reduce"], "database-plus": ["storage", "memory", "bytes", "server", "plus", "add", "create", "insert", "new", "expand"], "database-search": ["storage", "memory", "container", "tin", "pot", "bytes", "servers"], - "database-x": [ - "storage", - "memory", - "bytes", - "server", - "x", - "error", - "failed", - "invalid", - "rejected", - "denied", - "clear", - "remove", - "disconnect" - ], + "database-x": ["storage", "memory", "bytes", "server", "x", "error", "failed", "invalid", "rejected", "denied", "clear", "remove", "disconnect"], "database-zap": ["cache busting", "storage", "memory", "bytes", "servers", "power", "crash"], - "decimals-arrow-left": [ - "numerical", - "decimal", - "decrease", - "less", - "fewer", - "precision", - "rounding", - "digits", - "fraction", - "float", - "number" - ], - "decimals-arrow-right": [ - "numerical", - "decimal", - "increase", - "more", - "precision", - "rounding", - "digits", - "fraction", - "float", - "number" - ], + "decimals-arrow-left": ["numerical", "decimal", "decrease", "less", "fewer", "precision", "rounding", "digits", "fraction", "float", "number"], + "decimals-arrow-right": ["numerical", "decimal", "increase", "more", "precision", "rounding", "digits", "fraction", "float", "number"], "delete": ["backspace", "remove"], "dessert": [ "pudding", @@ -3193,20 +2472,7 @@ "dock": ["desktop", "applications", "launch", "home", "menu bar", "bottom", "line", "macos", "osx"], "dog": ["animal", "pet", "puppy", "hound", "canine"], "dollar-sign": ["currency", "money", "payment"], - "donut": [ - "doughnut", - "sprinkles", - "topping", - "fast food", - "junk food", - "snack", - "treat", - "sweet", - "sugar", - "dessert", - "hollow", - "ring" - ], + "donut": ["doughnut", "sprinkles", "topping", "fast food", "junk food", "snack", "treat", "sweet", "sugar", "dessert", "hollow", "ring"], "door-closed": ["entrance", "entry", "exit", "ingress", "egress", "gate", "gateway", "emergency exit"], "door-closed-locked": ["entrance", "entry", "exit", "ingress", "egress", "gate", "gateway", "emergency exit", "lock"], "door-open": ["entrance", "entry", "exit", "ingress", "egress", "gate", "gateway", "emergency exit"], @@ -3229,19 +2495,7 @@ "." ], "download": ["import", "export", "save"], - "drafting-compass": [ - "geometry", - "trigonometry", - "radius", - "diameter", - "circumference", - "calculate", - "measure", - "arc", - "curve", - "draw", - "sketch" - ], + "drafting-compass": ["geometry", "trigonometry", "radius", "diameter", "circumference", "calculate", "measure", "arc", "curve", "draw", "sketch"], "drama": ["drama", "masks", "theater", "theatre", "entertainment", "show"], "drill": ["power", "bit", "head", "hole", "diy", "toolbox", "build", "construction"], "drone": ["quadcopter", "uav", "aerial", "flight", "flying", "technology", "airborne", "robotics"], @@ -3273,18 +2527,7 @@ "ear": ["hearing", "noise", "audio", "accessibility"], "ear-off": ["hearing", "hard of hearing", "hearing loss", "deafness", "noise", "silence", "audio", "accessibility"], "earth": ["world", "browser", "language", "translate", "globe"], - "earth-lock": [ - "vpn", - "private", - "privacy", - "network", - "world", - "browser", - "security", - "encryption", - "protection", - "connection" - ], + "earth-lock": ["vpn", "private", "privacy", "network", "world", "browser", "security", "encryption", "protection", "connection"], "eclipse": [ "lunar", "solar", @@ -3310,35 +2553,10 @@ "blend", "shade" ], - "egg": [ - "bird", - "chicken", - "nest", - "hatch", - "shell", - "incubate", - "soft boiled", - "hard", - "breakfast", - "brunch", - "morning", - "easter" - ], + "egg": ["bird", "chicken", "nest", "hatch", "shell", "incubate", "soft boiled", "hard", "breakfast", "brunch", "morning", "easter"], "egg-fried": ["food", "breakfast"], "egg-off": ["egg free", "vegan", "hatched", "bad egg"], - "ellipse": [ - "shape", - "geometry", - "rounded", - "smooth", - "outline", - "form", - "boundary", - "curve", - "shapes", - "ellipse", - "oval" - ], + "ellipse": ["shape", "geometry", "rounded", "smooth", "outline", "form", "boundary", "curve", "shapes", "ellipse", "oval"], "ellipsis": [ "et cetera", "etc", @@ -3395,36 +2613,10 @@ "output" ], "euro": ["currency", "money", "payment"], - "ev-charger": [ - "electric", - "charger", - "station", - "vehicle", - "fast", - "plug", - "ev", - "power", - "electricity", - "energy", - "accumulator", - "charge" - ], + "ev-charger": ["electric", "charger", "station", "vehicle", "fast", "plug", "ev", "power", "electricity", "energy", "accumulator", "charge"], "expand": ["scale", "fullscreen", "maximize", "minimize", "contract"], "external-link": ["outbound", "open", "share"], - "eye": [ - "view", - "watch", - "see", - "show", - "expose", - "reveal", - "display", - "visible", - "visibility", - "vision", - "preview", - "read" - ], + "eye": ["view", "watch", "see", "show", "expose", "reveal", "display", "visible", "visibility", "vision", "preview", "read"], "eye-closed": ["view", "watch", "see", "hide", "conceal", "mask", "hidden", "visibility", "vision"], "eye-dashed": ["view", "watch", "see", "hide", "conceal", "mask", "hidden", "invisible", "visibility", "vision"], "eye-off": ["view", "watch", "see", "hide", "conceal", "mask", "hidden", "visibility", "vision"], @@ -3433,16 +2625,7 @@ "fast-forward": ["music"], "feather": ["logo"], "fence": ["picket", "panels", "woodwork", "diy", "materials", "suburban", "garden", "property", "territory"], - "ferris-wheel": [ - "big wheel", - "daisy wheel", - "observation", - "attraction", - "entertainment", - "amusement park", - "theme park", - "funfair" - ], + "ferris-wheel": ["big wheel", "daisy wheel", "observation", "attraction", "entertainment", "amusement park", "theme park", "funfair"], "file": ["document"], "file-archive": ["zip", "package", "archive"], "file-axis-3d": ["model", "3d", "axis", "coordinates"], @@ -3584,64 +2767,12 @@ "amenities" ], "fish": ["dish", "restaurant", "course", "meal", "seafood", "pet", "sea", "marine"], - "fish-off": [ - "food", - "dish", - "restaurant", - "course", - "meal", - "seafood", - "animal", - "pet", - "sea", - "marine", - "allergy", - "intolerance", - "diet" - ], + "fish-off": ["food", "dish", "restaurant", "course", "meal", "seafood", "animal", "pet", "sea", "marine", "allergy", "intolerance", "diet"], "fish-symbol": ["dish", "restaurant", "course", "meal", "seafood", "pet", "sea", "marine"], - "fishing-hook": [ - "sea", - "boating", - "angler", - "bait", - "reel", - "tackle", - "marine", - "outdoors", - "fish", - "fishing", - "hook", - "sports", - "travel" - ], + "fishing-hook": ["sea", "boating", "angler", "bait", "reel", "tackle", "marine", "outdoors", "fish", "fishing", "hook", "sports", "travel"], "fishing-rod": ["fishing", "rod", "hobby", "equipment", "reel"], - "flag": [ - "report", - "marker", - "notification", - "warning", - "milestone", - "goal", - "notice", - "signal", - "attention", - "banner" - ], - "flag-off": [ - "unflag", - "unmark", - "report", - "marker", - "notification", - "warning", - "milestone", - "goal", - "notice", - "signal", - "attention", - "banner" - ], + "flag": ["report", "marker", "notification", "warning", "milestone", "goal", "notice", "signal", "attention", "banner"], + "flag-off": ["unflag", "unmark", "report", "marker", "notification", "warning", "milestone", "goal", "notice", "signal", "attention", "banner"], "flag-triangle-left": ["report", "timeline", "marker", "pin"], "flag-triangle-right": ["report", "timeline", "marker", "pin"], "flame": [ @@ -3680,19 +2811,7 @@ "fold-vertical": ["arrow", "collapse", "fold", "vertical", "dashed"], "folder": ["directory"], "folder-archive": ["archive", "zip", "package"], - "folder-bookmark": [ - "folder", - "bookmark", - "file", - "mark", - "storage", - "archive", - "directory", - "project", - "favorite", - "save", - "read later" - ], + "folder-bookmark": ["folder", "bookmark", "file", "mark", "storage", "archive", "directory", "project", "favorite", "save", "read later"], "folder-check": ["done", "directory", "todo", "tick", "complete", "task"], "folder-clock": ["history", "directory", "clock"], "folder-closed": ["directory", "closed"], @@ -3772,20 +2891,7 @@ "transport", "logistics" ], - "form": [ - "document", - "page", - "file", - "layout", - "paper", - "stub", - "formality", - "structure", - "template", - "inputs", - "design", - "components" - ], + "form": ["document", "page", "file", "layout", "paper", "stub", "formality", "structure", "template", "inputs", "design", "components"], "forward": ["send", "share", "email"], "frame": ["logo", "design", "tool"], "frown": ["emoji", "face", "bad", "sad", "emotion"], @@ -3825,48 +2931,10 @@ ], "gamepad": ["console"], "gamepad-2": ["console"], - "gamepad-directional": [ - "direction", - "arrow", - "controller", - "navigation", - "button", - "move", - "pointer", - "arrowhead", - "console", - "game", - "gaming" - ], + "gamepad-directional": ["direction", "arrow", "controller", "navigation", "button", "move", "pointer", "arrowhead", "console", "game", "gaming"], "gauge": ["dashboard", "dial", "meter", "speed", "pressure", "measure", "level"], - "gavel": [ - "justice", - "law", - "court", - "judgment", - "legal", - "hands", - "penalty", - "decision", - "authority", - "hammer", - "mallet" - ], - "gem": [ - "diamond", - "crystal", - "ruby", - "jewellery", - "price", - "special", - "present", - "gift", - "ring", - "wedding", - "proposal", - "marriage", - "rubygems" - ], + "gavel": ["justice", "law", "court", "judgment", "legal", "hands", "penalty", "decision", "authority", "hammer", "mallet"], + "gem": ["diamond", "crystal", "ruby", "jewellery", "price", "special", "present", "gift", "ring", "wedding", "proposal", "marriage", "rubygems"], "georgian-lari": ["currency", "money", "payment"], "ghost": ["pac-man", "spooky"], "gift": ["present", "box", "birthday", "party"], @@ -3905,18 +2973,7 @@ "online", "status" ], - "globe-lock": [ - "vpn", - "private", - "privacy", - "network", - "world", - "browser", - "security", - "encryption", - "protection", - "connection" - ], + "globe-lock": ["vpn", "private", "privacy", "network", "world", "browser", "security", "encryption", "protection", "connection"], "globe-off": [ "globe", "earth", @@ -3939,18 +2996,7 @@ "network failure", "signal off" ], - "globe-x": [ - "globe", - "internet", - "offline", - "disconnected", - "network", - "connection", - "world", - "no connection", - "network failure", - "signal off" - ], + "globe-x": ["globe", "internet", "offline", "disconnected", "network", "connection", "world", "no connection", "network failure", "signal off"], "goal": ["flag", "bullseye"], "gpu": [ "processor", @@ -4150,61 +3196,15 @@ "handout", "pennies" ], - "hand-fist": [ - "clench", - "strength", - "power", - "unity", - "solidarity", - "rebellion", - "victory", - "triumph", - "support", - "fight", - "combat", - "brawl" - ], + "hand-fist": ["clench", "strength", "power", "unity", "solidarity", "rebellion", "victory", "triumph", "support", "fight", "combat", "brawl"], "hand-grab": ["hand"], "hand-heart": ["love", "like", "emotion"], "hand-helping": ["agreement", "help", "proposal", "charity", "begging", "terms"], "hand-metal": ["rock"], - "hand-platter": [ - "waiter", - "waitress", - "restaurant", - "table service", - "served", - "dinner", - "dining", - "meal", - "course", - "luxury" - ], + "hand-platter": ["waiter", "waitress", "restaurant", "table service", "served", "dinner", "dining", "meal", "course", "luxury"], "handbag": ["bag", "baggage", "carry", "clutch", "fashion", "luggage", "purse", "tote", "travel"], - "handshake": [ - "agreement", - "partnership", - "deal", - "business", - "assistance", - "cooperation", - "friendship", - "union", - "terms" - ], - "hard-drive": [ - "computer", - "server", - "memory", - "data", - "ssd", - "disk", - "hard disk", - "storage", - "hardware", - "backup", - "media" - ], + "handshake": ["agreement", "partnership", "deal", "business", "assistance", "cooperation", "friendship", "union", "terms"], + "hard-drive": ["computer", "server", "memory", "data", "ssd", "disk", "hard disk", "storage", "hardware", "backup", "media"], "hard-drive-download": ["computer", "server", "memory", "data", "ssd", "disk", "hard disk", "save"], "hard-drive-upload": ["computer", "server", "memory", "data", "ssd", "disk", "hard disk", "save"], "hard-hat": ["helmet", "construction", "safety", "savety"], @@ -4263,20 +3263,7 @@ "heading-6": ["h6", "html", "markup", "markdown"], "headphone-off": ["music", "audio", "sound", "mute", "off"], "headphones": ["music", "audio", "sound"], - "headset": [ - "music", - "audio", - "sound", - "gaming", - "headphones", - "headset", - "call", - "center", - "phone", - "telephone", - "voip", - "video" - ], + "headset": ["music", "audio", "sound", "gaming", "headphones", "headset", "call", "center", "phone", "telephone", "voip", "video"], "heart": ["like", "love", "emotion", "suit", "playing", "cards"], "heart-crack": ["heartbreak", "sadness", "emotion"], "heart-handshake": ["agreement", "charity", "help", "deal", "terms", "emotion", "together", "handshake"], @@ -4286,37 +3273,13 @@ "heart-pulse": ["heartbeat", "pulse", "health", "medical", "blood pressure", "cardiac", "systole", "diastole"], "heart-x": ["unlike", "unfavorite", "remove", "reject", "dismiss", "delete", "clear"], "heater": ["heating", "warmth", "comfort", "fire", "stove", "electric", "electronics", "amenities"], - "helicopter": [ - "transport", - "flying", - "rotor", - "aviation", - "helipad", - "gear", - "flyer", - "technology", - "helicopter", - "aircraft", - "vehicle" - ], + "helicopter": ["transport", "flying", "rotor", "aviation", "helipad", "gear", "flyer", "technology", "helicopter", "aircraft", "vehicle"], "hexagon": ["shape", "node.js", "logo"], "highlighter": ["mark", "text"], "history": ["time", "redo", "undo", "rewind", "timeline", "version", "time machine", "backup", "rotate", "ccw"], "hop": ["beer", "brewery", "drink"], "hop-off": ["beer", "brewery", "drink", "hop free", "allergy", "intolerance", "diet"], - "hospital": [ - "infirmary", - "sanatorium", - "healthcare", - "doctor", - "hospice", - "clinic", - "emergency room", - "ward", - "building", - "medical", - "vet" - ], + "hospital": ["infirmary", "sanatorium", "healthcare", "doctor", "hospice", "clinic", "emergency room", "ward", "building", "medical", "vet"], "hotel": ["building", "hostel", "motel", "inn"], "hourglass": ["timer", "time", "sandglass"], "house": ["home", "living", "building", "residence", "architecture"], @@ -4341,21 +3304,7 @@ "inbox": ["email"], "indian-rupee": ["currency", "money", "payment"], "infinity": ["unlimited", "forever", "loop", "math"], - "info": [ - "about", - "advice", - "clue", - "details", - "help", - "hint", - "indicator", - "information", - "knowledge", - "notice", - "status", - "support", - "tooltip" - ], + "info": ["about", "advice", "clue", "details", "help", "hint", "indicator", "information", "knowledge", "notice", "status", "support", "tooltip"], "inspection-panel": ["access", "cover", "tile", "metal", "materials", "screws"], "italic": ["oblique", "text", "format"], "iteration-ccw": ["arrow", "right"], @@ -4378,19 +3327,7 @@ "code", "coding" ], - "kayak": [ - "kayak", - "boat", - "paddle", - "water", - "sport", - "recreation", - "adventure", - "outdoors", - "equipment", - "lake", - "ocean" - ], + "kayak": ["kayak", "boat", "paddle", "water", "sport", "recreation", "adventure", "outdoors", "equipment", "lake", "ocean"], "key": ["password", "login", "authentication", "secure", "unlock", "keychain", "key ring", "fob"], "key-round": ["password", "login", "authentication", "secure", "unlock"], "key-square": ["password", "login", "authentication", "secure", "unlock", "car key"], @@ -4570,38 +3507,12 @@ "music", "package" ], - "life-buoy": [ - "preserver", - "life belt", - "lifesaver", - "help", - "rescue", - "ship", - "ring", - "raft", - "inflatable", - "wheel", - "donut" - ], + "life-buoy": ["preserver", "life belt", "lifesaver", "help", "rescue", "ship", "ring", "raft", "inflatable", "wheel", "donut"], "ligature": ["text", "font", "typography", "alternates", "alternatives"], "lightbulb": ["idea", "bright", "lights"], "lightbulb-off": ["lights"], "line-dot-right-horizontal": ["code", "version control", "waypoint", "stop", "station", "last", "end"], - "line-squiggle": [ - "line", - "snakes", - "annotate", - "curve", - "doodle", - "stroke", - "pen", - "tool", - "gesture", - "draw", - "wave", - "art", - "road" - ], + "line-squiggle": ["line", "snakes", "annotate", "curve", "doodle", "stroke", "pen", "tool", "gesture", "draw", "wave", "art", "road"], "line-style": ["line", "stroke", "style", "dashed", "border"], "link": ["chain", "url"], "link-2": ["chain", "url"], @@ -4643,21 +3554,7 @@ "unfold", "vertical" ], - "list-collapse": [ - "items", - "collapse", - "expand", - "details", - "disclosure", - "show", - "hide", - "toggle", - "accordion", - "more", - "less", - "fold", - "unfold" - ], + "list-collapse": ["items", "collapse", "expand", "details", "disclosure", "show", "hide", "toggle", "accordion", "more", "less", "fold", "unfold"], "list-end": ["queue", "bottom", "end", "playlist"], "list-filter": ["options"], "list-filter-plus": ["filter", "plus", "options", "add"], @@ -4711,18 +3608,7 @@ "list-x": ["playlist", "subtract", "remove", "delete", "unqueue"], "loader": ["loading", "wait", "busy", "progress", "spinner", "spinning", "throbber"], "loader-circle": ["loading", "wait", "busy", "progress", "spinner", "spinning", "throbber", "circle"], - "loader-pinwheel": [ - "loading", - "wait", - "busy", - "progress", - "throbber", - "spinner", - "spinning", - "beach ball", - "frozen", - "freeze" - ], + "loader-pinwheel": ["loading", "wait", "busy", "progress", "throbber", "spinner", "spinning", "beach ball", "frozen", "freeze"], "locate": ["map", "gps", "location", "cross"], "locate-fixed": ["map", "gps", "location", "cross"], "locate-off": ["map", "gps", "location", "cross"], @@ -4737,20 +3623,7 @@ "luggage": ["baggage", "luggage", "travel", "suitcase"], "magnet": ["horseshoe", "lock", "science", "snap"], "mail": ["email", "message", "letter", "unread"], - "mail-check": [ - "email", - "message", - "letter", - "subscribe", - "delivered", - "success", - "read", - "done", - "todo", - "tick", - "complete", - "task" - ], + "mail-check": ["email", "message", "letter", "subscribe", "delivered", "success", "read", "done", "todo", "tick", "complete", "task"], "mail-minus": ["email", "message", "letter", "remove", "delete"], "mail-open": ["email", "message", "letter", "read"], "mail-plus": ["email", "message", "letter", "add", "create", "new", "compose"], @@ -4803,56 +3676,14 @@ "maximize-2": ["fullscreen", "arrows", "expand"], "medal": ["prize", "sports", "winner", "trophy", "award", "achievement"], "megaphone": ["advertisement", "announcement", "attention", "alert", "loudspeaker", "megaphone", "notification"], - "megaphone-off": [ - "advertisement", - "announcement", - "attention", - "alert", - "loudspeaker", - "megaphone", - "notification", - "disable", - "silent" - ], + "megaphone-off": ["advertisement", "announcement", "attention", "alert", "loudspeaker", "megaphone", "notification", "disable", "silent"], "meh": ["emoji", "face", "neutral", "emotion"], - "memory-stick": [ - "ram", - "random access", - "technology", - "computer", - "chip", - "circuit", - "specs", - "capacity", - "gigabytes", - "gb" - ], + "memory-stick": ["ram", "random access", "technology", "computer", "chip", "circuit", "specs", "capacity", "gigabytes", "gb"], "menu": ["bars", "navigation", "hamburger", "options"], "merge": ["combine", "join", "unite"], "message-circle": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble"], - "message-circle-check": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "moderate", - "check", - "done", - "todo", - "complete" - ], - "message-circle-code": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "code review", - "coding" - ], + "message-circle-check": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "moderate", "check", "done", "todo", "complete"], + "message-circle-code": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "code review", "coding"], "message-circle-dashed": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "draft"], "message-circle-heart": [ "comment", @@ -4903,16 +3734,7 @@ ], "message-circle-plus": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "add"], "message-circle-question-mark": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "help"], - "message-circle-reply": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "reply", - "response" - ], + "message-circle-reply": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "reply", "response"], "message-circle-warning": [ "comment", "chat", @@ -4946,29 +3768,8 @@ "moderate" ], "message-square": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble"], - "message-square-check": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "moderate", - "check", - "done", - "todo", - "complete" - ], - "message-square-code": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "code review", - "coding" - ], + "message-square-check": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "moderate", "check", "done", "todo", "complete"], + "message-square-code": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "code review", "coding"], "message-square-dashed": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "draft"], "message-square-diff": [ "comment", @@ -4989,17 +3790,8 @@ "version control", "git" ], - "message-square-dot": [ - "unread", - "unresolved", - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble" - ], - "message-square-heart": [ + "message-square-dot": ["unread", "unresolved", "comment", "chat", "conversation", "dialog", "feedback", "speech bubble"], + "message-square-heart": [ "comment", "chat", "conversation", @@ -5014,16 +3806,7 @@ "date", "speech bubble" ], - "message-square-lock": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "secure", - "encrypted" - ], + "message-square-lock": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "secure", "encrypted"], "message-square-more": [ "comment", "chat", @@ -5070,26 +3853,8 @@ "reply", "response" ], - "message-square-reply": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "reply", - "response" - ], - "message-square-share": [ - "comment", - "chat", - "conversation", - "dialog", - "feedback", - "speech bubble", - "network", - "forward" - ], + "message-square-reply": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "reply", "response"], + "message-square-share": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble", "network", "forward"], "message-square-text": ["comment", "chat", "conversation", "dialog", "feedback", "speech bubble"], "message-square-warning": [ "comment", @@ -5160,20 +3925,7 @@ "mic": ["record", "sound", "listen", "radio", "podcast", "microphone"], "mic-off": ["record", "sound", "mute", "microphone"], "mic-vocal": ["lyrics", "voice", "listen", "sound", "music", "radio", "podcast", "karaoke", "singing", "microphone"], - "microchip": [ - "processor", - "cores", - "technology", - "computer", - "chip", - "integrated circuit", - "memory", - "ram", - "specs", - "gpu", - "gigahertz", - "ghz" - ], + "microchip": ["processor", "cores", "technology", "computer", "chip", "integrated circuit", "memory", "ram", "specs", "gpu", "gigahertz", "ghz"], "microscope": ["medical", "education", "science", "imaging", "research"], "microwave": ["oven", "cooker", "toaster oven", "bake"], "milestone": ["signpost", "direction", "right", "east", "forward", "version control", "waypoint"], @@ -5276,88 +4028,16 @@ "monitor-dot": ["tv", "screen", "display", "desktop", "running", "active", "virtual machine", "vm"], "monitor-down": ["tv", "screen", "display", "desktop", "download"], "monitor-off": ["share"], - "monitor-pause": [ - "tv", - "screen", - "display", - "desktop", - "video", - "movie", - "film", - "suspend", - "hibernate", - "boot", - "virtual machine", - "vm" - ], - "monitor-play": [ - "tv", - "screen", - "display", - "desktop", - "video", - "movie", - "film", - "running", - "start", - "boot", - "virtual machine", - "vm" - ], - "monitor-smartphone": [ - "smartphone", - "phone", - "cellphone", - "device", - "mobile", - "desktop", - "monitor", - "responsive", - "screens" - ], + "monitor-pause": ["tv", "screen", "display", "desktop", "video", "movie", "film", "suspend", "hibernate", "boot", "virtual machine", "vm"], + "monitor-play": ["tv", "screen", "display", "desktop", "video", "movie", "film", "running", "start", "boot", "virtual machine", "vm"], + "monitor-smartphone": ["smartphone", "phone", "cellphone", "device", "mobile", "desktop", "monitor", "responsive", "screens"], "monitor-speaker": ["devices", "connect", "cast"], - "monitor-stop": [ - "tv", - "screen", - "display", - "desktop", - "video", - "movie", - "film", - "stop", - "shutdown", - "virtual machine", - "vm" - ], + "monitor-stop": ["tv", "screen", "display", "desktop", "video", "movie", "film", "stop", "shutdown", "virtual machine", "vm"], "monitor-up": ["tv", "screen", "display", "upload", "connect", "remote", "screen share"], - "monitor-x": [ - "tv", - "screen", - "display", - "desktop", - "virtual machine", - "vm", - "close", - "stop", - "suspend", - "remove", - "delete" - ], + "monitor-x": ["tv", "screen", "display", "desktop", "virtual machine", "vm", "close", "stop", "suspend", "remove", "delete"], "moon": ["dark", "night"], "moon-star": ["dark", "night", "star"], - "motorbike": [ - "moto", - "motorcycle", - "transport", - "vehicle", - "drive", - "ride", - "trip", - "race", - "racing", - "journey", - "delivery" - ], + "motorbike": ["moto", "motorcycle", "transport", "vehicle", "drive", "ride", "trip", "race", "racing", "journey", "delivery"], "mountain": ["climb", "hike", "rock"], "mountain-snow": ["alpine", "climb", "snow"], "mouse": ["device", "scroll", "click"], @@ -5558,18 +4238,7 @@ "nut-off": ["hazelnut", "acorn", "food", "allergy", "intolerance", "diet"], "octagon": ["stop", "shape"], "octagon-alert": ["warning", "alert", "danger", "exclamation mark"], - "octagon-minus": [ - "stop", - "forbidden", - "subtract", - "remove", - "decrease", - "reduce", - "-", - "traffic", - "halt", - "restricted" - ], + "octagon-minus": ["stop", "forbidden", "subtract", "remove", "decrease", "reduce", "-", "traffic", "halt", "restricted"], "octagon-pause": ["music", "audio", "stop"], "octagon-x": ["delete", "stop", "alert", "warning", "times", "clear", "math"], "omega": [ @@ -5590,19 +4259,7 @@ "option": ["keyboard", "key", "mac", "alt", "button"], "orbit": ["planet", "space", "physics", "satellites", "moons"], "origami": ["paper", "bird"], - "package": [ - "box", - "container", - "storage", - "sealed", - "delivery", - "undelivered", - "unopened", - "packed", - "archive", - "zip", - "module" - ], + "package": ["box", "container", "storage", "sealed", "delivery", "undelivered", "unopened", "packed", "archive", "zip", "module"], "package-2": ["box", "container", "storage", "sealed", "packed", "unopened", "undelivered", "archive", "zip"], "package-check": ["confirm", "verified", "done", "todo", "tick", "complete", "task", "delivered"], "package-minus": ["delete", "remove"], @@ -5622,61 +4279,15 @@ "panel-bottom-open": ["drawer", "dock", "show", "reveal", "chevron", "up"], "panel-left": ["primary", "drawer"], "panel-left-close": ["primary", "drawer", "hide", "chevron", "<"], - "panel-left-dashed": [ - "sidebar", - "primary", - "drawer", - "show", - "reveal", - "padding", - "margin", - "guide", - "layout", - "bleed" - ], + "panel-left-dashed": ["sidebar", "primary", "drawer", "show", "reveal", "padding", "margin", "guide", "layout", "bleed"], "panel-left-open": ["primary", "drawer", "show", "reveal", "chevron", "right", ">"], - "panel-left-right-dashed": [ - "sidebar", - "primary", - "drawer", - "show", - "reveal", - "padding", - "margin", - "guide", - "layout", - "vertical", - "bleed" - ], + "panel-left-right-dashed": ["sidebar", "primary", "drawer", "show", "reveal", "padding", "margin", "guide", "layout", "vertical", "bleed"], "panel-right": ["sidebar", "secondary", "drawer"], "panel-right-close": ["sidebar", "secondary", "drawer", "hide", "chevron", ">"], - "panel-right-dashed": [ - "sidebar", - "secondary", - "drawer", - "show", - "reveal", - "padding", - "margin", - "guide", - "layout", - "bleed" - ], + "panel-right-dashed": ["sidebar", "secondary", "drawer", "show", "reveal", "padding", "margin", "guide", "layout", "bleed"], "panel-right-open": ["sidebar", "secondary", "drawer", "show", "reveal", "chevron", "left", "<"], "panel-top": ["drawer", "browser", "webpage"], - "panel-top-bottom-dashed": [ - "sidebar", - "primary", - "drawer", - "show", - "reveal", - "padding", - "margin", - "guide", - "layout", - "horizontal", - "bleed" - ], + "panel-top-bottom-dashed": ["sidebar", "primary", "drawer", "show", "reveal", "padding", "margin", "guide", "layout", "horizontal", "bleed"], "panel-top-close": ["menu bar", "drawer", "hide", "chevron", "up"], "panel-top-dashed": ["menu bar", "drawer", "show", "reveal", "padding", "margin", "guide", "layout", "bleed"], "panel-top-open": ["menu bar", "drawer", "show", "reveal", "chevron", "down"], @@ -5725,50 +4336,12 @@ ")" ], "parking-meter": ["driving", "car park", "pay", "sidewalk", "pavement"], - "party-popper": [ - "emoji", - "congratulations", - "celebration", - "party", - "tada", - "🎉", - "🎊", - "excitement", - "exciting", - "excites", - "confetti" - ], + "party-popper": ["emoji", "congratulations", "celebration", "party", "tada", "🎉", "🎊", "excitement", "exciting", "excites", "confetti"], "pause": ["music", "stop"], "paw-print": ["pets", "vets", "veterinarian", "domesticated", "cat", "dog", "bear"], "pc-case": ["computer", "chassis"], - "pen": [ - "pencil", - "change", - "create", - "draw", - "writer", - "writing", - "biro", - "ink", - "marker", - "felt tip", - "stationery", - "artist" - ], - "pen-line": [ - "pencil", - "change", - "create", - "draw", - "writer", - "writing", - "biro", - "ink", - "marker", - "felt tip", - "stationery", - "artist" - ], + "pen": ["pencil", "change", "create", "draw", "writer", "writing", "biro", "ink", "marker", "felt tip", "stationery", "artist"], + "pen-line": ["pencil", "change", "create", "draw", "writer", "writing", "biro", "ink", "marker", "felt tip", "stationery", "artist"], "pen-off": [ "disabled", "inactive", @@ -5904,47 +4477,13 @@ "performance", "concert" ], - "pickaxe": [ - "mining", - "mine", - "land worker", - "extraction", - "labor", - "construction", - "progress", - "advancement", - "crafting", - "building", - "creation" - ], + "pickaxe": ["mining", "mine", "land worker", "extraction", "labor", "construction", "progress", "advancement", "crafting", "building", "creation"], "picture-in-picture": ["display", "play", "video", "pop out", "always on top", "window", "inset", "multitask"], "picture-in-picture-2": ["display", "play", "video", "pop out", "always on top", "window", "inset", "multitask"], "piggy-bank": ["money", "savings"], "pilcrow": ["paragraph", "mark", "paraph", "blind", "typography", "type", "text", "prose", "symbol"], - "pilcrow-left": [ - "direction", - "paragraph", - "mark", - "paraph", - "blind", - "typography", - "type", - "text", - "prose", - "symbol" - ], - "pilcrow-right": [ - "direction", - "paragraph", - "mark", - "paraph", - "blind", - "typography", - "type", - "text", - "prose", - "symbol" - ], + "pilcrow-left": ["direction", "paragraph", "mark", "paraph", "blind", "typography", "type", "text", "prose", "symbol"], + "pilcrow-right": ["direction", "paragraph", "mark", "paraph", "blind", "typography", "type", "text", "prose", "symbol"], "pill": ["medicine", "medication", "drug", "prescription", "tablet", "pharmacy"], "pill-bottle": [ "medicine", @@ -6020,20 +4559,7 @@ "popcorn": ["cinema", "movies", "films", "salted", "sweet", "sugar", "candy", "snack"], "popsicle": ["ice lolly", "ice cream", "sweet", "food"], "pound-sterling": ["currency", "money", "payment"], - "power": [ - "on", - "off", - "device", - "switch", - "toggle", - "binary", - "boolean", - "reboot", - "restart", - "button", - "keyboard", - "troubleshoot" - ], + "power": ["on", "off", "device", "switch", "toggle", "binary", "boolean", "reboot", "restart", "button", "keyboard", "troubleshoot"], "power-off": ["on", "off", "device", "switch"], "presentation": [ "screen", @@ -6054,18 +4580,7 @@ "printer": ["fax", "office", "device"], "printer-check": ["fax", "office", "device", "success", "printed"], "printer-x": ["fax", "office", "device", "cross", "cancel", "remove", "error"], - "projector": [ - "cinema", - "film", - "movie", - "home video", - "presentation", - "slideshow", - "office", - "meeting", - "project", - "planning" - ], + "projector": ["cinema", "film", "movie", "home video", "presentation", "slideshow", "office", "meeting", "project", "planning"], "proportions": [ "screens", "sizes", @@ -6087,18 +4602,7 @@ "landscape" ], "puzzle": ["component", "module", "part", "piece"], - "pyramid": [ - "prism", - "triangle", - "triangular", - "hierarchy", - "structure", - "geometry", - "ancient", - "egyptian", - "landmark", - "tourism" - ], + "pyramid": ["prism", "triangle", "triangular", "hierarchy", "structure", "geometry", "ancient", "egyptian", "landmark", "tourism"], "qr-code": ["barcode", "scan", "link", "url", "information", "digital"], "quote": ["quotation"], "rabbit": ["animal", "rodent", "pet", "pest", "bunny", "hare", "fast", "speed", "hop"], @@ -6141,18 +4645,7 @@ "receipt-pound-sterling": ["bill", "voucher", "slip", "check", "counterfoil", "british", "currency", "gbp", "£"], "receipt-russian-ruble": ["bill", "voucher", "slip", "check", "counterfoil", "currency", "rub", "₽"], "receipt-swiss-franc": ["bill", "voucher", "slip", "check", "counterfoil", "currency", "chf", "₣"], - "receipt-text": [ - "bill", - "voucher", - "slip", - "check", - "counterfoil", - "details", - "small print", - "terms", - "conditions", - "contract" - ], + "receipt-text": ["bill", "voucher", "slip", "check", "counterfoil", "details", "small print", "terms", "conditions", "contract"], "receipt-turkish-lira": ["bill", "voucher", "slip", "check", "counterfoil", "currency", "try", "₺"], "rectangle-circle": ["compose", "keyboard", "key", "button"], "rectangle-ellipsis": [ @@ -6192,19 +4685,7 @@ "redo-2": ["undo", "rerun", "history"], "redo-dot": ["redo", "history", "step", "over", "forward"], "refresh-ccw": ["arrows", "rotate", "reload", "rerun", "synchronise", "synchronize", "circular", "cycle"], - "refresh-ccw-dot": [ - "arrows", - "rotate", - "reload", - "synchronise", - "synchronize", - "circular", - "cycle", - "issue", - "code", - "coding", - "version control" - ], + "refresh-ccw-dot": ["arrows", "rotate", "reload", "synchronise", "synchronize", "circular", "cycle", "issue", "code", "coding", "version control"], "refresh-cw": ["rotate", "reload", "rerun", "synchronise", "synchronize", "arrows", "circular", "cycle"], "refresh-cw-off": [ "rotate", @@ -6284,19 +4765,7 @@ "rotate-cw": ["arrow", "right", "clockwise", "refresh", "reload", "rerun", "redo"], "rotate-cw-square": ["right", "clockwise", "rotate", "image", "90", "45", "degrees", "°"], "route": ["path", "journey", "planner", "points", "stops", "stations"], - "route-off": [ - "path", - "journey", - "planner", - "points", - "stops", - "stations", - "reset", - "clear", - "cancelled", - "closed", - "blocked" - ], + "route-off": ["path", "journey", "planner", "points", "stops", "stations", "reset", "clear", "cancelled", "closed", "blocked"], "router": ["computer", "server", "cloud"], "rows-2": [ "lines", @@ -6429,22 +4898,7 @@ "save-off": ["floppy disk", "unsalvageable"], "save-pen": ["floppy disk", "directory", "rename"], "save-plus": ["floppy disk", "save", "plus", "add", "update", "create"], - "scale": [ - "balance", - "legal", - "license", - "right", - "rule", - "law", - "justice", - "weight", - "measure", - "compare", - "judge", - "fair", - "ethics", - "decision" - ], + "scale": ["balance", "legal", "license", "right", "rule", "law", "justice", "weight", "measure", "compare", "judge", "fair", "ethics", "decision"], "scale-3d": ["gizmo", "transform", "size", "axis"], "scaling": ["scale", "resize", "design"], "scan": [ @@ -6467,18 +4921,7 @@ "square", "dashed" ], - "scan-barcode": [ - "checkout", - "till", - "cart", - "transaction", - "purchase", - "buy", - "product", - "packaging", - "retail", - "consumer" - ], + "scan-barcode": ["checkout", "till", "cart", "transaction", "purchase", "buy", "product", "packaging", "retail", "consumer"], "scan-box": [ "ar", "augmented reality", @@ -6521,20 +4964,7 @@ ], "scan-face": ["face", "biometric", "identification", "authentication", "2fa", "access", "login", "dashed"], "scan-heart": ["health", "heart rate", "pulse", "monitoring", "healthiness", "screening", "dashed"], - "scan-line": [ - "checkout", - "till", - "cart", - "transaction", - "purchase", - "buy", - "product", - "packaging", - "retail", - "consumer", - "qr-code", - "dashed" - ], + "scan-line": ["checkout", "till", "cart", "transaction", "purchase", "buy", "product", "packaging", "retail", "consumer", "qr-code", "dashed"], "scan-qr-code": ["barcode", "scan", "qrcode", "url", "information", "digital", "scanner"], "scan-search": ["preview", "zoom", "expand", "fullscreen", "gallery", "image", "focus", "lens"], "scan-text": ["recognition", "read", "translate", "copy", "lines"], @@ -6568,46 +4998,9 @@ "scooter": ["vehicle", "drive", "trip", "journey", "transport", "electric", "ride", "urban", "commute", "speed"], "screen-share": ["host", "desktop", "monitor"], "screen-share-off": ["desktop", "disconnect", "monitor"], - "scroll": [ - "paper", - "log", - "scripture", - "document", - "notes", - "parchment", - "list", - "long", - "script", - "story", - "code", - "coding" - ], - "scroll-text": [ - "paper", - "log", - "scripture", - "document", - "notes", - "parchment", - "list", - "long", - "script", - "story", - "code", - "coding" - ], - "search": [ - "find", - "scan", - "magnifier", - "magnifying glass", - "lens", - "locate", - "explore", - "discover", - "enlarge", - "zoom" - ], + "scroll": ["paper", "log", "scripture", "document", "notes", "parchment", "list", "long", "script", "story", "code", "coding"], + "scroll-text": ["paper", "log", "scripture", "document", "notes", "parchment", "list", "long", "script", "story", "code", "coding"], + "search": ["find", "scan", "magnifier", "magnifying glass", "lens", "locate", "explore", "discover", "enlarge", "zoom"], "search-alert": [ "find", "scan", @@ -6702,18 +5095,7 @@ "server-plus": ["add", "create", "new", "cloud", "storage", "computing"], "settings": ["cog", "edit", "gear", "preferences"], "settings-2": ["cog", "edit", "gear", "preferences", "slider"], - "shapes": [ - "triangle", - "equilateral", - "square", - "circle", - "classification", - "different", - "collection", - "toy", - "blocks", - "learning" - ], + "shapes": ["triangle", "equilateral", "square", "circle", "classification", "different", "collection", "toy", "blocks", "learning"], "share": ["network", "connections"], "share-2": ["network", "connections"], "sheet": ["spreadsheets", "table", "excel"], @@ -7392,21 +5774,7 @@ "eliminated", "exterminated" ], - "ship": [ - "boat", - "knots", - "nautical mile", - "maritime", - "sailing", - "yacht", - "cruise", - "ocean liner", - "tanker", - "vessel", - "navy", - "trip", - "releases" - ], + "ship": ["boat", "knots", "nautical mile", "maritime", "sailing", "yacht", "cruise", "ocean liner", "tanker", "vessel", "navy", "trip", "releases"], "ship-wheel": [ "steering", "rudder", @@ -7451,18 +5819,7 @@ "waste", "permanent" ], - "shrimp": [ - "seafood", - "shellfish", - "crustacean", - "prawn", - "scallop", - "whelk", - "arthropod", - "littleneck", - "quahog", - "cherrystone" - ], + "shrimp": ["seafood", "shellfish", "crustacean", "prawn", "scallop", "whelk", "arthropod", "littleneck", "quahog", "cherrystone"], "shrink": ["scale", "fullscreen"], "shrub": ["forest", "undergrowth", "park", "nature"], "shuffle": ["music", "random", "reorder"], @@ -7614,17 +5971,7 @@ "square-arrow-up": ["forward", "direction", "north", "sign", "keyboard", "button"], "square-arrow-up-left": ["direction", "north-west", "diagonal", "sign", "keyboard", "button"], "square-arrow-up-right": ["direction", "north-east", "diagonal", "sign", "keyboard", "button", "share"], - "square-asterisk": [ - "password", - "secret", - "access", - "key", - "multiply", - "multiplication", - "glob pattern", - "wildcard", - "*" - ], + "square-asterisk": ["password", "secret", "access", "key", "multiply", "multiplication", "glob pattern", "wildcard", "*"], "square-bottom-dashed-scissors": ["cut", "snippet", "chop", "stationery", "crafts"], "square-centerline-dashed-horizontal": ["reflect", "mirror", "alignment", "dashed"], "square-centerline-dashed-vertical": ["reflect", "mirror", "alignment", "dashed"], @@ -7707,19 +6054,7 @@ "code", "coding" ], - "square-dashed-mouse-pointer": [ - "inspector", - "element", - "mouse", - "click", - "pointer", - "box", - "browser", - "selector", - "target", - "dom", - "node" - ], + "square-dashed-mouse-pointer": ["inspector", "element", "mouse", "click", "pointer", "box", "browser", "selector", "target", "dom", "node"], "square-dashed-text": ["find", "search", "selection", "dashed"], "square-dashed-top-solid": [ "square", @@ -7809,19 +6144,7 @@ "minimum", "downgrade" ], - "square-mouse-pointer": [ - "inspector", - "element", - "mouse", - "click", - "pointer", - "box", - "browser", - "selector", - "target", - "dom", - "node" - ], + "square-mouse-pointer": ["inspector", "element", "mouse", "click", "pointer", "box", "browser", "selector", "target", "dom", "node"], "square-parking": ["parking lot", "car park"], "square-parking-off": ["parking lot", "car park", "no parking"], "square-pause": ["music", "audio", "stop"], @@ -7867,20 +6190,7 @@ "coding", "+" ], - "square-power": [ - "on", - "off", - "device", - "switch", - "toggle", - "binary", - "boolean", - "reboot", - "restart", - "button", - "keyboard", - "troubleshoot" - ], + "square-power": ["on", "off", "device", "switch", "toggle", "binary", "boolean", "reboot", "restart", "button", "keyboard", "troubleshoot"], "square-radical": ["calculate", "formula", "math", "operator", "root", "square", "symbol"], "square-round-corner": ["border", "radius", "style", "design", "corner", "layout", "round", "rounded"], "square-scissors": ["cut", "snippet", "chop", "stationery", "crafts", "toolbar", "button"], @@ -7889,32 +6199,8 @@ "square-split-horizontal": ["split", "divide"], "square-split-vertical": ["split", "divide"], "square-square": ["float", "center", "rectangle"], - "square-stack": [ - "versions", - "clone", - "copy", - "duplicate", - "multiple", - "revisions", - "version control", - "backup", - "history" - ], - "square-star": [ - "badge", - "medal", - "honour", - "decoration", - "order", - "pin", - "laurel", - "trophy", - "medallion", - "insignia", - "bronze", - "silver", - "gold" - ], + "square-stack": ["versions", "clone", "copy", "duplicate", "multiple", "revisions", "version control", "backup", "history"], + "square-star": ["badge", "medal", "honour", "decoration", "order", "pin", "laurel", "trophy", "medallion", "insignia", "bronze", "silver", "gold"], "square-stop": ["media", "music"], "square-terminal": ["code", "command line", "prompt", "shell"], "square-user": ["person", "account", "contact"], @@ -8102,39 +6388,13 @@ "duplicate", "copy" ], - "stone": [ - "mineral", - "geology", - "nature", - "solid", - "pebble", - "crystal", - "ore", - "hard", - "coal", - "stone", - "rock", - "boulder" - ], + "stone": ["mineral", "geology", "nature", "solid", "pebble", "crystal", "ore", "hard", "coal", "stone", "rock", "boulder"], "store": ["shop", "supermarket", "stand", "boutique", "building"], "stretch-horizontal": ["items", "flex", "justify", "distribute"], "stretch-vertical": ["items", "flex", "justify", "distribute"], "strikethrough": ["cross out", "delete", "remove", "format"], "subscript": ["text"], - "summary": [ - "brief", - "abstract", - "synopsis", - "report", - "digest", - "outline", - "recap", - "condensation", - "summary", - "ai", - "text", - "overview" - ], + "summary": ["brief", "abstract", "synopsis", "report", "digest", "outline", "recap", "condensation", "summary", "ai", "text", "overview"], "sun": ["brightness", "weather", "light", "summer"], "sun-dim": ["brightness", "dim", "low", "brightness low"], "sun-medium": ["brightness", "medium"], @@ -8195,65 +6455,13 @@ "tag-x": ["label", "badge", "ticket", "mark", "x", "delete", "remove"], "tags": ["labels", "badges", "tickets", "marks", "copy", "multiple"], "tally-1": ["count", "score", "enumerate", "days", "one", "1", "first", "bar", "prison", "cell", "sentence"], - "tally-2": [ - "count", - "score", - "enumerate", - "days", - "two", - "2", - "second", - "double", - "bars", - "prison", - "cell", - "sentence" - ], - "tally-3": [ - "count", - "score", - "enumerate", - "days", - "three", - "3", - "third", - "triple", - "bars", - "prison", - "cell", - "sentence" - ], + "tally-2": ["count", "score", "enumerate", "days", "two", "2", "second", "double", "bars", "prison", "cell", "sentence"], + "tally-3": ["count", "score", "enumerate", "days", "three", "3", "third", "triple", "bars", "prison", "cell", "sentence"], "tally-4": ["count", "score", "enumerate", "days", "4", "fourth", "quadruple", "bars", "prison", "cell", "sentence"], - "tally-5": [ - "count", - "score", - "enumerate", - "days", - "five", - "5", - "fifth", - "bars", - "prison", - "cell", - "sentence", - "slash", - "/" - ], + "tally-5": ["count", "score", "enumerate", "days", "five", "5", "fifth", "bars", "prison", "cell", "sentence", "slash", "/"], "tangent": ["tangential", "shape", "circle", "geometry", "trigonometry", "bezier curve"], "target": ["logo", "bullseye", "deadline", "projects", "overview", "work", "productivity"], - "telescope": [ - "astronomy", - "space", - "discovery", - "exploration", - "explore", - "vision", - "perspective", - "focus", - "stargazing", - "observe", - "view" - ], + "telescope": ["astronomy", "space", "discovery", "exploration", "explore", "vision", "perspective", "focus", "stargazing", "observe", "view"], "tent": [ "tipi", "teepee", @@ -8272,18 +6480,7 @@ "tent-tree": ["camping", "campsite", "holiday", "retreat", "nomadic", "wilderness", "outdoors"], "terminal": ["code", "command line", "prompt", "shell"], "test-tube": ["tube", "vial", "phial", "flask", "ampoule", "ampule", "lab", "chemistry", "experiment", "test"], - "test-tube-diagonal": [ - "tube", - "vial", - "phial", - "flask", - "ampoule", - "ampule", - "lab", - "chemistry", - "experiment", - "test" - ], + "test-tube-diagonal": ["tube", "vial", "phial", "flask", "ampoule", "ampule", "lab", "chemistry", "experiment", "test"], "test-tubes": ["tubes", "vials", "phials", "flasks", "ampoules", "ampules", "lab", "chemistry", "experiment", "test"], "text-align-center": ["text", "alignment", "center"], "text-align-end": ["text", "alignment", "right"], @@ -8319,34 +6516,8 @@ "checked", "used" ], - "ticket-minus": [ - "entry", - "pass", - "voucher", - "event", - "concert", - "show", - "remove", - "cancel", - "unbook", - "subtract", - "decrease", - "-" - ], - "ticket-percent": [ - "discount", - "reduced", - "offer", - "voucher", - "entry", - "pass", - "event", - "concert", - "show", - "book", - "purchase", - "%" - ], + "ticket-minus": ["entry", "pass", "voucher", "event", "concert", "show", "remove", "cancel", "unbook", "subtract", "decrease", "-"], + "ticket-percent": ["discount", "reduced", "offer", "voucher", "entry", "pass", "event", "concert", "show", "book", "purchase", "%"], "ticket-plus": ["entry", "pass", "voucher", "event", "concert", "show", "book", "purchase", "add", "+"], "ticket-slash": [ "entry", @@ -8404,20 +6575,7 @@ "x" ], "tickets": ["trip", "travel", "pass", "entry", "voucher", "event", "concert", "show", "perforated", "dashed"], - "tickets-plane": [ - "plane", - "trip", - "airplane", - "flight", - "travel", - "fly", - "takeoff", - "vacation", - "passenger", - "pass", - "check-in", - "airport" - ], + "tickets-plane": ["plane", "trip", "airplane", "flight", "travel", "fly", "takeoff", "vacation", "passenger", "pass", "check-in", "airport"], "timeline": ["tags", "history"], "timer": ["time", "timer", "stopwatch"], "timer-off": ["time", "timer", "stopwatch"], @@ -8454,20 +6612,7 @@ "diy" ], "tornado": ["weather", "wind", "storm", "hurricane"], - "torus": [ - "donut", - "doughnut", - "ring", - "hollow", - "3d", - "fast food", - "junk food", - "snack", - "treat", - "sweet", - "sugar", - "dessert" - ], + "torus": ["donut", "doughnut", "ring", "hollow", "3d", "fast food", "junk food", "snack", "treat", "sweet", "sugar", "dessert"], "touchpad": ["trackpad", "cursor"], "touchpad-off": ["trackpad", "cursor"], "towel-rack": [ @@ -8535,18 +6680,7 @@ "triangle": ["equilateral", "delta", "shape", "pyramid", "hierarchy"], "triangle-alert": ["warning", "alert", "danger", "exclamation mark", "linter"], "triangle-dashed": ["equilateral", "delta", "shape", "pyramid", "hierarchy", "dashed"], - "triangle-right": [ - "volume", - "controls", - "controller", - "tv remote", - "geometry", - "delta", - "ramp", - "slope", - "incline", - "increase" - ], + "triangle-right": ["volume", "controls", "controller", "tv remote", "geometry", "delta", "ramp", "slope", "incline", "increase"], "trophy": ["prize", "sports", "winner", "achievement", "award", "champion", "celebration", "victory"], "truck": ["delivery", "van", "shipping", "haulage", "lorry"], "truck-electric": ["delivery", "van", "shipping", "haulage", "lorry", "electric"], @@ -8730,20 +6864,7 @@ "user-x": ["delete", "remove", "unfollow", "unsubscribe", "unavailable"], "users": ["group", "people"], "users-round": ["group", "people"], - "utensils": [ - "fork", - "knife", - "cutlery", - "flatware", - "tableware", - "silverware", - "food", - "restaurant", - "meal", - "breakfast", - "dinner", - "supper" - ], + "utensils": ["fork", "knife", "cutlery", "flatware", "tableware", "silverware", "food", "restaurant", "meal", "breakfast", "dinner", "supper"], "utensils-crossed": [ "fork", "knife", @@ -8792,32 +6913,8 @@ "(", ")" ], - "vault": [ - "safe", - "lockbox", - "deposit", - "locker", - "coffer", - "strongbox", - "safety", - "secure", - "storage", - "valuables", - "bank" - ], - "vector-square": [ - "shape", - "geometry", - "art", - "width", - "height", - "size", - "calculate", - "measure", - "select", - "graphics", - "box" - ], + "vault": ["safe", "lockbox", "deposit", "locker", "coffer", "strongbox", "safety", "secure", "storage", "valuables", "bank"], + "vector-square": ["shape", "geometry", "art", "width", "height", "size", "calculate", "measure", "select", "graphics", "box"], "vegan": ["vegetarian", "fruitarian", "herbivorous", "animal rights", "diet"], "venetian-mask": ["mask", "masquerade", "impersonate", "secret", "incognito"], "venus": ["gender", "sex", "female", "feminine", "woman", "girl"], @@ -8879,68 +6976,17 @@ "cc" ], "wallet-minimal": ["finance", "pocket"], - "wallpaper": [ - "background", - "texture", - "image", - "art", - "design", - "visual", - "decor", - "pattern", - "screen", - "cover", - "lock screen" - ], + "wallpaper": ["background", "texture", "image", "art", "design", "visual", "decor", "pattern", "screen", "cover", "lock screen"], "wand": ["magic", "selection"], "wand-sparkles": ["magic", "wizard", "magician"], "warehouse": ["storage", "storehouse", "depot", "depository", "repository", "stockroom", "logistics", "building"], "washing-machine": ["tumble dryer", "amenities", "electronics", "cycle", "clothes", "rinse", "spin", "drum"], "watch": ["clock", "time"], - "waves-arrow-down": [ - "water", - "sea", - "level", - "sound", - "hertz", - "wavelength", - "vibrate", - "low", - "tide", - "ocean", - "rising", - "down", - "falling" - ], - "waves-arrow-up": [ - "water", - "sea", - "level", - "sound", - "hertz", - "wavelength", - "vibrate", - "high", - "tide", - "ocean", - "rising" - ], + "waves-arrow-down": ["water", "sea", "level", "sound", "hertz", "wavelength", "vibrate", "low", "tide", "ocean", "rising", "down", "falling"], + "waves-arrow-up": ["water", "sea", "level", "sound", "hertz", "wavelength", "vibrate", "high", "tide", "ocean", "rising"], "waves-horizontal": ["water", "sea", "sound", "hertz", "wavelength", "vibrate", "ocean", "swimming", "frequency"], "waves-ladder": ["swimming", "water", "pool", "lifeguard", "ocean", "🌊", "🏊‍♂️", "🏊‍♀️", "🏊", "🥽"], - "waves-vertical": [ - "steam", - "warmth", - "temperature", - "burn", - "hot", - "boiling", - "heat", - "smoke", - "vapor", - "smell", - "aroma", - "sauna" - ], + "waves-vertical": ["steam", "warmth", "temperature", "burn", "hot", "boiling", "heat", "smoke", "vapor", "smell", "aroma", "sauna"], "waypoints": [ "indirection", "vpn", @@ -8968,18 +7014,7 @@ "wheat-off": ["corn", "cereal", "grain", "gluten free", "allergy", "intolerance", "diet"], "whole-word": ["text", "selection", "letters", "characters", "font", "typography"], "wifi": ["connection", "signal", "wireless"], - "wifi-cog": [ - "connection", - "signal", - "wireless", - "directory", - "settings", - "control", - "preferences", - "cog", - "edit", - "gear" - ], + "wifi-cog": ["connection", "signal", "wireless", "directory", "settings", "control", "preferences", "cog", "edit", "gear"], "wifi-high": ["connection", "signal", "wireless"], "wifi-low": ["connection", "signal", "wireless"], "wifi-off": ["disabled"], @@ -8989,45 +7024,11 @@ "wind": ["weather", "air", "blow"], "wind-arrow-down": ["weather", "air", "pressure", "blow"], "wine": ["alcohol", "beverage", "bar", "drink", "glass", "sommelier", "vineyard", "winery"], - "wine-off": [ - "alcohol", - "beverage", - "drink", - "glass", - "alcohol free", - "abstinence", - "abstaining", - "teetotalism", - "allergy", - "intolerance" - ], + "wine-off": ["alcohol", "beverage", "drink", "glass", "alcohol free", "abstinence", "abstaining", "teetotalism", "allergy", "intolerance"], "workflow": ["action", "continuous integration", "ci", "automation", "devops", "network", "node", "connection"], - "worm": [ - "invertebrate", - "grub", - "larva", - "snake", - "crawl", - "wiggle", - "slither", - "pest control", - "computer virus", - "malware" - ], + "worm": ["invertebrate", "grub", "larva", "snake", "crawl", "wiggle", "slither", "pest control", "computer virus", "malware"], "wrench": ["account", "settings", "spanner", "diy", "toolbox", "build", "construction"], - "wrench-off": [ - "account", - "settings", - "spanner", - "diy", - "toolbox", - "build", - "construction", - "off", - "service", - "maintenance", - "disabled" - ], + "wrench-off": ["account", "settings", "spanner", "diy", "toolbox", "build", "construction", "off", "service", "maintenance", "disabled"], "x": ["cancel", "close", "cross", "delete", "ex", "remove", "times", "clear", "math", "multiply", "multiplication"], "x-line-top": [ "line", @@ -9055,90 +7056,13 @@ ], "zap": ["flash", "camera", "lightning", "electricity", "energy", "power", "quick"], "zap-off": ["flash", "camera", "lightning", "electricity", "energy", "power"], - "zodiac-aquarius": [ - "water bearer", - "waves", - "innovation", - "air", - "future", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-aries": [ - "ram", - "horns", - "fire", - "energy", - "initiative", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-cancer": [ - "crab", - "shell", - "protection", - "water", - "intuition", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-capricorn": [ - "goat", - "mountain", - "ambition", - "earth", - "discipline", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-gemini": [ - "twins", - "duality", - "communication", - "air", - "adaptability", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-leo": [ - "lion", - "crown", - "leadership", - "fire", - "confidence", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-libra": [ - "scales", - "balance", - "justice", - "air", - "harmony", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], + "zodiac-aquarius": ["water bearer", "waves", "innovation", "air", "future", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-aries": ["ram", "horns", "fire", "energy", "initiative", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-cancer": ["crab", "shell", "protection", "water", "intuition", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-capricorn": ["goat", "mountain", "ambition", "earth", "discipline", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-gemini": ["twins", "duality", "communication", "air", "adaptability", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-leo": ["lion", "crown", "leadership", "fire", "confidence", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-libra": ["scales", "balance", "justice", "air", "harmony", "astrology", "star sign", "horoscope", "constellation", "celestial"], "zodiac-ophiuchus": [ "serpent", "snake holder", @@ -9151,30 +7075,8 @@ "constellation", "celestial" ], - "zodiac-pisces": [ - "fish", - "duality", - "water", - "dreams", - "empathy", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], - "zodiac-sagittarius": [ - "archer", - "arrow", - "exploration", - "fire", - "philosophy", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], + "zodiac-pisces": ["fish", "duality", "water", "dreams", "empathy", "astrology", "star sign", "horoscope", "constellation", "celestial"], + "zodiac-sagittarius": ["archer", "arrow", "exploration", "fire", "philosophy", "astrology", "star sign", "horoscope", "constellation", "celestial"], "zodiac-scorpio": [ "scorpion", "stinger", @@ -9187,18 +7089,7 @@ "constellation", "celestial" ], - "zodiac-taurus": [ - "bull", - "strength", - "stability", - "earth", - "endurance", - "astrology", - "star sign", - "horoscope", - "constellation", - "celestial" - ], + "zodiac-taurus": ["bull", "strength", "stability", "earth", "endurance", "astrology", "star sign", "horoscope", "constellation", "celestial"], "zodiac-virgo": [ "virgin", "maiden", diff --git a/frontend/src/modules/common/json-viewer/collapsed-preview.tsx b/frontend/src/modules/common/json-viewer/collapsed-preview.tsx index d7a5f1fa4..171061283 100644 --- a/frontend/src/modules/common/json-viewer/collapsed-preview.tsx +++ b/frontend/src/modules/common/json-viewer/collapsed-preview.tsx @@ -1,34 +1,23 @@ +import { cn } from '~/utils/cn'; + interface CollapsedPreviewProps { itemCount: number; closeBracket: string; hiddenMatchCount: number; displayDataTypes: boolean; typeLabel: string; - theme: { - bracket: string; - matchBadge: string; - }; + theme: { bracket: string; matchBadge: string }; } -export function CollapsedPreview({ - itemCount, - closeBracket, - hiddenMatchCount, - displayDataTypes, - typeLabel, - theme, -}: CollapsedPreviewProps) { +export function CollapsedPreview({ itemCount, closeBracket, hiddenMatchCount, displayDataTypes, typeLabel, theme }: CollapsedPreviewProps) { return ( <> <span className="mx-1.5 whitespace-nowrap text-xs italic opacity-50"> {itemCount} {itemCount === 1 ? 'item' : 'items'} </span> - <span className={`font-medium ${theme.bracket} group-data-[openapi-mode=schema]/jv:hidden`}>{closeBracket}</span> + <span className={cn('font-medium', theme.bracket, 'group-data-[openapi-mode=schema]/jv:hidden')}>{closeBracket}</span> {hiddenMatchCount > 0 && ( - <span - className={`ml-1.5 rounded px-1.5 py-0.5 font-medium text-sm ${theme.matchBadge}`} - title="Contains search matches - click to expand" - > + <span className={cn('ml-1.5 rounded px-1.5 py-0.5 font-medium text-sm', theme.matchBadge)} title="Contains search matches - click to expand"> {hiddenMatchCount} {hiddenMatchCount === 1 ? 'match' : 'matches'} </span> )} diff --git a/frontend/src/modules/common/json-viewer/copy-button.tsx b/frontend/src/modules/common/json-viewer/copy-button.tsx index ac13e4643..9e1000796 100644 --- a/frontend/src/modules/common/json-viewer/copy-button.tsx +++ b/frontend/src/modules/common/json-viewer/copy-button.tsx @@ -16,11 +16,11 @@ export function CopyButton({ value }: CopyButtonProps) { return ( <button type="button" - className="ml-1 inline-flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 opacity-0 transition-opacity hover:bg-black/10 hover:opacity-100 group-hover/node:opacity-60 dark:hover:bg-white/10" + className="ml-1 inline-flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 opacity-0 transition-opacity hover:bg-foreground/10 hover:opacity-100 group-hover/node:opacity-60" onClick={handleCopy} title="Copy to clipboard" > - {copied ? <CheckIcon className="icon-xs" /> : <CopyIcon className="icon-xs" />} + {copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />} </button> ); } diff --git a/frontend/src/modules/common/json-viewer/json-node.tsx b/frontend/src/modules/common/json-viewer/json-node.tsx index 295936b27..e34bbca1f 100644 --- a/frontend/src/modules/common/json-viewer/json-node.tsx +++ b/frontend/src/modules/common/json-viewer/json-node.tsx @@ -1,5 +1,6 @@ import { ChevronRightIcon } from 'lucide-react'; import { memo, useEffect, useState } from 'react'; +import { cn } from '~/utils/cn'; import { CollapsedPreview } from './collapsed-preview'; import { useJsonViewerContext } from './context'; import { CopyButton } from './copy-button'; @@ -104,7 +105,7 @@ export const JsonNode = memo( (typeof keyName === 'number' ? ( <span className={theme.index}>{keyName}</span> ) : ( - <span className={`font-medium ${theme.key}`}>{showKeyQuotes ? `"${keyName}"` : keyName}</span> + <span className={cn('font-medium', theme.key)}>{showKeyQuotes ? `"${keyName}"` : keyName}</span> ))} {keyName !== false && <span className="mr-1 opacity-70">:</span>} <CustomComponent value={value} path={path} /> @@ -118,20 +119,9 @@ export const JsonNode = memo( const isObjectValue = valueType === 'object'; const hasSelfRequired = - openapiMode === 'schema' && - typeof value === 'object' && - value !== null && - (value as Record<string, unknown>).required === true; + openapiMode === 'schema' && typeof value === 'object' && value !== null && (value as Record<string, unknown>).required === true; - const keyProps = { - keyName, - showKeyQuotes, - searchText, - isObjectValue, - hasSelfRequired, - openapiMode, - theme, - }; + const keyProps = { keyName, showKeyQuotes, searchText, isObjectValue, hasSelfRequired, openapiMode, theme }; if (valueType !== 'object' && valueType !== 'array') { return ( @@ -169,8 +159,7 @@ export const JsonNode = memo( return null; })(); - const canExtractLabels = - openapiMode === 'schema' && !isArray && !isInsideProperties && typeof value === 'object' && value !== null; + const canExtractLabels = openapiMode === 'schema' && !isArray && !isInsideProperties && typeof value === 'object' && value !== null; const valueObj = canExtractLabels ? (value as Record<string, unknown>) : null; // anyOf/oneOf render as a type label. @@ -187,9 +176,7 @@ export const JsonNode = memo( // Rendered as a label and filtered out of the entries below. const contentTypeValue = - openapiMode === 'schema' && !isInsideProperties && valueObj && typeof valueObj.contentType === 'string' - ? valueObj.contentType - : null; + openapiMode === 'schema' && !isInsideProperties && valueObj && typeof valueObj.contentType === 'string' ? valueObj.contentType : null; // Rendered inline and filtered out of the entries below. const constraints = (() => { @@ -213,15 +200,9 @@ export const JsonNode = memo( // Array schemas hoist items.properties. const isArraySchema = - openapiMode === 'schema' && - !isArray && - typeof value === 'object' && - value !== null && - (value as Record<string, unknown>).type === 'array'; + openapiMode === 'schema' && !isArray && typeof value === 'object' && value !== null && (value as Record<string, unknown>).type === 'array'; - const rawEntries = isArray - ? (value as unknown[]).map((v, i) => [i, v] as [number, unknown]) - : Object.entries(value as Record<string, unknown>); + const rawEntries = isArray ? (value as unknown[]).map((v, i) => [i, v] as [number, unknown]) : Object.entries(value as Record<string, unknown>); // Hide schema keys promoted into labels and hoist array-item properties. const filteredEntries = @@ -233,8 +214,7 @@ export const JsonNode = memo( key !== 'minLength' && key !== 'maximum' && key !== 'minimum' && - (isInsideProperties || - (key !== 'type' && key !== 'ref' && key !== 'contentType' && key !== 'additionalProperties')) && + (isInsideProperties || (key !== 'type' && key !== 'ref' && key !== 'contentType' && key !== 'additionalProperties')) && !(isArraySchema && key === 'items'), ) : rawEntries; @@ -283,11 +263,9 @@ export const JsonNode = memo( // Schema mode: an object is expandable only if it has nested object (not array) children; arrays are always expandable. const hasNestedObjects = - openapiMode === 'schema' && !isArray - ? entries.some(([, val]) => val !== null && typeof val === 'object' && !Array.isArray(val)) - : true; + openapiMode === 'schema' && !isArray ? entries.some(([, val]) => val !== null && typeof val === 'object' && !Array.isArray(val)) : true; - const bracketClass = `font-medium ${theme.bracket} group-data-[openapi-mode=schema]/jv:hidden`; + const bracketClass = cn('font-medium', theme.bracket, 'group-data-[openapi-mode=schema]/jv:hidden'); if (isEmpty) { return ( @@ -310,9 +288,7 @@ export const JsonNode = memo( } const isPrimitiveArray = - isArray && - singleLineArrays && - (value as unknown[]).every((item) => item === null || (typeof item !== 'object' && typeof item !== 'undefined')); + isArray && singleLineArrays && (value as unknown[]).every((item) => item === null || (typeof item !== 'object' && typeof item !== 'undefined')); if (isPrimitiveArray) { const items = value as unknown[]; @@ -345,7 +321,10 @@ export const JsonNode = memo( {!hideExpandHeader && ( // biome-ignore lint/a11y/useKeyWithClickEvents: developer-facing JSON tree viewer; expand/collapse is a visual affordance for mouse users. <div - className={`group/node -mx-1 -my-px inline-flex items-center gap-0.5 rounded px-1 py-px ${isExpandable ? 'cursor-pointer hover:bg-gray-100 dark:hover:bg-white/5' : 'pointer-events-none'}`} + className={cn( + 'group/node -mx-1 -my-px inline-flex items-center gap-0.5 rounded px-1 py-px', + isExpandable ? 'cursor-pointer hover:bg-accent/50' : 'pointer-events-none', + )} style={{ paddingLeft }} onClick={ isExpandable @@ -360,10 +339,8 @@ export const JsonNode = memo( : undefined } > - <span - className={`inline-flex h-4 w-4 shrink-0 items-center justify-center ${isExpandable ? 'opacity-60' : '-ml-3.5 opacity-0'}`} - > - <ChevronRightIcon className={`icon-sm transition-transform ${isExpanded ? 'rotate-90' : 'rotate-0'}`} /> + <span className={cn('inline-flex size-4 shrink-0 items-center justify-center', isExpandable ? 'opacity-60' : '-ml-3.5 opacity-0')}> + <ChevronRightIcon className={cn('size-3.5 transition-transform', isExpanded ? 'rotate-90' : 'rotate-0')} /> </span> <KeyRenderer {...keyProps} /> {keyName !== false && <span className="mr-1 opacity-70">:</span>} diff --git a/frontend/src/modules/common/json-viewer/json-viewer.stories.tsx b/frontend/src/modules/common/json-viewer/json-viewer.stories.tsx index 460af85a4..cea985f0b 100644 --- a/frontend/src/modules/common/json-viewer/json-viewer.stories.tsx +++ b/frontend/src/modules/common/json-viewer/json-viewer.stories.tsx @@ -1,33 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; -import { expect, userEvent, waitFor } from 'storybook/test'; +import { expect, spyOn, userEvent, waitFor } from 'storybook/test'; import { JsonViewer } from '../json-viewer'; // Sample data for stories -const simpleObject = { - name: 'John Doe', - age: 30, - email: 'john@example.com', - active: true, - role: null, -}; +const simpleObject = { name: 'John Doe', age: 30, email: 'john@example.com', active: true, role: null }; const nestedObject = { user: { id: 1, - profile: { - firstName: 'John', - lastName: 'Doe', - settings: { - theme: 'dark', - notifications: true, - }, - }, - }, - metadata: { - createdAt: '2024-01-15T10:30:00Z', - version: '1.0.0', + profile: { firstName: 'John', lastName: 'Doe', settings: { theme: 'dark', notifications: true } }, }, + metadata: { createdAt: '2024-01-15T10:30:00Z', version: '1.0.0' }, }; const arrayData = { @@ -57,17 +41,12 @@ const openApiSchemaData = { email: { type: 'string', format: 'email', required: true }, age: { type: 'integer', required: false }, active: { type: 'boolean', required: false }, - roles: { - type: 'array', - items: { type: 'string' }, - }, + roles: { type: 'array', items: { type: 'string' } }, profile: { type: 'object', required: false, ref: '#/components/schemas/Profile', - properties: { - bio: { type: 'string', required: false }, - }, + properties: { bio: { type: 'string', required: false } }, }, }, }; @@ -80,59 +59,20 @@ const meta = { title: 'common/JsonViewer', component: JsonViewer, tags: ['autodocs'], - parameters: { - layout: 'padded', - }, + parameters: { layout: 'padded' }, argTypes: { - value: { - control: 'object', - description: 'The JSON data to display', - }, - defaultInspectDepth: { - control: { type: 'number', min: 0, max: 10 }, - description: 'Default depth to expand nodes', - }, - rootName: { - control: 'text', - description: 'Root node name (false to hide)', - }, - displayDataTypes: { - control: 'boolean', - description: 'Show data type labels next to values', - }, - enableClipboard: { - control: 'boolean', - description: 'Enable copy to clipboard button', - }, - indentWidth: { - control: { type: 'number', min: 1, max: 8 }, - description: 'Indentation width in characters', - }, - collapseStringsAfterLength: { - control: { type: 'number', min: 10, max: 200 }, - description: 'Truncate strings after this length', - }, - openapiMode: { - control: 'select', - options: [undefined, 'spec', 'schema'], - description: 'OpenAPI display mode', - }, - searchText: { - control: 'text', - description: 'Text to search and highlight', - }, - expandAll: { - control: 'boolean', - description: 'Expand all nodes', - }, - showKeyQuotes: { - control: 'boolean', - description: 'Show quotes around object keys', - }, - expandChildrenDepth: { - control: { type: 'number', min: 1, max: 5 }, - description: 'Levels to expand when clicking a node', - }, + value: { control: 'object', description: 'The JSON data to display' }, + defaultInspectDepth: { control: { type: 'number', min: 0, max: 10 }, description: 'Default depth to expand nodes' }, + rootName: { control: 'text', description: 'Root node name (false to hide)' }, + displayDataTypes: { control: 'boolean', description: 'Show data type labels next to values' }, + enableClipboard: { control: 'boolean', description: 'Enable copy to clipboard button' }, + indentWidth: { control: { type: 'number', min: 1, max: 8 }, description: 'Indentation width in characters' }, + collapseStringsAfterLength: { control: { type: 'number', min: 10, max: 200 }, description: 'Truncate strings after this length' }, + openapiMode: { control: 'select', options: [undefined, 'spec', 'schema'], description: 'OpenAPI display mode' }, + searchText: { control: 'text', description: 'Text to search and highlight' }, + expandAll: { control: 'boolean', description: 'Expand all nodes' }, + showKeyQuotes: { control: 'boolean', description: 'Show quotes around object keys' }, + expandChildrenDepth: { control: { type: 'number', min: 1, max: 5 }, description: 'Levels to expand when clicking a node' }, }, args: { value: simpleObject, @@ -155,133 +95,67 @@ type Story = StoryObj<typeof meta>; /** * Default JSON viewer with a simple object. */ -export const Default: Story = { - args: { - value: simpleObject, - }, -}; +export const Default: Story = { args: { value: simpleObject } }; /** * Nested object with multiple levels of depth. */ -export const NestedObject: Story = { - args: { - value: nestedObject, - defaultInspectDepth: 2, - }, -}; +export const NestedObject: Story = { args: { value: nestedObject, defaultInspectDepth: 2 } }; /** * Arrays and collections display. */ -export const WithArrays: Story = { - args: { - value: arrayData, - defaultInspectDepth: 3, - }, -}; +export const WithArrays: Story = { args: { value: arrayData, defaultInspectDepth: 3 } }; /** * Single-line primitive arrays in schema mode. */ -export const SingleLineArrays: Story = { - args: { - value: arrayData, - openapiMode: 'schema', - defaultInspectDepth: 3, - }, -}; +export const SingleLineArrays: Story = { args: { value: arrayData, openapiMode: 'schema', defaultInspectDepth: 3 } }; /** * Display with data type labels shown. */ -export const WithDataTypes: Story = { - args: { - value: simpleObject, - displayDataTypes: true, - }, -}; +export const WithDataTypes: Story = { args: { value: simpleObject, displayDataTypes: true } }; /** * Copy to clipboard enabled on hover. */ -export const WithClipboard: Story = { - args: { - value: nestedObject, - enableClipboard: true, - }, -}; +export const WithClipboard: Story = { args: { value: nestedObject, enableClipboard: true } }; /** * Long strings are truncated with click to expand. */ -export const LongStrings: Story = { - args: { - value: longStringData, - collapseStringsAfterLength: 50, - }, -}; +export const LongStrings: Story = { args: { value: longStringData, collapseStringsAfterLength: 50 } }; /** * Keys displayed without quotes for cleaner look. */ -export const WithoutKeyQuotes: Story = { - args: { - value: simpleObject, - showKeyQuotes: false, - }, -}; +export const WithoutKeyQuotes: Story = { args: { value: simpleObject, showKeyQuotes: false } }; /** * Hidden root name for embedding in other contexts. */ -export const HiddenRootName: Story = { - args: { - value: simpleObject, - rootName: false, - }, -}; +export const HiddenRootName: Story = { args: { value: simpleObject, rootName: false } }; /** * Custom root name. */ -export const CustomRootName: Story = { - args: { - value: simpleObject, - rootName: 'userData', - }, -}; +export const CustomRootName: Story = { args: { value: simpleObject, rootName: 'userData' } }; /** * All nodes expanded regardless of depth. */ -export const ExpandAll: Story = { - args: { - value: nestedObject, - expandAll: true, - }, -}; +export const ExpandAll: Story = { args: { value: nestedObject, expandAll: true } }; /** * Shallow default expansion (depth 1). */ -export const ShallowExpansion: Story = { - args: { - value: nestedObject, - defaultInspectDepth: 1, - }, -}; +export const ShallowExpansion: Story = { args: { value: nestedObject, defaultInspectDepth: 1 } }; /** * Wider indentation for better readability. */ -export const WideIndent: Story = { - args: { - value: nestedObject, - indentWidth: 4, - defaultInspectDepth: 4, - }, -}; +export const WideIndent: Story = { args: { value: nestedObject, indentWidth: 4, defaultInspectDepth: 4 } }; /** * Interactive search with highlighting. @@ -304,34 +178,18 @@ export const WithSearch: Story = { </div> ); }, - args: { - value: nestedObject, - defaultInspectDepth: 4, - }, + args: { value: nestedObject, defaultInspectDepth: 4 }, }; /** * OpenAPI schema mode with type labels and required indicators. */ -export const OpenApiSchemaMode: Story = { - args: { - value: openApiSchemaData, - openapiMode: 'schema', - showKeyQuotes: false, - defaultInspectDepth: 4, - }, -}; +export const OpenApiSchemaMode: Story = { args: { value: openApiSchemaData, openapiMode: 'schema', showKeyQuotes: false, defaultInspectDepth: 4 } }; /** * Cascade expand multiple levels on click. */ -export const CascadeExpand: Story = { - args: { - value: nestedObject, - defaultInspectDepth: 1, - expandChildrenDepth: 3, - }, -}; +export const CascadeExpand: Story = { args: { value: nestedObject, defaultInspectDepth: 1, expandChildrenDepth: 3 } }; /** * Complex real-world API response structure. @@ -345,48 +203,23 @@ export const ComplexApiResponse: Story = { { id: 'usr_123', email: 'alice@example.com', - profile: { - firstName: 'Alice', - lastName: 'Smith', - avatar: 'https://example.com/avatars/alice.jpg', - }, + profile: { firstName: 'Alice', lastName: 'Smith', avatar: 'https://example.com/avatars/alice.jpg' }, permissions: ['read', 'write', 'admin'], createdAt: '2024-01-15T10:30:00Z', - metadata: { - lastLogin: '2024-06-20T14:22:00Z', - loginCount: 42, - verified: true, - }, + metadata: { lastLogin: '2024-06-20T14:22:00Z', loginCount: 42, verified: true }, }, { id: 'usr_456', email: 'bob@example.com', - profile: { - firstName: 'Bob', - lastName: 'Jones', - avatar: null, - }, + profile: { firstName: 'Bob', lastName: 'Jones', avatar: null }, permissions: ['read'], createdAt: '2024-03-22T08:15:00Z', - metadata: { - lastLogin: '2024-06-19T09:45:00Z', - loginCount: 7, - verified: false, - }, + metadata: { lastLogin: '2024-06-19T09:45:00Z', loginCount: 7, verified: false }, }, ], - pagination: { - page: 1, - pageSize: 20, - total: 2, - hasMore: false, - }, - }, - meta: { - requestId: 'req_abc123', - timestamp: '2024-06-21T12:00:00Z', - version: 'v1', + pagination: { page: 1, pageSize: 20, total: 2, hasMore: false }, }, + meta: { requestId: 'req_abc123', timestamp: '2024-06-21T12:00:00Z', version: 'v1' }, }, defaultInspectDepth: 2, enableClipboard: true, @@ -406,10 +239,7 @@ export const EdgeCases: Story = { zero: 0, emptyString: '', falseBoolean: false, - nestedEmpty: { - inner: {}, - list: [], - }, + nestedEmpty: { inner: {}, list: [] }, }, defaultInspectDepth: 3, }, @@ -436,15 +266,7 @@ export const AllFeatures: Story = { </div> ); }, - args: { - value: { - ...nestedObject, - ...arrayData, - config: longStringData, - }, - defaultInspectDepth: 2, - showKeyQuotes: false, - }, + args: { value: { ...nestedObject, ...arrayData, config: longStringData }, defaultInspectDepth: 2, showKeyQuotes: false }, }; // Interaction tests @@ -455,11 +277,7 @@ export const AllFeatures: Story = { export const ShouldExpandOnClick: Story = { name: 'when collapsed node is clicked, should expand to show children', tags: ['!dev', '!autodocs'], - args: { - value: nestedObject, - defaultInspectDepth: 1, - rootName: false, - }, + args: { value: nestedObject, defaultInspectDepth: 1, rootName: false }, play: async ({ canvas, step }) => { await step('Click to expand user node', async () => { const userNode = await canvas.findByText('"user"'); @@ -476,11 +294,7 @@ export const ShouldExpandOnClick: Story = { export const ShouldCollapseOnClick: Story = { name: 'when expanded node is clicked, should collapse', tags: ['!dev', '!autodocs'], - args: { - value: nestedObject, - defaultInspectDepth: 2, - rootName: false, - }, + args: { value: nestedObject, defaultInspectDepth: 2, rootName: false }, play: async ({ canvas, step }) => { // First verify it's expanded await waitFor(() => expect(canvas.queryByText('"id"')).toBeVisible()); @@ -494,28 +308,27 @@ export const ShouldCollapseOnClick: Story = { }; /** - * Tests that the copy button appears on hover and copies content. + * Tests that the copy button stays faded until its node is hovered and copies content. */ export const ShouldShowCopyOnHover: Story = { name: 'when node is hovered, should show copy button', tags: ['!dev', '!autodocs'], - args: { - value: simpleObject, - enableClipboard: true, - rootName: false, - defaultInspectDepth: 0, - }, + args: { value: simpleObject, enableClipboard: true, rootName: false, defaultInspectDepth: 0 }, play: async ({ canvas, step }) => { - await step('Hover over node to show copy button', async () => { - const rootNode = await canvas.findByText('5 items'); - await userEvent.hover(rootNode); - await waitFor(() => expect(canvas.getByTitle('Copy to clipboard')).toBeVisible()); + const copyButton = await canvas.findByTitle('Copy to clipboard'); + + await step('Copy button is faded out inside the hover group of its node', async () => { + // The reveal is a CSS group-hover; synthetic pointer events do not set :hover, so the resting state is checked. + await expect(copyButton).not.toBeVisible(); + await expect(copyButton.closest('.group\\/node')).toHaveTextContent('5 items'); }); - await step('Unhover to hide copy button', async () => { - const rootNode = await canvas.findByText('5 items'); - await userEvent.unhover(rootNode); - // Copy button should become invisible (opacity-0) + await step('Click copies the node and shows a check', async () => { + // A synthetic click grants no user activation, which the real clipboard requires. + const writeText = spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + await userEvent.click(copyButton); + await expect(writeText).toHaveBeenCalledWith(JSON.stringify(simpleObject, null, 2)); + await waitFor(() => expect(copyButton.querySelector('.lucide-check')).not.toBeNull()); }); }, }; @@ -526,12 +339,7 @@ export const ShouldShowCopyOnHover: Story = { export const ShouldExpandLongString: Story = { name: 'when truncated string is clicked, should expand full text', tags: ['!dev', '!autodocs'], - args: { - value: longStringData, - collapseStringsAfterLength: 30, - defaultInspectDepth: 2, - rootName: false, - }, + args: { value: longStringData, collapseStringsAfterLength: 30, defaultInspectDepth: 2, rootName: false }, play: async ({ canvas, step }) => { await step('Verify string is truncated', async () => { // Should show truncated text with ellipsis (multiple strings are truncated) @@ -556,12 +364,7 @@ export const ShouldExpandLongString: Story = { export const ShouldHighlightSearchMatches: Story = { name: 'when search text matches, should highlight matches', tags: ['!dev', '!autodocs'], - args: { - value: simpleObject, - searchText: 'john', - defaultInspectDepth: 2, - rootName: false, - }, + args: { value: simpleObject, searchText: 'john', defaultInspectDepth: 2, rootName: false }, play: async ({ canvas, step }) => { await step('Verify search match is highlighted', async () => { // The "John" text should have highlight styling applied @@ -600,13 +403,7 @@ export const ShouldRenderSingleLineArrays: Story = { export const ShouldShowTypeLabelsInSchemaMode: Story = { name: 'when openapiMode is schema, should show type labels', tags: ['!dev', '!autodocs'], - args: { - value: openApiSchemaData, - openapiMode: 'schema', - defaultInspectDepth: 2, - rootName: false, - showKeyQuotes: false, - }, + args: { value: openApiSchemaData, openapiMode: 'schema', defaultInspectDepth: 2, rootName: false, showKeyQuotes: false }, play: async ({ canvas, step }) => { await step('Verify type label is displayed', async () => { // Should show "object" type label (from nested profile property) diff --git a/frontend/src/modules/common/json-viewer/json-viewer.tsx b/frontend/src/modules/common/json-viewer/json-viewer.tsx index 5ed05d756..b0e616339 100644 --- a/frontend/src/modules/common/json-viewer/json-viewer.tsx +++ b/frontend/src/modules/common/json-viewer/json-viewer.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useRef, useState } from 'react'; +import { cn } from '~/utils/cn'; import { JsonViewerContext, type JsonViewerContextValue } from './context'; import { JsonNode } from './json-node'; import type { DataType, JsonViewerProps } from './types'; @@ -99,10 +100,7 @@ export function JsonViewer({ }, 200); }, []); - const refDataType = useMemo( - () => (openapiMode === 'spec' ? createRefDataType(handleRefNavigate) : null), - [openapiMode, handleRefNavigate], - ); + const refDataType = useMemo(() => (openapiMode === 'spec' ? createRefDataType(handleRefNavigate) : null), [openapiMode, handleRefNavigate]); const combinedValueTypes = useMemo(() => { if (refDataType) { @@ -150,11 +148,7 @@ export function JsonViewer({ } `} </style> - <div - ref={containerRef} - data-openapi-mode={openapiMode} - className={`group/jv font-mono text-gray-900 text-sm leading-relaxed dark:text-gray-100 ${className || ''}`} - > + <div ref={containerRef} data-openapi-mode={openapiMode} className={cn('group/jv font-mono text-foreground text-sm leading-relaxed', className)}> <JsonNode value={value} path={[]} keyName={rootName} depth={0} /> </div> </JsonViewerContext.Provider> diff --git a/frontend/src/modules/common/json-viewer/key-renderer.tsx b/frontend/src/modules/common/json-viewer/key-renderer.tsx index 6dfa8ae85..1a73347fb 100644 --- a/frontend/src/modules/common/json-viewer/key-renderer.tsx +++ b/frontend/src/modules/common/json-viewer/key-renderer.tsx @@ -1,3 +1,5 @@ +import { cn } from '~/utils/cn'; + interface KeyRendererProps { keyName?: string | number | false; showKeyQuotes: boolean; @@ -5,23 +7,10 @@ interface KeyRendererProps { isObjectValue: boolean; hasSelfRequired: boolean; openapiMode?: 'spec' | 'schema'; - theme: { - key: string; - index: string; - required: string; - searchMatch: string; - }; + theme: { key: string; index: string; required: string; searchMatch: string }; } -export function KeyRenderer({ - keyName, - showKeyQuotes, - searchText, - isObjectValue, - hasSelfRequired, - openapiMode, - theme, -}: KeyRendererProps) { +export function KeyRenderer({ keyName, showKeyQuotes, searchText, isObjectValue, hasSelfRequired, openapiMode, theme }: KeyRendererProps) { if (keyName === false || keyName === undefined) return null; const keyStr = String(keyName); @@ -30,9 +19,7 @@ export function KeyRenderer({ // Dictionary key from additionalProperties, written as [key]. const isDictionaryKey = openapiMode === 'schema' && keyStr.startsWith('[') && keyStr.endsWith(']'); - const requiredLabel = hasSelfRequired && ( - <span className={`ml-1.5 rounded px-1 py-0.5 font-medium text-xs ${theme.required}`}>required</span> - ); + const requiredLabel = hasSelfRequired && <span className={cn('ml-1.5 rounded px-1 py-0.5 font-medium text-xs', theme.required)}>required</span>; if (typeof keyName === 'number') { return <span className={theme.index}>{keyName}</span>; @@ -41,7 +28,12 @@ export function KeyRenderer({ return ( <> <span - className={`font-medium ${theme.key} ${isMatch ? theme.searchMatch : ''} ${isDictionaryKey ? 'text-foreground/40! italic' : openapiMode === 'schema' && !isObjectValue ? 'text-foreground/40!' : ''}`} + className={cn( + 'font-medium', + theme.key, + isMatch && theme.searchMatch, + isDictionaryKey ? 'text-muted-foreground/70! italic' : openapiMode === 'schema' && !isObjectValue ? 'text-muted-foreground/70!' : '', + )} data-search-match={isMatch ? 'true' : undefined} > {showKeyQuotes && !isDictionaryKey ? `"${keyName}"` : keyName} diff --git a/frontend/src/modules/common/json-viewer/primitive-value.tsx b/frontend/src/modules/common/json-viewer/primitive-value.tsx index fc9813096..1a2807682 100644 --- a/frontend/src/modules/common/json-viewer/primitive-value.tsx +++ b/frontend/src/modules/common/json-viewer/primitive-value.tsx @@ -1,4 +1,7 @@ import { useState } from 'react'; +import { cn } from '~/utils/cn'; +import { tw } from '~/utils/tw'; +import { getTypeColorClass, type JsonViewerTheme } from './types'; import { highlightText, JSON_SCHEMA_TYPES } from './utils'; interface InlinePrimitiveValueProps { @@ -62,29 +65,15 @@ export function InlinePrimitiveValue({ value, theme, searchText }: InlinePrimiti interface PrimitiveValueProps { value: unknown; type: string; - theme: { - string: string; - number: string; - boolean: string; - null: string; - schemaType: string; - searchMatch: string; - }; + theme: Pick<JsonViewerTheme, 'string' | 'number' | 'boolean' | 'null' | 'schemaType' | 'structureType' | 'searchMatch'>; collapseStringsAfterLength: number; searchText: string; openapiMode?: 'spec' | 'schema'; } -export function PrimitiveValue({ - value, - type, - theme, - collapseStringsAfterLength, - searchText, - openapiMode, -}: PrimitiveValueProps) { +export function PrimitiveValue({ value, type, theme, collapseStringsAfterLength, searchText, openapiMode }: PrimitiveValueProps) { const [isExpanded, setIsExpanded] = useState(false); - const baseClass = 'break-word whitespace-pre-line'; + const baseClass = tw('wrap-break-word whitespace-pre-line'); switch (type) { case 'string': { @@ -92,17 +81,7 @@ export function PrimitiveValue({ // Schema-mode type keywords render unquoted and in their type color. if (openapiMode === 'schema' && JSON_SCHEMA_TYPES.has(str)) { - const typeClass = - str === 'string' - ? theme.string - : str === 'number' || str === 'integer' - ? theme.number - : str === 'boolean' - ? theme.boolean - : str === 'null' - ? theme.null - : 'text-purple-600 dark:text-purple-400'; // for array/object - return <span className={`${baseClass} ${theme.schemaType} ${typeClass}`}>{str}</span>; + return <span className={cn(baseClass, theme.schemaType, getTypeColorClass(str, theme))}>{str}</span>; } const shouldTruncate = str.length > collapseStringsAfterLength; @@ -112,16 +91,12 @@ export function PrimitiveValue({ return ( // biome-ignore lint/a11y/useKeyWithClickEvents: developer-facing JSON viewer; expanding a truncated string is a visual mouse affordance. <span - className={`${baseClass} inline-block max-w-[600px] align-top ${shouldTruncate ? 'cursor-pointer' : ''}`} + className={cn(baseClass, 'inline-block max-w-[600px] align-top', shouldTruncate && 'cursor-pointer')} onClick={shouldTruncate ? () => setIsExpanded(!isExpanded) : undefined} title={shouldTruncate ? (isExpanded ? 'Click to collapse' : 'Click to expand') : undefined} > <span className="group-data-[openapi-mode=schema]/jv:hidden">"</span> - {isMatch ? ( - highlightText(displayValue, searchText, theme.string, theme.searchMatch) - ) : ( - <span className={theme.string}>{displayValue}</span> - )} + {isMatch ? highlightText(displayValue, searchText, theme.string, theme.searchMatch) : <span className={theme.string}>{displayValue}</span>} {!isExpanded && shouldTruncate && <span className="opacity-50">…</span>} <span className="group-data-[openapi-mode=schema]/jv:hidden">"</span> </span> @@ -132,11 +107,7 @@ export function PrimitiveValue({ const isMatch = searchText && numStr.includes(searchText); return ( <span className={baseClass}> - {isMatch ? ( - highlightText(numStr, searchText, theme.number, theme.searchMatch) - ) : ( - <span className={theme.number}>{numStr}</span> - )} + {isMatch ? highlightText(numStr, searchText, theme.number, theme.searchMatch) : <span className={theme.number}>{numStr}</span>} </span> ); } @@ -145,18 +116,14 @@ export function PrimitiveValue({ const isMatch = searchText && boolStr.toLowerCase().includes(searchText.toLowerCase()); return ( <span className={baseClass}> - {isMatch ? ( - highlightText(boolStr, searchText, theme.boolean, theme.searchMatch) - ) : ( - <span className={theme.boolean}>{boolStr}</span> - )} + {isMatch ? highlightText(boolStr, searchText, theme.boolean, theme.searchMatch) : <span className={theme.boolean}>{boolStr}</span>} </span> ); } case 'null': - return <span className={`${baseClass} ${theme.null}`}>null</span>; + return <span className={cn(baseClass, theme.null)}>null</span>; case 'undefined': - return <span className={`${baseClass} ${theme.null}`}>undefined</span>; + return <span className={cn(baseClass, theme.null)}>undefined</span>; default: return <span className={baseClass}>{String(value)}</span>; } diff --git a/frontend/src/modules/common/json-viewer/schema-labels.tsx b/frontend/src/modules/common/json-viewer/schema-labels.tsx index 229f4e226..325d26b34 100644 --- a/frontend/src/modules/common/json-viewer/schema-labels.tsx +++ b/frontend/src/modules/common/json-viewer/schema-labels.tsx @@ -1,3 +1,6 @@ +import { cn } from '~/utils/cn'; +import { getTypeColorClass, type JsonViewerTheme } from './types'; + interface SchemaLabelsProps { typeValue: string | string[] | null; refValue: string | null; @@ -5,43 +8,10 @@ interface SchemaLabelsProps { hasAnyOf?: boolean; hasOneOf?: boolean; constraints?: { maxLength?: number; minLength?: number; maximum?: number; minimum?: number } | null; - theme: { - string: string; - number: string; - boolean: string; - null: string; - schemaType: string; - }; -} - -function getTypeColorClass( - typeValue: string, - theme: { string: string; number: string; boolean: string; null: string }, -): string { - switch (typeValue) { - case 'string': - return theme.string; - case 'number': - case 'integer': - return theme.number; - case 'boolean': - return theme.boolean; - case 'null': - return theme.null; - default: - return 'text-purple-600 dark:text-purple-400'; // for array/object - } + theme: Pick<JsonViewerTheme, 'string' | 'number' | 'boolean' | 'null' | 'schemaType' | 'structureType'>; } -export function SchemaLabels({ - typeValue, - refValue, - contentTypeValue, - hasAnyOf, - hasOneOf, - constraints, - theme, -}: SchemaLabelsProps) { +export function SchemaLabels({ typeValue, refValue, contentTypeValue, hasAnyOf, hasOneOf, constraints, theme }: SchemaLabelsProps) { if (!typeValue && !refValue && !contentTypeValue && !hasAnyOf && !hasOneOf && !constraints) return null; const typeValues = typeValue ? (Array.isArray(typeValue) ? typeValue : [typeValue]) : []; @@ -53,25 +23,19 @@ export function SchemaLabels({ <> {typeValues.map((type, index) => ( <span key={type}> - <span - className={`ml-0.5 rounded px-1 py-0.5 font-medium text-xs opacity-70 ${theme.schemaType} ${getTypeColorClass(type, theme)}`} - > + <span className={cn('ml-0.5 rounded px-1 py-0.5 font-medium text-xs opacity-70', theme.schemaType, getTypeColorClass(type, theme))}> {type} </span> {index < typeValues.length - 1 && <span className="mx-1 opacity-50">|</span>} </span> ))} {compositionLabel && ( - <span className="ml-0.5 rounded bg-amber-500/10 px-1 py-0.5 font-medium text-amber-600 text-xs dark:text-amber-400"> - {compositionLabel} - </span> - )} - {refValue && ( - <span className="ml-0.5 rounded bg-primary/10 px-1 py-0.5 font-medium text-primary text-xs">{refValue}</span> + <span className="ml-0.5 rounded bg-amber-500/10 px-1 py-0.5 font-medium text-amber-600 text-xs dark:text-amber-400">{compositionLabel}</span> )} - {contentTypeValue && <span className="ml-1 text-foreground/40 text-xs italic">{contentTypeValue}</span>} + {refValue && <span className="ml-0.5 rounded bg-primary/10 px-1 py-0.5 font-medium text-primary text-xs">{refValue}</span>} + {contentTypeValue && <span className="ml-1 text-muted-foreground/70 text-xs italic">{contentTypeValue}</span>} {constraints && ( - <span className="ml-1.5 text-foreground/35 text-xs"> + <span className="ml-1.5 text-muted-foreground/70 text-xs"> {[ constraints.minLength != null && `min:${constraints.minLength}`, constraints.maxLength != null && `max:${constraints.maxLength}`, diff --git a/frontend/src/modules/common/json-viewer/types.ts b/frontend/src/modules/common/json-viewer/types.ts index 84674cc05..949c94989 100644 --- a/frontend/src/modules/common/json-viewer/types.ts +++ b/frontend/src/modules/common/json-viewer/types.ts @@ -60,6 +60,8 @@ export interface JsonViewerTheme { index: string; // Schema mode specific schemaType: string; + /** Type keywords other than the primitive ones (array, object) */ + structureType: string; required: string; // Search highlight searchMatch: string; @@ -70,12 +72,22 @@ export const defaultTheme: JsonViewerTheme = { string: 'text-foreground', number: 'text-amber-700 dark:text-amber-400', boolean: 'text-rose-600 dark:text-rose-400', - null: 'text-gray-500 dark:text-gray-500', + null: 'text-gray-500', key: 'text-emerald-700 dark:text-emerald-400', bracket: 'text-gray-700 dark:text-gray-300', index: 'text-gray-500 opacity-70 text-xs', schemaType: 'font-medium italic', + structureType: 'text-purple-600 dark:text-purple-400', required: 'bg-amber-100/50 dark:bg-amber-900/10 text-amber-700/60 dark:text-amber-200/60', searchMatch: 'bg-yellow-200 dark:bg-yellow-700 rounded px-0.5', matchBadge: 'bg-yellow-200 dark:bg-yellow-800 text-yellow-800 dark:text-yellow-200', }; + +/** Color class for a JSON Schema type keyword, shared by schema labels and schema-mode values. */ +export function getTypeColorClass(type: string, theme: Pick<JsonViewerTheme, 'string' | 'number' | 'boolean' | 'null' | 'structureType'>): string { + if (type === 'string') return theme.string; + if (type === 'number' || type === 'integer') return theme.number; + if (type === 'boolean') return theme.boolean; + if (type === 'null') return theme.null; + return theme.structureType; +} diff --git a/frontend/src/modules/common/json-viewer/utils.tsx b/frontend/src/modules/common/json-viewer/utils.tsx index d18337513..bacc770b3 100644 --- a/frontend/src/modules/common/json-viewer/utils.tsx +++ b/frontend/src/modules/common/json-viewer/utils.tsx @@ -84,12 +84,7 @@ export function getTypeLabel(value: unknown, type: string): string { export const JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object', 'null']); /** Wraps search matches in Tailwind-styled spans carrying data-search-match for scroll-to-match. */ -export const highlightText = ( - text: string, - searchText: string, - colorClass: string, - searchMatchClass: string, -): ReactNode => { +export const highlightText = (text: string, searchText: string, colorClass: string, searchMatchClass: string): ReactNode => { if (!searchText) return <span className={colorClass}>{text}</span>; const lowerText = text.toLowerCase(); diff --git a/frontend/src/modules/common/list-skeleton.tsx b/frontend/src/modules/common/list-skeleton.tsx index 722a1e883..a44340f79 100644 --- a/frontend/src/modules/common/list-skeleton.tsx +++ b/frontend/src/modules/common/list-skeleton.tsx @@ -15,13 +15,7 @@ export function ListSkeleton({ count = 3, cardHeight = 160, className }: ListSke const { hasStarted } = useMountedState(); return ( - <div - className={cn( - 'flex flex-col gap-4 transition-opacity duration-300', - hasStarted ? 'opacity-100' : 'opacity-0', - className, - )} - > + <div className={cn('flex flex-col gap-4 transition-opacity duration-300', hasStarted ? 'opacity-100' : 'opacity-0', className)}> {Array.from({ length: count }).map((_, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static keys are fine here as this is a skeleton <Skeleton key={index} className="w-full rounded-lg" style={{ height: `${cardHeight}px` }} /> diff --git a/frontend/src/modules/common/logo.tsx b/frontend/src/modules/common/logo.tsx index cb7e3a1bb..a1b6c0b07 100644 --- a/frontend/src/modules/common/logo.tsx +++ b/frontend/src/modules/common/logo.tsx @@ -29,13 +29,7 @@ export function Logo({ className, iconColor, textColor, height = 50, iconOnly = viewBox={`0 -5 ${iconOnly ? 150 : 400} 140`} > <title>Logo - + - + + {fallback} ); diff --git a/frontend/src/modules/common/overlay-providers.test.tsx b/frontend/src/modules/common/overlay-providers.test.tsx new file mode 100644 index 000000000..e00a8f76a --- /dev/null +++ b/frontend/src/modules/common/overlay-providers.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Dialoger } from '~/modules/common/dialoger/provider'; +import { type DialogData, useDialoger } from '~/modules/common/dialoger/use-dialoger'; +import { Dropdowner } from '~/modules/common/dropdowner/provider'; +import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; +import { Sheeter } from '~/modules/common/sheeter/provider'; +import { type SheetData, useSheeter } from '~/modules/common/sheeter/use-sheeter'; +import { useNavigationStore } from '~/modules/navigation/navigation-store'; +import { useUIStore } from '~/modules/ui/ui-store'; + +type BeforeLoad = (event: { pathChanged: boolean }) => void; + +const router = vi.hoisted(() => ({ listeners: new Set() })); + +vi.mock('~/routes/-router-instance', () => ({ + getRouter: () => ({ + subscribe: (event: string, listener: BeforeLoad) => { + if (event !== 'onBeforeLoad') throw new Error(`unexpected router event ${event}`); + router.listeners.add(listener); + return () => router.listeners.delete(listener); + }, + }), +})); + +// The providers are under test here, not the overlay shells they render. +vi.mock('~/modules/common/dialoger/dialog', () => ({ DialogerDialog: () => null })); +vi.mock('~/modules/common/dialoger/drawer', () => ({ DialogerDrawer: () => null })); +vi.mock('~/modules/common/sheeter/sheet', () => ({ SheeterSheet: () => null })); +vi.mock('~/modules/common/sheeter/drawer', () => ({ SheeterDrawer: () => null })); +vi.mock('~/modules/common/dropdowner/dropdown', () => ({ DropdownerDropdown: () => null })); +vi.mock('~/modules/common/dropdowner/drawer', () => ({ DropdownerDrawer: () => null })); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const dialog = (id: string, data: Partial = {}): DialogData => ({ id, triggerRef: createRef(), ...data }); +const sheet = (id: string, data: Partial = {}): SheetData => ({ id, side: 'right', triggerRef: createRef(), ...data }); + +const changeRoute = (pathChanged: boolean) => + act(() => { + for (const listener of router.listeners) listener({ pathChanged }); + }); + +const dialogIds = () => useDialoger.getState().dialogs.map((d) => d.id); +const sheetIds = () => useSheeter.getState().sheets.map((s) => s.id); +const locks = () => useUIStore.getState().uiLocks; + +let root: Root; + +beforeEach(async () => { + useDialoger.setState({ dialogs: [] }); + useSheeter.setState({ sheets: [] }); + useDropdowner.setState({ dropdown: null, lastRemovedTriggerId: null, lastRemovedAt: 0 }); + useNavigationStore.setState({ navSheetOpen: null, keepNavOpen: false }); + useUIStore.setState({ uiLocks: [] }); + + root = createRoot(document.createElement('div')); + await act(async () => + root.render( + <> + + + + , + ), + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe('overlay providers on a route change', () => { + it('subscribe once each for the dialoger and sheeter and unsubscribe on unmount', async () => { + expect(router.listeners.size).toBe(2); + + await act(async () => root.unmount()); + + expect(router.listeners.size).toBe(0); + root = createRoot(document.createElement('div')); + }); + + it('ignore a navigation that keeps the path', async () => { + await act(() => { + useDialoger.getState().create(null, dialog('d')); + useSheeter.getState().create(null, sheet('s')); + }); + + await changeRoute(false); + + expect(dialogIds()).toEqual(['d']); + expect(sheetIds()).toEqual(['s']); + }); + + it('dialoger closes every dialog and calls onClose with isCleanup', async () => { + const onClose = vi.fn(); + await act(() => { + useDialoger.getState().create(null, dialog('a', { onClose })); + useDialoger.getState().create(null, dialog('b', { onClose })); + }); + + await changeRoute(true); + + expect(dialogIds()).toEqual([]); + expect(onClose.mock.calls).toEqual([[true], [true]]); + }); + + it('sheeter closes route-bound sheets with isCleanup and keeps the others', async () => { + const onClose = vi.fn(); + await act(() => { + useSheeter.getState().create(null, sheet('route', { onClose })); + useSheeter.getState().create(null, sheet('pinned', { closeSheetOnRouteChange: false, onClose })); + }); + + await changeRoute(true); + + expect(sheetIds()).toEqual(['pinned']); + expect(onClose.mock.calls).toEqual([[true]]); + }); + + it('sheeter keeps the nav sheet while the nav is kept open', async () => { + const onClose = vi.fn(); + await act(() => { + useNavigationStore.setState({ navSheetOpen: 'menu' as never, keepNavOpen: true }); + useSheeter.getState().create(null, sheet('nav-sheet', { onClose })); + useSheeter.getState().create(null, sheet('other', { onClose })); + }); + + await changeRoute(true); + + expect(sheetIds()).toEqual(['nav-sheet']); + expect(onClose.mock.calls).toEqual([[true]]); + }); + + it('sheeter closes the nav sheet when the nav is not kept open', async () => { + const onClose = vi.fn(); + await act(() => { + useNavigationStore.setState({ navSheetOpen: 'menu' as never, keepNavOpen: false }); + useSheeter.getState().create(null, sheet('nav-sheet', { onClose })); + }); + + await changeRoute(true); + + expect(sheetIds()).toEqual([]); + expect(onClose.mock.calls).toEqual([[true]]); + }); + + it('dropdowner keeps its dropdown', async () => { + const triggerRef = { current: document.body.appendChild(document.createElement('button')) }; + await act(() => { + useDropdowner.getState().create(null, { id: 'dd', triggerId: 'dd', triggerRef }); + }); + + await changeRoute(true); + + expect(useDropdowner.getState().dropdown?.id).toBe('dd'); + }); +}); + +describe('overlay providers while open', () => { + it('lock the UI per overlay kind while one is open', async () => { + const triggerRef = { current: document.body.appendChild(document.createElement('button')) }; + await act(() => { + useDialoger.getState().create(null, dialog('d')); + useSheeter.getState().create(null, sheet('s')); + useDropdowner.getState().create(null, { id: 'dd', triggerId: 'dd', triggerRef }); + }); + + expect([...locks()].sort()).toEqual(['dialoger', 'dropdowner', 'sheeter']); + + await act(() => { + useDialoger.getState().remove(); + useDropdowner.getState().remove(); + }); + expect(locks()).toEqual(['sheeter']); + + await act(() => useSheeter.getState().remove()); + expect(locks()).toEqual([]); + }); + + it('mark the body with sheeter-open while a sheet is open', async () => { + expect(document.body.classList.contains('sheeter-open')).toBe(false); + + await act(() => { + useSheeter.getState().create(null, sheet('s')); + }); + expect(document.body.classList.contains('sheeter-open')).toBe(true); + + await act(() => useSheeter.getState().remove('s')); + expect(document.body.classList.contains('sheeter-open')).toBe(false); + }); + + it('drop sheeter-open when the provider unmounts with a sheet open', async () => { + await act(() => { + useSheeter.getState().create(null, sheet('s')); + }); + + await act(async () => root.unmount()); + + expect(document.body.classList.contains('sheeter-open')).toBe(false); + expect(locks()).toEqual([]); + root = createRoot(document.createElement('div')); + }); +}); diff --git a/frontend/src/modules/common/overlay-store-helpers.ts b/frontend/src/modules/common/overlay-store-helpers.ts new file mode 100644 index 000000000..6e2dbde10 --- /dev/null +++ b/frontend/src/modules/common/overlay-store-helpers.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react'; +import { asRecord } from 'shared/utils/as-record'; +import { useLatestRef } from '~/hooks/use-latest-ref'; +import { useUIStore } from '~/modules/ui/ui-store'; +import { fallbackContentRef } from '~/utils/fallback-content-ref'; + +type Closable = { onClose?: (isCleanup?: boolean) => void }; + +/** Spreads `data` over `defaults`, skipping undefined values: an option passed as undefined keeps its default. */ +export function withDefaults(defaults: D, data: T): D & T { + const merged = { ...asRecord(defaults) }; + for (const [key, value] of Object.entries(data)) if (value !== undefined) merged[key] = value; + return merged as D & T; +} + +/** Blurs a focused button or link and keeps it as the focus fallback: a modal sets aria-hidden on its ancestors. */ +export function blurAndStashTrigger() { + const active = document.activeElement; + if (!(active instanceof HTMLButtonElement || active instanceof HTMLAnchorElement)) return; + fallbackContentRef.current = active; + active.blur(); +} + +/** + * Commits `items` without `toRemove`, then runs their onClose. The store updates first: a callback that + * navigates from inside set() would interleave a router update with this one and render a stale frame. + */ +export function removeAndNotify(commit: (remaining: T[]) => void, items: T[], toRemove: T[], opts?: { isCleanup?: boolean }) { + if (!toRemove.length) return; + commit(items.filter((item) => !toRemove.includes(item))); + for (const item of toRemove) item.onClose?.(opts?.isCleanup); +} + +/** + * Lets a user-dismissed overlay play its exit animation: `close` runs `hide` (set `open: false`) and the entry is + * removed when Base UI reports the overlay closed. Pass `onOpenChangeComplete` to the Base UI root. Closes made + * through the store (`update`, `remove`, route changes) behave as before. + */ +export function useRemoveAfterExit(hide: () => void, remove: () => void) { + const pendingRemoval = useRef(false); + const removeRef = useLatestRef(remove); + + // Unmounting mid-exit (e.g. a breakpoint switch remounts the overlay) must not strand the closed entry. + useEffect( + () => () => { + if (pendingRemoval.current) removeRef.current(); + }, + [], + ); + + const close = () => { + pendingRemoval.current = true; + hide(); + }; + + const onOpenChangeComplete = (isOpen: boolean) => { + if (isOpen) pendingRemoval.current = false; + else if (pendingRemoval.current) { + pendingRemoval.current = false; + remove(); + } + }; + + return { close, onOpenChangeComplete }; +} + +/** Locks the UI for `source` while an overlay of that kind is open. */ +export function useOverlayLock(source: string, active: boolean) { + const lockUI = useUIStore((state) => state.lockUI); + const unlockUI = useUIStore((state) => state.unlockUI); + + useEffect(() => { + if (!active) return; + lockUI(source); + return () => unlockUI(source); + }, [active]); +} diff --git a/frontend/src/modules/common/page/aside.tsx b/frontend/src/modules/common/page/aside.tsx index c9163be21..c323ae5f5 100644 --- a/frontend/src/modules/common/page/aside.tsx +++ b/frontend/src/modules/common/page/aside.tsx @@ -35,7 +35,7 @@ export function PageAside({ tabs, className, setFocus }: Page return (
    {tabs.map(({ id, label, icon, resource }, index) => { - const btnClass = `${id.includes('delete') && 'text-red-600'} hover:bg-accent/50 w-full justify-start text-left`; + const btnClass = cn(id.includes('delete') && 'text-destructive', 'w-full justify-start text-left hover:bg-accent/50'); const Icon = icon; return ( ); })} diff --git a/frontend/src/modules/common/page/cover.tsx b/frontend/src/modules/common/page/cover.tsx index d20339774..0b8080422 100644 --- a/frontend/src/modules/common/page/cover.tsx +++ b/frontend/src/modules/common/page/cover.tsx @@ -6,6 +6,7 @@ import { appConfig } from 'shared'; import { toaster } from '~/modules/common/toaster/toaster'; import { useUploader } from '~/modules/common/uploader/use-uploader'; import { Button } from '~/modules/ui/button'; +import { cn } from '~/utils/cn'; import { numberToColorClass } from '~/utils/number-to-color-class'; export interface PageCoverProps { @@ -51,7 +52,7 @@ function PageCoverBase({ id, canUpdate, organizationId, url, coverUpdateCallback return (
    {canUpdate && appConfig.has.uploadEnabled && ( @@ -62,7 +63,7 @@ function PageCoverBase({ id, canUpdate, organizationId, url, coverUpdateCallback className="relative top-3 mx-auto opacity-50 hover:bg-secondary hover:opacity-80" onClick={openUploadDialog} > - + {t('c:upload_item', { item: t('c:cover').toLowerCase() })} )} diff --git a/frontend/src/modules/common/page/header.tsx b/frontend/src/modules/common/page/header.tsx index 2e086b365..91b52a42e 100644 --- a/frontend/src/modules/common/page/header.tsx +++ b/frontend/src/modules/common/page/header.tsx @@ -7,13 +7,7 @@ import { appConfig } from 'shared'; import { EntityAvatar } from '~/modules/common/entity-avatar'; import { PageCover, type PageCoverProps } from '~/modules/common/page/cover'; import type { EnrichedChannel } from '~/modules/entities/types'; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbSeparator, -} from '~/modules/ui/breadcrumb'; +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from '~/modules/ui/breadcrumb'; import { getChannelRoute, pageTopHashNav } from '~/utils/channel-route'; type PageHeaderProps = Omit & { @@ -45,9 +39,7 @@ export function PageHeader({ entity, panel, parents, parent, ...coverProps }: Pa type={entity.entityType} url={entity.thumbnailUrl} className={ - entity.entityType === 'user' - ? 'mx-3 -mt-13 h-26 w-26 rounded-full text-4xl shadow-[0_0_0_4px_rgba(0,0,0,0.1)]' - : 'm-2 h-12 w-12 text-xl' + entity.entityType === 'user' ? 'mx-3 -mt-13 size-26 rounded-full text-4xl shadow-[0_0_0_4px_rgba(0,0,0,0.1)]' : 'm-2 size-12 text-xl' } /> @@ -63,23 +55,21 @@ export function PageHeader({ entity, panel, parents, parent, ...coverProps }: Pa } > {crumb.name} - - + + ); })} - + {t(`c:${entity.entityType}`).toLowerCase()} - {appConfig.mode === 'development' && ( - {entity.id} - )} + {appConfig.mode === 'development' && {entity.id}} diff --git a/frontend/src/modules/common/page/local-tab-nav.tsx b/frontend/src/modules/common/page/local-tab-nav.tsx index 6cc00c3e5..048b28f59 100644 --- a/frontend/src/modules/common/page/local-tab-nav.tsx +++ b/frontend/src/modules/common/page/local-tab-nav.tsx @@ -1,9 +1,7 @@ -import { motion } from 'motion/react'; import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { nanoid } from 'shared/utils/nanoid'; -import { useMountedState } from '~/hooks/use-mounted-state'; import type { TKey } from '~/lib/i18n-locales'; +import { ActiveTabMarker, useTabIndicator } from '~/modules/common/page/tab-indicator'; import { type TabNavAvatar, TabNavShell } from '~/modules/common/page/tab-nav-shell'; import { getScrollParent } from '~/modules/common/sticky-box'; import { truncateMiddle } from '~/utils/truncate-middle'; @@ -25,10 +23,7 @@ interface Props { */ export function LocalTabNav({ tabs, activeId, onTabChange, title, avatar, className }: Props) { const { t } = useTranslation(); - const { hasStarted } = useMountedState(); - - const layoutId = useRef(nanoid()).current; - const tabRefs = useRef>({}); + const indicator = useTabIndicator(); // Zero-height sentinel above the bar: the scroll-reset target inside a sheet, where the // window-scoped useScrollReset() has no effect. const resetRef = useRef(null); @@ -43,16 +38,16 @@ export function LocalTabNav({ tabs, activeId, onTabChange, title, avatar, classN } }; + // The new tab's ActiveTabMarker scrolls it into view const select = (id: string) => { onTabChange(id); scrollToReset(); - tabRefs.current[id]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); }; return ( <>
    - + {tabs.map(({ id, label }) => { const isActive = id === activeId; return ( @@ -61,24 +56,12 @@ export function LocalTabNav({ tabs, activeId, onTabChange, title, avatar, classN // data-tab, not id="tab-…": PageTabNav uses those ids and may render on the page behind this sheet data-tab={id} type="button" - ref={(el) => { - if (el) tabRefs.current[id] = el; - }} className="focus-effect group relative rounded-sm px-2 py-3 font-medium opacity-70 ring-inset ring-offset-0 transition-opacity last:mr-4 hover:opacity-100 data-[active=true]:opacity-100 lg:px-4" data-active={isActive || undefined} onClick={() => select(id)} > - {truncateMiddle(t(label), 20)} - {isActive && hasStarted && ( - - )} - {isActive && !hasStarted && ( - - )} + {truncateMiddle(t(label), 20)} + {isActive && } ); })} diff --git a/frontend/src/modules/common/page/tab-indicator.tsx b/frontend/src/modules/common/page/tab-indicator.tsx new file mode 100644 index 000000000..d48c7842f --- /dev/null +++ b/frontend/src/modules/common/page/tab-indicator.tsx @@ -0,0 +1,120 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +/** 0.4s after a 0.1s hold; `ease` tracks a critically damped spring of that length. */ +const glideTiming: KeyframeAnimationOptions = { duration: 400, delay: 100, easing: 'ease', fill: 'backwards' }; + +type Geometry = { x: number; width: number }; + +const measure = (tab: HTMLElement): Geometry => ({ x: tab.offsetLeft, width: tab.offsetWidth }); + +/** The bar's geometry as painted, mid-glide included. */ +function paintedGeometry(bar: HTMLElement): Geometry { + const { a, e } = new DOMMatrixReadOnly(getComputedStyle(bar).transform); + return { x: e, width: a * bar.offsetWidth }; +} + +/** + * One underline bar per tab track, placed from the active tab's offsets and moved with `transform` only: + * the glide then runs on the compositor and keeps presenting while the tab switch mounts the next page. + * A switch sets the bar's width once and animates from the painted geometry through `scaleX`. + */ +function createTabIndicator() { + let bar: HTMLElement | null = null; + // Tabs that mounted an ActiveTabMarker, in mount order; the last one carries the bar + let shown: HTMLElement[] = []; + let placed: Geometry | null = null; + let glide: Animation | null = null; + let observer: ResizeObserver | null = null; + let observedTab: HTMLElement | null = null; + + const place = (animate: boolean) => { + if (!bar) return; + const tab = shown.at(-1); + bar.style.opacity = tab ? '1' : '0'; + if (!tab) return; + + if (tab !== observedTab) { + if (observedTab) observer?.unobserve(observedTab); + observer?.observe(tab); + observedTab = tab; + } + + const next = measure(tab); + const from = animate && placed && typeof bar.animate === 'function' ? paintedGeometry(bar) : null; + glide?.cancel(); + glide = null; + bar.style.width = `${next.width}px`; + bar.style.transform = `translateX(${next.x}px)`; + placed = next; + + if (!from || next.width <= 0 || (from.x === next.x && from.width === next.width)) return; + const start = `translateX(${from.x}px) scaleX(${from.width / next.width})`; + glide = bar.animate([{ transform: start }, { transform: bar.style.transform }], glideTiming); + }; + + // Viewport, font and label changes shift the offsets: follow them without a glide + const onResize = () => { + const tab = shown.at(-1); + if (!tab || !placed) return; + const next = measure(tab); + if (next.x !== placed.x || next.width !== placed.width) place(false); + }; + + return { + setBar: (el: HTMLElement | null) => { + observer?.disconnect(); + observer = null; + observedTab = null; + bar = el; + if (!el) return; + observer = new ResizeObserver(onResize); + if (el.parentElement) observer.observe(el.parentElement); + place(false); + }, + show: (tab: HTMLElement) => { + shown = [...shown.filter((item) => item !== tab), tab]; + place(true); + }, + // A switch hides the old tab before showing the new one in the same commit, so the bar glides on + hide: (tab: HTMLElement) => { + const carried = shown.at(-1) === tab; + shown = shown.filter((item) => item !== tab); + if (carried) place(true); + }, + }; +} + +export type TabIndicator = ReturnType; + +/** Stable indicator for one tab bar: TabNavShell renders its bar, ActiveTabMarker moves it. */ +export function useTabIndicator(): TabIndicator { + const [indicator] = useState(createTabIndicator); + return indicator; +} + +/** The bar, rendered once in the tab track; the track is its containing block and the tabs' offsetParent. */ +export function TabIndicatorBar({ indicator }: { indicator: TabIndicator }) { + return ( + + + + ); +} + +/** Rendered inside the active tab: moves the bar onto that tab and scrolls it into view. */ +export function ActiveTabMarker({ indicator }: { indicator: TabIndicator }) { + const ref = useRef(null); + + useLayoutEffect(() => { + const tab = ref.current?.parentElement; + if (!tab) return; + indicator.show(tab); + return () => indicator.hide(tab); + }, [indicator]); + + useEffect(() => { + ref.current?.parentElement?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + }, []); + + return
    - -
    {children}
    -
    +
    +
    + {indicator && } + {children} +
    +
    ); } diff --git a/frontend/src/modules/common/page/tab-nav.test.tsx b/frontend/src/modules/common/page/tab-nav.test.tsx index 21bc8d4cf..1adea4991 100644 --- a/frontend/src/modules/common/page/tab-nav.test.tsx +++ b/frontend/src/modules/common/page/tab-nav.test.tsx @@ -23,14 +23,7 @@ defineFrontendModule({ description: 'Registry tab test module.', tools: [ { slot: 'organization.tabs', id: 'reports', label: key('c:reports'), order: 15, render: () => null }, - { - slot: 'organization.tabs', - id: 'reports-admin', - label: key('c:reports'), - order: 16, - visibleTo: ['organization.admin'], - render: () => null, - }, + { slot: 'organization.tabs', id: 'reports-admin', label: key('c:reports'), order: 16, visibleTo: ['organization.admin'], render: () => null }, { slot: 'system.tabs', id: 'audit', label: key('c:audit'), order: 5, render: () => null }, ], }); @@ -66,11 +59,7 @@ describe('resolveNavTabs merges route-file and registry tabs', () => { expect(reports?.path).toBe('/org/$tool'); // The registry tab preserves the surface's own params and sets only the host's $tool id const params = reports?.params as (prev: Record) => Record; - expect(params({ tenantId: 't', organizationSlug: 'o' })).toEqual({ - tenantId: 't', - organizationSlug: 'o', - tool: 'reports', - }); + expect(params({ tenantId: 't', organizationSlug: 'o' })).toEqual({ tenantId: 't', organizationSlug: 'o', tool: 'reports' }); }); it('hides tabs whose requires/visibleTo condition is unmet', () => { diff --git a/frontend/src/modules/common/page/tab-nav.tsx b/frontend/src/modules/common/page/tab-nav.tsx index 2eeb9b706..88954f442 100644 --- a/frontend/src/modules/common/page/tab-nav.tsx +++ b/frontend/src/modules/common/page/tab-nav.tsx @@ -1,20 +1,13 @@ import type { AnyRoute } from '@tanstack/react-router'; import { Link, type LinkComponentProps, redirect, useNavigate, useRouterState } from '@tanstack/react-router'; -import { motion } from 'motion/react'; import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { ContextRole, SlotToolsConfig } from 'shared/tools-config'; -import { nanoid } from 'shared/utils/nanoid'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { useMountedState } from '~/hooks/use-mounted-state'; import type { TKey } from '~/lib/i18n-locales'; -import { - getSlotDescriptors, - isPlacementHidden, - type PlacementDescriptor, - type PlacementOverrides, - resolvePlacementList, -} from '~/lib/placements'; +import { getSlotDescriptors, isPlacementHidden, type PlacementDescriptor, type PlacementOverrides, resolvePlacementList } from '~/lib/placements'; +import { ActiveTabMarker, useTabIndicator } from '~/modules/common/page/tab-indicator'; import { type TabNavAvatar, TabNavShell } from '~/modules/common/page/tab-nav-shell'; import { useScrollReset } from '~/modules/common/scroll-reset'; import { getRouter } from '~/routes/-router-instance'; @@ -29,10 +22,7 @@ export type PageTab = { activeOptions?: LinkComponentProps['activeOptions']; }; -function hasRoute>( - routes: TRoutes, - routeId: string, -): routeId is Extract { +function hasRoute>(routes: TRoutes, routeId: string): routeId is Extract { return routeId in routes; } @@ -83,12 +73,7 @@ export function getNavTabCandidates(parentRouteId: string): NavCandidate[] { const navTab = route.options?.staticData?.navTab; if (!navTab) return null; // Cast: PageTab link props are the loose LinkComponentProps; `true` inherits current params - return { - ...navTab, - order: navTab.order ?? 0, - path: route.fullPath as PageTab['path'], - params: true as PageTab['params'], - }; + return { ...navTab, order: navTab.order ?? 0, path: route.fullPath as PageTab['path'], params: true as PageTab['params'] }; }) .filter((tab): tab is NavCandidate => tab !== null); @@ -162,9 +147,7 @@ export function guardNavTabs( if (!needsLanding) { const candidates = getNavTabCandidates(parentRouteId); const toolId = (deepest.params as { tool?: string } | undefined)?.tool; - const target = toolId - ? candidates.find((tab) => tab.id === toolId) - : candidates.find((tab) => tab.path === deepest.fullPath); + const target = toolId ? candidates.find((tab) => tab.id === toolId) : candidates.find((tab) => tab.path === deepest.fullPath); needsLanding = target !== undefined && isPlacementHidden(getTabsHost(parentRouteId), target, { slotConfig }); } if (!needsLanding) return; @@ -184,13 +167,12 @@ export function guardNavTabs( */ export function useNavTabRedirect(parentRouteId: string, options: ResolveNavTabsOptions = {}): void { const navigate = useNavigate(); - const leaf = useRouterState({ select: (state) => state.matches[state.matches.length - 1] }); + // Primitives only: the leaf match object is new on every navigation + const leafPath = useRouterState({ select: (state) => state.matches.at(-1)?.fullPath }); + const toolId = useRouterState({ select: (state) => (state.matches.at(-1)?.params as { tool?: string } | undefined)?.tool }); - const toolId = (leaf?.params as { tool?: string } | undefined)?.tool; const candidates = parentRouteId ? getNavTabCandidates(parentRouteId) : []; - const active = toolId - ? candidates.find((tab) => tab.id === toolId) - : candidates.find((tab) => tab.path === leaf?.fullPath); + const active = toolId ? candidates.find((tab) => tab.id === toolId) : candidates.find((tab) => tab.path === leafPath); const disabled = active !== undefined && isPlacementHidden(getTabsHost(parentRouteId), active, options); const target = disabled ? resolveNavTabs(parentRouteId, options)[0] : undefined; @@ -215,17 +197,7 @@ interface Props { className?: string; } -export function PageTabNav({ - tabs: explicitTabs, - parentRouteId, - grants, - pairs, - slotConfig, - title, - avatar, - fallbackToFirst, - className, -}: Props) { +export function PageTabNav({ tabs: explicitTabs, parentRouteId, grants, pairs, slotConfig, title, avatar, fallbackToFirst, className }: Props) { const { t } = useTranslation(); const isMobile = useBreakpointBelow('sm', false); const { hasStarted } = useMountedState(); @@ -236,8 +208,7 @@ export function PageTabNav({ // Forward off a tab this surface has disabled (explicit tab lists opt out of route derivation) useNavTabRedirect(explicitTabs ? '' : (parentRouteId ?? ''), { grants, pairs, slotConfig }); - const layoutId = useRef(nanoid()).current; - + const indicator = useTabIndicator(); const tabRefs = useRef>({}); useEffect(() => { @@ -246,58 +217,38 @@ export function PageTabNav({ const scrollToReset = useScrollReset(); - const scrollTabIntoView = (id: string) => { - const tab = tabRefs.current[id]; - tab?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); - }; - return ( - - {tabs.map( - ( - { id, path, label, search = {}, params = true, activeOptions = { exact: true, includeSearch: false } }, - index, - ) => ( - { - if (el) tabRefs.current[id] = el; - }} - resetScroll={false} - className="focus-effect group relative rounded-sm px-2 py-3 font-medium opacity-70 ring-inset ring-offset-0 transition-opacity last:mr-4 hover:opacity-100 data-[active=true]:opacity-100 lg:px-4" - to={path} - draggable={false} - data-active={fallbackToFirst && index === 0 ? true : undefined} - params={params} - search={search} - activeOptions={activeOptions} - activeProps={{ 'data-active': true }} - onClick={scrollToReset} - > - {({ isActive }) => { - const showAsActive = isActive || (fallbackToFirst && index === 0); - if (showAsActive) scrollTabIntoView(id); - - return ( - <> - {truncateMiddle(t(label), 20)} - {showAsActive && hasStarted && ( - - )} - {showAsActive && !hasStarted && ( - - )} - - ); - }} - - ), - )} + + {tabs.map(({ id, path, label, search = {}, params = true, activeOptions = { exact: true, includeSearch: false } }, index) => ( + { + if (el) tabRefs.current[id] = el; + }} + resetScroll={false} + className="focus-effect group relative rounded-sm px-2 py-3 font-medium opacity-70 ring-inset ring-offset-0 transition-opacity last:mr-4 hover:opacity-100 data-[active=true]:opacity-100 lg:px-4" + to={path} + draggable={false} + data-active={fallbackToFirst && index === 0 ? true : undefined} + params={params} + search={search} + activeOptions={activeOptions} + activeProps={{ 'data-active': true }} + onClick={scrollToReset} + > + {({ isActive }) => { + const showAsActive = isActive || (fallbackToFirst && index === 0); + + return ( + <> + {truncateMiddle(t(label), 20)} + {showAsActive && } + + ); + }} + + ))} ); } diff --git a/frontend/src/modules/common/popconfirm.tsx b/frontend/src/modules/common/popconfirm.tsx index e56442926..383aa7ae4 100644 --- a/frontend/src/modules/common/popconfirm.tsx +++ b/frontend/src/modules/common/popconfirm.tsx @@ -1,5 +1,8 @@ +import type { ReactNode } from 'react'; +import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; + interface Props { - children: React.ReactNode; + children: ReactNode; title: string; } @@ -11,3 +14,11 @@ export function PopConfirm({ children, title }: Props) {
    ); } + +/** + * Turns the open dropdown (e.g. a table row's "…" menu) into a confirmation panel on the same trigger. A menu cannot + * hold a form: its items close it and its focus handling drops the buttons, so the confirmation opens as a panel. + */ +export function openPopConfirm(title: string, children: ReactNode) { + useDropdowner.getState().update({ kind: 'panel', key: Date.now(), content: {children} }); +} diff --git a/frontend/src/modules/common/public-layout.tsx b/frontend/src/modules/common/public-layout.tsx index 708e75e57..ee0635a0a 100644 --- a/frontend/src/modules/common/public-layout.tsx +++ b/frontend/src/modules/common/public-layout.tsx @@ -1,11 +1,18 @@ import { Outlet } from '@tanstack/react-router'; +import { Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { appConfig } from 'shared'; import { Alerter } from '~/modules/common/alerter/alerter'; import { DownAlert } from '~/modules/common/alerter/down-alert'; import { Dialoger } from '~/modules/common/dialoger/provider'; import { Dropdowner } from '~/modules/common/dropdowner/provider'; import { ErrorNotice, type ErrorNoticeError } from '~/modules/common/error-notice'; import { Sheeter } from '~/modules/common/sheeter/provider'; +import { lazyNamed } from '~/utils/lazy-named'; + +// Development only: staging and tunnel keep the devtools but don't show public visitors a 🐞 +const DebugDropdown = + __DEV_TOOLS__ && appConfig.mode === 'development' ? lazyNamed(() => import('~/modules/common/debug-dropdown'), 'DebugDropdown') : () => null; /** * Layout for all public (unauthenticated) routes. The public SSE stream is mounted by `PublicContentLayout` @@ -27,6 +34,11 @@ export function PublicLayout() { + + {/* Bottom-left, above the docs sidebar but under the dev sign-in banner; below md it clears the docs floating nav */} + + +
    ); } diff --git a/frontend/src/modules/common/pull-to-refresh.tsx b/frontend/src/modules/common/pull-to-refresh.tsx index 57f277e7a..8f561d1a7 100644 --- a/frontend/src/modules/common/pull-to-refresh.tsx +++ b/frontend/src/modules/common/pull-to-refresh.tsx @@ -1,9 +1,16 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; +import { useLatestRef } from '~/hooks/use-latest-ref'; import { useUIStore } from '~/modules/ui/ui-store'; +import { cn } from '~/utils/cn'; // Hold the indicator still briefly, then glide it off-screen (ms). const exitHold = 100; const exitDuration = 450; +// Minimum swipe distance before the pull-to-refresh UI appears and starts counting (px). +const activationThreshold = 30; +// First 30px of pull just shows the empty circle, progress starts after that (px). +const emptyPhase = 30; type Phase = 'idle' | 'refreshing' | 'exiting'; @@ -22,133 +29,123 @@ function getScrollParent(el: Element | null): Element | null { type Props = { onRefresh: () => void | Promise; - /** Whether queries are currently fetching (from useIsFetching) */ - isFetching?: boolean; refreshThreshold?: number; maximumPullLength?: number; isDisabled?: boolean; }; -export function PullToRefresh({ - onRefresh, - isFetching = false, - refreshThreshold = 90, - maximumPullLength = 200, - isDisabled = false, -}: Props) { +export function PullToRefresh({ onRefresh, refreshThreshold = 90, maximumPullLength = 200, isDisabled = false }: Props) { + const queryClient = useQueryClient(); const [pullPosition, setPullPosition] = useState(0); const [phase, setPhase] = useState('idle'); const pullStartRef = useRef(null); const isDraggingRef = useRef(false); + // Mirrors pullPosition for the touch handlers, so they are not re-bound on every move + const pullPositionRef = useRef(0); // Whether any query actually fetched during the current refresh cycle. const sawFetchRef = useRef(false); + const onRefreshRef = useLatestRef(onRefresh); const isRefreshing = phase === 'refreshing'; // Indicator stays styled as a spinner through both refreshing and exiting. const isActive = phase !== 'idle'; const isPulling = pullPosition > 0; - // Disable when UI is locked (dialog, dropdown, sheet open) + // Disabled while an overlay is open (dialog, dropdown, sheet) const isUILocked = useUIStore((state) => state.uiLocks.length > 0); - if (isUILocked) isDisabled = true; + const disabled = isDisabled || isUILocked; - const startPull = useCallback( - (e: TouchEvent) => { - if (isDisabled) return; + // Watches the query cache only while refreshing, so fetches at other times never re-render this + useEffect(() => { + if (!isRefreshing) return; + const check = () => { + if (!sawFetchRef.current && queryClient.isFetching() > 0) sawFetchRef.current = true; + }; + check(); + return queryClient.getQueryCache().subscribe(check); + }, [isRefreshing, queryClient]); + + useEffect(() => { + if (disabled) return; - // Only start at the top of the touch target's scroll parent, or of the window - const target = e.target as Element | null; - const scrollParent = getScrollParent(target); - const scrollTop = scrollParent ? scrollParent.scrollTop : window.scrollY; - if (scrollTop > 0) return; + const setPull = (position: number) => { + pullPositionRef.current = position; + setPullPosition(position); + }; + + const cancelPull = () => { + pullStartRef.current = null; + isDraggingRef.current = false; + setPull(0); + }; + const startPull = (e: TouchEvent) => { const touch = e.targetTouches[0]; - const pullArea = window.innerHeight * 0.4; + if (!touch || touch.clientY > window.innerHeight * 0.4 || window.scrollY > 0) return; - if (touch.clientY <= pullArea) { - setPhase('idle'); // cancel any in-progress exit animation - pullStartRef.current = touch.screenY; - isDraggingRef.current = true; - } - }, - [isDisabled], - ); + // Only start at the top of the touch target's scroll parent; the walk reads styles, so it runs last + const scrollParent = getScrollParent(e.target as Element | null); + if (scrollParent && scrollParent.scrollTop > 0) return; - // Minimum swipe distance before the pull-to-refresh UI appears and starts counting - const activationThreshold = 30; + setPhase('idle'); // cancel any in-progress exit animation + pullStartRef.current = touch.screenY; + isDraggingRef.current = true; + }; - const onPull = useCallback( - (e: TouchEvent) => { - if (isDisabled || !isDraggingRef.current || pullStartRef.current === null) return; + const onPull = (e: TouchEvent) => { + if (!isDraggingRef.current || pullStartRef.current === null) return; const touch = e.targetTouches[0]; if (!touch) return; const rawDelta = touch.screenY - pullStartRef.current; - if (rawDelta < activationThreshold) { - setPullPosition(0); + setPull(0); return; } - const delta = rawDelta - activationThreshold; - const clamped = Math.max(0, Math.min(delta, maximumPullLength)); - - setPullPosition(clamped); - }, - [isDisabled, maximumPullLength], - ); - - const endPull = useCallback(() => { - if (isDisabled || !isDraggingRef.current) return; - - // Discount the empty-circle phase before comparing against the threshold - const pulledEnough = pullPosition - 30 >= refreshThreshold; - - pullStartRef.current = null; - isDraggingRef.current = false; - - if (!pulledEnough) { - setPullPosition(0); - return; - } - - // Enter the refreshing state immediately, even on routes without active query observers. - setPhase('refreshing'); - setPullPosition(0); - sawFetchRef.current = false; - - Promise.resolve(onRefresh()).finally(() => { - // Static routes have nothing to refetch, so skip the animation and hard-reload. - if (!sawFetchRef.current) { - window.location.reload(); - return; - } - setPhase('exiting'); - }); - }, [isDisabled, pullPosition, refreshThreshold, onRefresh]); - - useEffect(() => { - if (isRefreshing && isFetching) sawFetchRef.current = true; - }, [isRefreshing, isFetching]); - - useEffect(() => { - if (isDisabled) return; + setPull(Math.max(0, Math.min(rawDelta - activationThreshold, maximumPullLength))); + }; - // Non-passive so onPull can preventDefault; otherwise the browser's own pull-to-refresh wins - const options = { passive: false }; + const endPull = () => { + if (!isDraggingRef.current) return; + + // Discount the empty-circle phase before comparing against the threshold + const pulledEnough = pullPositionRef.current - emptyPhase >= refreshThreshold; + cancelPull(); + if (!pulledEnough) return; + + // Enter the refreshing state immediately, even on routes without active query observers. + setPhase('refreshing'); + sawFetchRef.current = false; + + Promise.resolve(onRefreshRef.current()).finally(() => { + // Static routes have nothing to refetch, so skip the animation and hard-reload. + if (!sawFetchRef.current) { + window.location.reload(); + return; + } + setPhase('exiting'); + }); + }; - window.addEventListener('touchstart', startPull, options); - window.addEventListener('touchmove', onPull, options); - window.addEventListener('touchend', endPull, options); + // Native pull-to-refresh is off through overscroll-none on html and body, so the listeners stay passive + window.addEventListener('touchstart', startPull, { passive: true }); + window.addEventListener('touchmove', onPull, { passive: true }); + window.addEventListener('touchend', endPull); + // A touch the browser takes over (scroll, system gesture) must not leave the pull half-drawn + window.addEventListener('touchcancel', cancelPull); return () => { window.removeEventListener('touchstart', startPull); window.removeEventListener('touchmove', onPull); window.removeEventListener('touchend', endPull); + window.removeEventListener('touchcancel', cancelPull); + // An overlay opening mid-pull disables this: drop the pull so it never freezes on screen + cancelPull(); }; - }, [startPull, onPull, endPull, isDisabled]); + }, [disabled, maximumPullLength, refreshThreshold]); useEffect(() => { const className = 'overflow-hidden'; @@ -160,8 +157,6 @@ export function PullToRefresh({ }; }, [isPulling]); - // First 30px of pull just shows the empty circle, progress starts after that - const emptyPhase = 30; const progressPull = Math.max(0, pullPosition - emptyPhase); const clamped = Math.min(progressPull, refreshThreshold); const progress = clamped / refreshThreshold; @@ -185,33 +180,27 @@ export function PullToRefresh({ if (!isPulling && phase === 'idle') return null; const isExiting = phase === 'exiting'; - const top = isExiting ? -20 : isRefreshing ? 48 : Math.min(pullPosition / 1.5, 120); + // Moved by transform, so the release and exit glides run on the compositor while the refresh re-renders the page + const offset = isExiting ? -20 : isRefreshing ? 48 : Math.min(pullPosition / 1.5, 120); const opacity = isExiting ? 0 : isActive || pullPosition > 0 ? 1 : 0; const transition = isDraggingRef.current ? 'none' : isExiting - ? `top ${exitDuration}ms ease-in ${exitHold}ms, opacity ${exitDuration}ms ease-in ${exitHold}ms` - : 'top 0.3s ease-out, opacity 0.3s ease-out'; + ? `transform ${exitDuration}ms ease-in ${exitHold}ms, opacity ${exitDuration}ms ease-in ${exitHold}ms` + : 'transform 0.3s ease-out, opacity 0.3s ease-out'; return (
    { if (isExiting && e.propertyName === 'opacity') setPhase('idle'); }} - style={{ - top, - opacity, - transition, - }} - className="fixed inset-x-1/2 z-300 h-8 w-8 -translate-x-1/2 bg-base-100" + style={{ transform: `translateY(${offset}px)`, opacity, transition }} + className="fixed inset-x-1/2 top-0 z-300 size-8 -translate-x-1/2" > Pull to refresh
    diff --git a/frontend/src/modules/common/resizable-panels/resizable-panels.tsx b/frontend/src/modules/common/resizable-panels/resizable-panels.tsx index 915aa13ef..ed0b82e6d 100644 --- a/frontend/src/modules/common/resizable-panels/resizable-panels.tsx +++ b/frontend/src/modules/common/resizable-panels/resizable-panels.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, type Ref, useContext, useEffect, useRef } from 'react'; +import { createContext, type ReactNode, type Ref, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useLatestRef } from '~/hooks/use-latest-ref'; import { cn } from '~/utils/cn'; @@ -107,12 +107,7 @@ function resolveLayout( if (growPanel && collapsedAtStart.has(growPanel.id) && growPanel.collapsible) { const expandThreshold = growPanel.minWidth - growPanel.collapsedWidth; if (absDx < expandThreshold) { - hints.push({ - panelId: growPanel.id, - side: draggingLeft ? 'left' : 'right', - mode: 'expand', - progress: absDx / expandThreshold, - }); + hints.push({ panelId: growPanel.id, side: draggingLeft ? 'left' : 'right', mode: 'expand', progress: absDx / expandThreshold }); return { widths: { ...initialWidths }, hints }; } } @@ -175,12 +170,7 @@ function resolveLayout( // Collapse hint (G4) const progress = collapseProgress(victim, rawWidth); if (progress > 0) { - hints.push({ - panelId: victim.id, - side: draggingLeft ? 'right' : 'left', - mode: 'collapse', - progress, - }); + hints.push({ panelId: victim.id, side: draggingLeft ? 'right' : 'left', mode: 'collapse', progress }); } if (clampedWidth <= victim.collapsedWidth) { @@ -236,12 +226,7 @@ function resolveLayout( // Collapse hint (G4) const progress = collapseProgress(victim, rawWidth); if (progress > 0) { - hints.push({ - panelId: victim.id, - side: draggingLeft ? 'right' : 'left', - mode: 'collapse', - progress, - }); + hints.push({ panelId: victim.id, side: draggingLeft ? 'right' : 'left', mode: 'collapse', progress }); } if (clampedWidth <= victim.collapsedWidth) { @@ -293,18 +278,12 @@ function ResizeHint() {
    ([]); const widthsRef = useRef>({}); const dragRef = useRef(null); @@ -449,9 +415,7 @@ export function ResizablePanelGroup({ const computeAutoFill = () => { const container = containerRef.current; if (!container) return true; - const parentWidth = container.parentElement - ? container.parentElement.getBoundingClientRect().width - : container.getBoundingClientRect().width; + const parentWidth = (container.parentElement ?? container).getBoundingClientRect().width; return getIdealPanelSum() + getSeparatorSpace() <= parentWidth; }; @@ -655,8 +619,7 @@ export function ResizablePanelGroup({ let sepSpace = 0; for (const sep of separatorsRef.current.values()) { const style = getComputedStyle(sep); - sepSpace += - sep.getBoundingClientRect().width + Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight); + sepSpace += sep.getBoundingClientRect().width + Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight); } return sepSpace; }; @@ -746,9 +709,7 @@ export function ResizablePanelGroup({ const w = widthsRef.current[panel.id] ?? panel.minWidth; const isLast = i === resizable.length - 1; // Last panel absorbs rounding remainder to keep total exact - let newW = isLast - ? Math.max(panel.minWidth, target - distributed) - : Math.max(panel.minWidth, Math.floor(w * ratio)); + let newW = Math.max(panel.minWidth, isLast ? target - distributed : Math.floor(w * ratio)); newW = Math.min(newW, viewportWidth); distributed += newW; if (Math.abs(newW - w) >= 1) { @@ -797,15 +758,7 @@ export function ResizablePanelGroup({ if (!drag) return; const dx = e.clientX - drag.startX; const ppc = dx < 0 ? drag.perPanelCascade.left : drag.perPanelCascade.right; - const result = resolveLayout( - panelsRef.current, - drag.separatorIndex, - drag.initialWidths, - drag.collapsedAtStart, - dx, - drag.autoFill, - ppc, - ); + const result = resolveLayout(panelsRef.current, drag.separatorIndex, drag.initialWidths, drag.collapsedAtStart, dx, drag.autoFill, ppc); applyLayoutResult(result); }; @@ -826,13 +779,10 @@ export function ResizablePanelGroup({ }; }, []); - const ctxValue: PanelGroupContextValue = { - groupId: id, - registerPanel, - unregisterPanel, - registerSeparator, - unregisterSeparator, - }; + // Panels and separators re-register whenever this value changes, which re-measures and aborts a running drag. + // The functions only touch refs, so the first render's copies stay valid and the value follows `id` alone. + const [registry] = useState(() => ({ registerPanel, unregisterPanel, registerSeparator, unregisterSeparator })); + const ctxValue = useMemo(() => ({ groupId: id, ...registry }), [id, registry]); const dragCtx: SeparatorDragContextValue = { startDrag, @@ -845,12 +795,7 @@ export function ResizablePanelGroup({ return ( -
    +
    {children}
    @@ -873,16 +818,7 @@ interface PanelProps { [key: `data-${string}`]: string | undefined; } -export function ResizablePanel({ - id, - minWidth, - collapsedWidth = 0, - collapsible = false, - className, - children, - ref, - ...rest -}: PanelProps) { +export function ResizablePanel({ id, minWidth, collapsedWidth = 0, collapsible = false, className, children, ref, ...rest }: PanelProps) { const ctx = useContext(PanelGroupContext); const internalRef = useRef(null); @@ -1027,7 +963,7 @@ export function ResizableSeparator({ index, className, children, ...rest }: Sepa aria-controls={ariaControls || undefined} tabIndex={0} data-separator="inactive" - className={cn('select-none focus-visible:outline-none focus-visible:ring-0', className)} + className={cn('select-none focus-visible:outline-hidden focus-visible:ring-0', className)} style={{ touchAction: 'none', cursor: 'col-resize', flexShrink: 0 }} onPointerDown={handlePointerDown} onPointerEnter={handlePointerEnter} diff --git a/frontend/src/modules/common/root.tsx b/frontend/src/modules/common/root.tsx index b6f605089..df7d588b6 100644 --- a/frontend/src/modules/common/root.tsx +++ b/frontend/src/modules/common/root.tsx @@ -7,6 +7,9 @@ import { useOnlineManager } from '~/hooks/use-online-manager'; import { ReloadPrompt } from '~/modules/common/reload-prompt'; import { ToasterProvider } from '~/modules/common/toaster/toaster-provider'; import { TooltipProvider } from '~/modules/ui/tooltip'; +import { lazyNamed } from '~/utils/lazy-named'; + +const Devtools = __DEV_TOOLS__ ? lazyNamed(() => import('~/modules/common/debug-dropdown'), 'Devtools') : () => null; function NoChatSupport() { return null; @@ -16,10 +19,7 @@ export function Root() { const isOnline = useOnlineManager(); const GleapSupport = useLazyComponent( - () => - appConfig.has.chatSupport && isOnline - ? import('~/modules/common/gleap-support') - : Promise.resolve({ GleapSupport: NoChatSupport }), + () => (appConfig.has.chatSupport && isOnline ? import('~/modules/common/gleap-support') : Promise.resolve({ GleapSupport: NoChatSupport })), 'GleapSupport', 5000, ); // 5 seconds delay @@ -35,6 +35,9 @@ export function Root() { {GleapSupport ? : null} + + + ); } diff --git a/frontend/src/modules/common/search-history-group.tsx b/frontend/src/modules/common/search-history-group.tsx new file mode 100644 index 000000000..31168fa49 --- /dev/null +++ b/frontend/src/modules/common/search-history-group.tsx @@ -0,0 +1,46 @@ +import { HistoryIcon, XIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '~/modules/ui/button'; +import { ComboboxGroup, ComboboxGroupLabel, ComboboxItem } from '~/modules/ui/combobox'; + +/** A picked history row, which re-runs its query in place. */ +export type HistoryEntry = { kind: 'history'; value: string }; + +interface SearchHistoryGroupProps { + searches: string[]; + onRemove: (value: string) => void; +} + +/** Recent searches as combobox rows, each with its index (typed into the empty input, it picks the row) and a remove button. */ +export function SearchHistoryGroup({ searches, onRemove }: SearchHistoryGroupProps) { + const { t } = useTranslation(); + + return ( + + {t('c:history')} + {searches.map((search, index) => ( + +
    + + {search} +
    +
    + {index} + +
    +
    + ))} +
    + ); +} diff --git a/frontend/src/modules/common/search-params-schemas.ts b/frontend/src/modules/common/search-params-schemas.ts index 5e0717b2e..8ebd37b6e 100644 --- a/frontend/src/modules/common/search-params-schemas.ts +++ b/frontend/src/modules/common/search-params-schemas.ts @@ -2,7 +2,4 @@ import { zApiError } from 'sdk/zod.gen'; import { z } from 'zod'; /** Search params for the error routes: error page, auth error, account OAuth errors. */ -export const errorSearchSchema = z.object({ - error: z.string().optional(), - severity: zApiError.shape.severity.optional(), -}); +export const errorSearchSchema = z.object({ error: z.string().optional(), severity: zApiError.shape.severity.optional() }); diff --git a/frontend/src/modules/common/search-spinner.tsx b/frontend/src/modules/common/search-spinner.tsx index 97268e437..e97a084ee 100644 --- a/frontend/src/modules/common/search-spinner.tsx +++ b/frontend/src/modules/common/search-spinner.tsx @@ -25,10 +25,7 @@ export function SearchSpinner({ isSearching, value, appearDelay = 0.3 }: SearchS ) : ( - + )} diff --git a/frontend/src/modules/common/selection-action-bar.tsx b/frontend/src/modules/common/selection-action-bar.tsx index 50f7c897c..edaa62a5e 100644 --- a/frontend/src/modules/common/selection-action-bar.tsx +++ b/frontend/src/modules/common/selection-action-bar.tsx @@ -44,7 +44,7 @@ export function SelectionActionBar({ count, onClear, children }: SelectionAction diff --git a/frontend/src/modules/common/sheet-tabs.tsx b/frontend/src/modules/common/sheet-tabs.tsx index 535cd7891..29e40994a 100644 --- a/frontend/src/modules/common/sheet-tabs.tsx +++ b/frontend/src/modules/common/sheet-tabs.tsx @@ -1,7 +1,6 @@ import { motion } from 'motion/react'; -import { Suspense, useState } from 'react'; +import { Suspense, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { nanoid } from 'shared/utils/nanoid'; import type { TKey } from '~/lib/i18n-locales'; import { Button } from '~/modules/ui/button'; @@ -13,7 +12,8 @@ interface Props { } export function SheetTabs({ tabs }: Props) { - const layoutId = nanoid(); + // Stable per instance, so the underline animates between tabs + const layoutId = useId(); const { t } = useTranslation(); const [currentPage, setCurrentPage] = useState(tabs[0]); @@ -32,7 +32,7 @@ export function SheetTabs({ tabs }: Props) { className="peer group opacity-80 hover:opacity-100 data-[current=true]:opacity-100" onClick={() => setCurrentPage(tab)} > - {t(tab.label)} + {t(tab.label)} {currentPage.id === tab.id && ( { + container = document.createElement('div'); + container.dataset.slot = 'scroll-area-viewport'; + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +const render = (contentKey: string, text: string) => + act(async () => + root.render( + +

    {text}

    +
    , + ), + ); + +const wait = (ms: number) => act(async () => new Promise((resolve) => setTimeout(resolve, ms))); + +describe('ContentKeyTransition', () => { + it('lets the old content leave at its scroll position, then starts the new content at the top', async () => { + await render('notifications', 'Notifications'); + container.scrollTop = 500; + + await render('account', 'Account'); + expect(container.textContent).toBe('Notifications'); + expect(container.scrollTop).toBe(500); + + await wait(500); + expect(container.textContent).toBe('Account'); + expect(container.scrollTop).toBe(0); + }); + + it('keeps the scroll position when the content re-renders under the same key', async () => { + await render('account', 'Account'); + container.scrollTop = 500; + + await render('account', 'Account updated'); + + expect(container.textContent).toBe('Account updated'); + expect(container.scrollTop).toBe(500); + }); +}); diff --git a/frontend/src/modules/common/sheeter/drawer.tsx b/frontend/src/modules/common/sheeter/drawer.tsx index bb2b589d0..c6ec6bc70 100644 --- a/frontend/src/modules/common/sheeter/drawer.tsx +++ b/frontend/src/modules/common/sheeter/drawer.tsx @@ -1,35 +1,23 @@ -import { AnimatePresence, motion } from 'motion/react'; import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; +import { ContentKeyTransition } from '~/modules/common/sheeter/sheet'; import { type InternalSheet, sheeter } from '~/modules/common/sheeter/use-sheeter'; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '~/modules/ui/drawer'; import { cn } from '~/utils/cn'; const sideToSwipeDirection = { top: 'up', bottom: 'down', left: 'left', right: 'right' } as const; -export function SheeterDrawer({ sheet }: { sheet: InternalSheet }) { +export function SheeterDrawer({ sheet, onExited }: { sheet: InternalSheet; onExited?: () => void }) { // Drawers on mobile are always modal (overlay + outside click to close) - const { - id, - side, - description, - title, - titleContent = title, - headerClassName, - className, - content, - contentKey, - open = true, - } = sheet; + const { id, side, description, title, titleContent = title, headerClassName, className, content, contentKey, open = true } = sheet; const updateSheet = sheeter.getState().update; - const isDropdownOpen = useDropdowner((state) => state.dropdown); - - const closeSheet = () => sheeter.getState().remove(sheet.id); + const isDropdownOpen = useDropdowner((state) => !!state.dropdown); + // The provider keeps the removed drawer rendered until it has slid out const onOpenChange = (open: boolean) => { - updateSheet(sheet.id, { open }); - if (!open) closeSheet(); + if (open) updateSheet(sheet.id, { open }); + else sheeter.getState().remove(sheet.id); }; return ( @@ -37,33 +25,17 @@ export function SheeterDrawer({ sheet }: { sheet: InternalSheet }) { key={id} modal open={open} - disablePointerDismissal={!!isDropdownOpen} + disablePointerDismissal={isDropdownOpen} swipeDirection={sideToSwipeDirection[side]} onOpenChange={onOpenChange} + onOpenChangeComplete={(isOpen) => !isOpen && onExited?.()} > - {titleContent} - - {description} - + {title && {titleContent}} + {description && {description}} - {contentKey ? ( - - - {content} - - - ) : ( - content - )} + {content} ); diff --git a/frontend/src/modules/common/sheeter/open-edit-sheet.tsx b/frontend/src/modules/common/sheeter/open-edit-sheet.tsx new file mode 100644 index 000000000..3be290972 --- /dev/null +++ b/frontend/src/modules/common/sheeter/open-edit-sheet.tsx @@ -0,0 +1,41 @@ +import i18n from 'i18next'; +import type { ReactNode } from 'react'; +import type { TKey } from '~/lib/i18n-locales'; +import { type TriggerRef, useSheeter } from '~/modules/common/sheeter/use-sheeter'; +import { UnsavedBadge } from '~/modules/common/unsaved-badge'; +import { Card, CardContent } from '~/modules/ui/card'; +import { tw } from '~/utils/tw'; + +interface OpenEditSheetOptions { + id: string; + /** The edited resource, such as `c:user`. */ + resource: TKey; + triggerRef: TriggerRef; + /** The edit form, rendered in the first card. */ + children: ReactNode; + /** Cards rendered below the form card. */ + after?: ReactNode; + className?: string; +} + +/** Opens a right-side sheet that edits one resource: the form in a card, the title with an unsaved badge. */ +export function openEditSheet({ id, resource, triggerRef, children, after, className = tw('container w-full') }: OpenEditSheetOptions) { + const title = i18n.t('c:edit_resource', { resource: i18n.t(resource).toLowerCase() }); + + useSheeter.getState().create( +
    + + {children} + + {after} +
    , + { + id, + triggerRef, + side: 'right', + className: tw('max-w-full lg:max-w-4xl'), + title, + titleContent: , + }, + ); +} diff --git a/frontend/src/modules/common/sheeter/provider.tsx b/frontend/src/modules/common/sheeter/provider.tsx index bd147cfc3..25490a543 100644 --- a/frontend/src/modules/common/sheeter/provider.tsx +++ b/frontend/src/modules/common/sheeter/provider.tsx @@ -1,13 +1,39 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useBodyClass } from '~/hooks/use-body-class'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; +import { useOverlayLock } from '~/modules/common/overlay-store-helpers'; import { SheeterDrawer } from '~/modules/common/sheeter/drawer'; import { SheeterSheet } from '~/modules/common/sheeter/sheet'; -import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; +import { type InternalSheet, useSheeter } from '~/modules/common/sheeter/use-sheeter'; import { useNavigationStore } from '~/modules/navigation/navigation-store'; -import { useUIStore } from '~/modules/ui/ui-store'; import { getRouter } from '~/routes/-router-instance'; +/** + * Keeps sheets that leave the store rendered with `open: false` until Base UI reports their exit done, so every + * close (dismiss, route change, store remove) animates out. A sheet created again with the same id takes over. + */ +function useExitingSheets(sheets: InternalSheet[], mode: string) { + const [seen, setSeen] = useState({ sheets, mode }); + const [exiting, setExiting] = useState([]); + + // Derived during render: in an effect the removed sheet would unmount first and remount closed, with nothing to animate. + if (seen.sheets !== sheets || seen.mode !== mode) { + setSeen({ sheets, mode }); + const isLive = (sheet: InternalSheet) => sheets.some((s) => s.id === sheet.id); + + // A breakpoint switch remounts every overlay, so an exit in progress cannot finish + if (seen.mode !== mode) setExiting([]); + else { + const removed = seen.sheets.filter((s) => s.open !== false && !isLive(s)); + setExiting((current) => [...current.filter((s) => !isLive(s)), ...removed.map((s) => ({ ...s, open: false, onClose: undefined }))]); + } + } + + const onExited = (id: string) => setExiting((current) => current.filter((s) => s.id !== id)); + + return { exiting, onExited }; +} + /** * Renders drawers on mobile, sheets on desktop; when `container` is provided, sheets are portaled into it. */ @@ -16,17 +42,13 @@ export function Sheeter() { const sheets = useSheeter((state) => state.sheets); // Part of the element keys, so crossing the breakpoint remounts the overlay const mode = isMobile ? 'drawer' : 'sheet'; - const lockUI = useUIStore((state) => state.lockUI); - const unlockUI = useUIStore((state) => state.unlockUI); + const { exiting, onExited } = useExitingSheets(sheets, mode); + // A sheet sliding out no longer blocks the page (pull-to-refresh, dialog stacking) + const hasOpenSheet = sheets.some((s) => s.open !== false); - useBodyClass({ 'sheeter-open': sheets.length > 0 }); - - useEffect(() => { - if (sheets.length > 0) { - lockUI('sheeter'); - return () => unlockUI('sheeter'); - } - }, [sheets.length > 0]); + useOverlayLock('sheeter', hasOpenSheet); + // Dialogs opened from a sheet stack above it through this class + useBodyClass({ 'sheeter-open': hasOpenSheet }); useEffect(() => { return getRouter().subscribe('onBeforeLoad', ({ pathChanged }) => { @@ -47,13 +69,13 @@ export function Sheeter() { }); }, []); - if (!sheets.length) return null; + if (!sheets.length && !exiting.length) return null; return ( <> - {sheets.map((sheet) => { + {[...sheets, ...exiting].map((sheet) => { const SheetComponent = isMobile && !sheet.container ? SheeterDrawer : SheeterSheet; - return ; + return onExited(sheet.id)} />; })} ); diff --git a/frontend/src/modules/common/sheeter/sheet.tsx b/frontend/src/modules/common/sheeter/sheet.tsx index c79b17d0a..2676f1738 100644 --- a/frontend/src/modules/common/sheeter/sheet.tsx +++ b/frontend/src/modules/common/sheeter/sheet.tsx @@ -1,4 +1,5 @@ import { AnimatePresence, motion } from 'motion/react'; +import type { ReactNode } from 'react'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { useDropdowner } from '~/modules/common/dropdowner/use-dropdowner'; @@ -7,7 +8,7 @@ import { useNavigationStore } from '~/modules/navigation/navigation-store'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '~/modules/ui/sheet'; import { cn } from '~/utils/cn'; -export function SheeterSheet({ sheet }: { sheet: InternalSheet }) { +export function SheeterSheet({ sheet, onExited }: { sheet: InternalSheet; onExited?: () => void }) { const { id, modal, @@ -23,7 +24,6 @@ export function SheeterSheet({ sheet }: { sheet: InternalSheet }) { closeSheetOnEsc = true, disablePointerDismissal, container, - skipAnimation, contentKey, autoScrollOnDrag, } = sheet; @@ -31,6 +31,7 @@ export function SheeterSheet({ sheet }: { sheet: InternalSheet }) { const isMobile = useBreakpointBelow('sm', false); const containerElement = container?.ref?.current ?? null; + // The provider keeps the removed sheet rendered until it has slid out const closeSheet = () => { useSheeter.getState().remove(sheet.id); @@ -75,43 +76,70 @@ export function SheeterSheet({ sheet }: { sheet: InternalSheet }) { } else closeSheet(); }; - // Resolved at close time: a trigger inside a grid is replaced by a new node once cell edit mode ends. - const finalFocus = triggerRef ? () => triggerRef.current ?? true : undefined; + // Resolved once the exit ends: a trigger inside a grid is replaced by a new node once cell edit mode ends. Focus that + // left the sheet while it slid out (a focus bridge, the page after a route change) stays where it went. + const finalFocus = triggerRef + ? () => { + const active = document.activeElement; + if (active && active !== document.body && !document.getElementById(String(id))?.contains(active)) return false; + return triggerRef.current ?? true; + } + : undefined; return ( - + !isOpen && onExited?.()} + modal={modal} + disablePointerDismissal={disablePointerDismissal} + > - {titleContent} - {description} + {title && {titleContent}} + {description && {description}} - {contentKey ? ( - - - {content} - - - ) : ( - content - )} + {content} ); } + +/** Ref callback that starts mounting content at the top of its sheet's scroll container; stable, so only mounts call it. */ +function scrollToTop(element: HTMLElement | null) { + const scroller = element?.closest('[data-slot="scroll-area-viewport"], [data-slot="drawer-content"]'); + if (scroller) scroller.scrollTop = 0; +} + +/** + * Slides in new content when `contentKey` changes; without a key the content renders as is. The old content leaves + * first, at its own scroll position, and the new content then starts at the top. + */ +export function ContentKeyTransition({ contentKey, children }: { contentKey?: string; children: ReactNode }) { + if (!contentKey) return children; + + return ( + + + {children} + + + ); +} diff --git a/frontend/src/modules/common/sheeter/stories/open-edit-sheet.stories.tsx b/frontend/src/modules/common/sheeter/stories/open-edit-sheet.stories.tsx new file mode 100644 index 000000000..4c1e61a33 --- /dev/null +++ b/frontend/src/modules/common/sheeter/stories/open-edit-sheet.stories.tsx @@ -0,0 +1,139 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import i18n from 'i18next'; +import { type RefObject, useRef } from 'react'; +import type { Organization, Tenant } from 'sdk'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { Sheeter } from '~/modules/common/sheeter/provider'; +import { openUpdateSheet as openUpdateOrganizationSheet } from '~/modules/organization/table/organizations-columns'; +import { domainsQueryOptions } from '~/modules/tenants/query'; +import { openUpdateSheet as openUpdateTenantSheet } from '~/modules/tenants/table/tenants-columns'; +import { openUpdateUserSheet } from '~/modules/user/table/users-columns'; +import type { BaseUser } from '~/modules/user/types'; +import { useUserStore } from '~/modules/user/user-store'; +import { withApp } from '~/stories/with-app'; + +type Opener = (triggerRef: RefObject) => void; + +// Fixtures carry the fields the edit forms read; the rest of the generated types stays unset. +const user = { id: 'user-1', name: 'Ada Lovelace', email: 'ada@example.com', slug: 'ada' } as BaseUser; +const organization = { + id: 'org-1', + entityType: 'organization', + name: 'Analytical Engines', + slug: 'engines', + languages: ['en'], + defaultLanguage: 'en', +} as Organization; +const tenant = { id: 'tenant-1', name: 'Babbage', status: 'active' } as Tenant; + +const translations = { + edit_resource: 'Edit {{resource}}', + user: 'User', + organization: 'Organization', + tenant: 'Tenant', + domain_other: 'Domains', + unsaved_changes: 'Unsaved changes', +}; + +function EditSheetTrigger({ open }: { open: Opener }) { + const triggerRef = useRef(null); + return ( + <> + + + + ); +} + +/** Edit sheets the entity tables open from a row: one form card, titled with the resource and an unsaved badge. */ +const meta = { + title: 'common/sheeter/openEditSheet', + component: EditSheetTrigger, + decorators: [withApp], + parameters: { app: { queryData: [[domainsQueryOptions(tenant.id).queryKey, []]] } }, + beforeEach: () => { + i18n.addResourceBundle('en', 'c', translations, true, true); + // The user form compares the edited user with the signed-in one. + useUserStore.setState({ user: { ...user, id: 'me' } as never }); + return () => { + i18n.removeResourceBundle('en', 'c'); + useUserStore.setState({ user: null }); + }; + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Opens the sheet, checks its id, title and content wrapper, then closes it and expects focus back on the trigger. */ +async function openAndClose(canvasElement: HTMLElement, { id, title, container }: Record) { + const trigger = within(canvasElement).getByRole('button', { name: 'Edit' }); + await userEvent.click(trigger); + + const body = within(document.body); + const sheet = await body.findByRole('dialog'); + await expect(sheet.id).toBe(id); + await expect(within(sheet).getByRole('heading', { name: new RegExp(title) })).toBeVisible(); + await expect(within(sheet).getByText('Unsaved changes')).toBeInTheDocument(); + + const form = sheet.querySelector('form'); + await expect(form).not.toBeNull(); + await expect(form?.closest('.container')?.className).toBe(container); + + // Edit sheets show no close button; Escape is the keyboard way out. + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(trigger).toHaveFocus()); + return sheet; +} + +export const User: Story = { + args: { open: (triggerRef) => openUpdateUserSheet(user, triggerRef) }, + play: async ({ canvasElement }) => { + const sheet = await openAndClose(canvasElement, { id: 'update-user', title: 'Edit user', container: 'container' }); + await expect(sheet.querySelectorAll('[data-slot="card"]')).toHaveLength(1); + }, +}; + +/** A dismissed sheet stays mounted while it slides out; its entry is removed once the exit animation ends. */ +export const DismissAnimatesOut: Story = { + name: 'Dismiss animates out', + args: { open: (triggerRef) => openUpdateUserSheet(user, triggerRef) }, + play: async ({ canvasElement }) => { + await userEvent.click(within(canvasElement).getByRole('button', { name: 'Edit' })); + const sheet = await within(document.body).findByRole('dialog'); + + await userEvent.keyboard('{Escape}'); + await expect(sheet.isConnected).toBe(true); + await expect(sheet).toHaveAttribute('data-closed'); + await waitFor(() => expect(sheet.isConnected).toBe(false)); + }, +}; + +export const OrganizationSheet: Story = { + name: 'Organization', + args: { open: (triggerRef) => openUpdateOrganizationSheet(organization, triggerRef) }, + play: async ({ canvasElement }) => { + const sheet = await openAndClose(canvasElement, { id: 'update-organization', title: 'Edit organization', container: 'container w-full' }); + const cards = sheet.querySelectorAll('[data-slot="card"]'); + await expect(cards).toHaveLength(1); + await expect(cards[0]).toHaveClass('mb-20'); + }, +}; + +export const TenantSheet: Story = { + name: 'Tenant', + args: { open: (triggerRef) => openUpdateTenantSheet(tenant, triggerRef) }, + play: async ({ canvasElement }) => { + const sheet = await openAndClose(canvasElement, { id: 'update-tenant', title: 'Edit tenant', container: 'container w-full' }); + // The form card, then the domains card. + const cards = [...sheet.querySelectorAll('[data-slot="card"]')]; + await expect(cards).toHaveLength(2); + await expect(cards[0]).toHaveClass('mb-4'); + await expect(cards[0].querySelector('form')).not.toBeNull(); + await expect(cards[1]).toHaveClass('mb-20'); + await expect(cards[1]).toHaveTextContent('Domains'); + }, +}; diff --git a/frontend/src/modules/common/sheeter/use-sheeter.test.ts b/frontend/src/modules/common/sheeter/use-sheeter.test.ts index 0525ade9d..776f19c04 100644 --- a/frontend/src/modules/common/sheeter/use-sheeter.test.ts +++ b/frontend/src/modules/common/sheeter/use-sheeter.test.ts @@ -1,14 +1,10 @@ // @vitest-environment jsdom import { createRef } from 'react'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { type SheetData, useSheeter } from './use-sheeter'; - -const sheet = (id: string, data: Partial = {}): SheetData => ({ - id, - side: 'right', - triggerRef: createRef(), - ...data, -}); +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fallbackContentRef } from '~/utils/fallback-content-ref'; +import { type SheetData, sheeter, useSheeter } from './use-sheeter'; + +const sheet = (id: string, data: Partial = {}): SheetData => ({ id, side: 'right', triggerRef: createRef(), ...data }); const openIds = () => useSheeter.getState().sheets.map((s) => s.id); @@ -35,13 +31,128 @@ describe('sheeter close order', () => { it('closes only route-bound sheets on a route change', () => { const closed: string[] = []; useSheeter.getState().create(null, sheet('route', { onClose: () => closed.push('route') })); - useSheeter - .getState() - .create(null, sheet('pinned', { closeSheetOnRouteChange: false, onClose: () => closed.push('pinned') })); + useSheeter.getState().create(null, sheet('pinned', { closeSheetOnRouteChange: false, onClose: () => closed.push('pinned') })); useSheeter.getState().removeOnRouteChange(); expect(closed).toEqual(['route']); expect(openIds()).toEqual(['pinned']); }); + + it('closes a sheet created with an undefined closeSheetOnRouteChange on a route change', () => { + useSheeter.getState().create(null, sheet('a', { closeSheetOnRouteChange: undefined })); + + expect(useSheeter.getState().get('a')?.closeSheetOnRouteChange).toBe(true); + useSheeter.getState().removeOnRouteChange(); + + expect(openIds()).toEqual([]); + }); +}); + +describe('sheeter store', () => { + beforeEach(() => { + useSheeter.setState({ sheets: [] }); + fallbackContentRef.current = null; + }); + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('opens a sheet with its defaults and returns its id', () => { + const id = useSheeter.getState().create('content', sheet('a')); + + expect(id).toBe('a'); + expect(useSheeter.getState().get('a')).toMatchObject({ + id: 'a', + side: 'right', + content: 'content', + open: true, + modal: true, + closeSheetOnRouteChange: true, + }); + }); + + it('keeps the defaults for options passed as undefined', () => { + useSheeter.getState().create(null, sheet('a', { modal: undefined })); + + expect(useSheeter.getState().get('a')).toMatchObject({ modal: true, closeSheetOnRouteChange: true }); + }); + + it('replace keeps the defaults for options passed as undefined when it opens a new sheet', () => { + useSheeter.getState().replace(null, sheet('a', { modal: undefined })); + + expect(useSheeter.getState().get('a')).toMatchObject({ modal: true, open: true }); + }); + + it('replaces a sheet created with the same id and moves it last', () => { + useSheeter.getState().create('first', sheet('a')); + useSheeter.getState().create(null, sheet('b')); + useSheeter.getState().create('second', sheet('a', { side: 'left' })); + + expect(openIds()).toEqual(['b', 'a']); + expect(useSheeter.getState().get('a')).toMatchObject({ content: 'second', side: 'left' }); + }); + + it('replace merges into an open sheet in place and reopens it', () => { + useSheeter.getState().create('first', sheet('a', { title: 'A', closeSheetOnRouteChange: false })); + useSheeter.getState().create(null, sheet('b')); + useSheeter.getState().update('a', { open: false }); + + const id = useSheeter.getState().replace('second', sheet('a', { side: 'left' })); + + expect(id).toBe('a'); + expect(openIds()).toEqual(['a', 'b']); + expect(useSheeter.getState().get('a')).toMatchObject({ content: 'second', side: 'left', title: 'A', open: true, closeSheetOnRouteChange: false }); + }); + + it('replace opens a new sheet when none has the id', () => { + useSheeter.getState().replace('content', sheet('a')); + + expect(useSheeter.getState().get('a')).toMatchObject({ content: 'content', open: true, closeSheetOnRouteChange: true }); + }); + + it('removes every sheet without an id and passes isCleanup to each onClose', () => { + const onClose = vi.fn(); + useSheeter.getState().create(null, sheet('a', { onClose })); + useSheeter.getState().create(null, sheet('b', { onClose })); + + useSheeter.getState().remove(undefined, { isCleanup: true }); + + expect(openIds()).toEqual([]); + expect(onClose.mock.calls).toEqual([[true], [true]]); + }); + + it('removes no sheet for an empty id', () => { + useSheeter.getState().create(null, sheet('a')); + useSheeter.getState().create(null, sheet('b')); + + useSheeter.getState().remove(''); + + expect(openIds()).toEqual(['a', 'b']); + }); + + it('leaves the store untouched when nothing matches', () => { + useSheeter.getState().create(null, sheet('a', { closeSheetOnRouteChange: false })); + const before = useSheeter.getState().sheets; + + useSheeter.getState().remove('missing'); + useSheeter.getState().removeOnRouteChange(); + + expect(useSheeter.getState().sheets).toBe(before); + }); + + it('blurs a focused link and stashes it as the focus fallback', () => { + const link = document.body.appendChild(document.createElement('a')); + link.href = '#'; + link.focus(); + + useSheeter.getState().create(null, sheet('a')); + + expect(fallbackContentRef.current).toBe(link); + expect(document.activeElement).not.toBe(link); + }); + + it('exposes the store as sheeter for non-React callers', () => { + expect(sheeter).toBe(useSheeter); + }); }); diff --git a/frontend/src/modules/common/sheeter/use-sheeter.tsx b/frontend/src/modules/common/sheeter/use-sheeter.tsx index faf555abc..95876347a 100644 --- a/frontend/src/modules/common/sheeter/use-sheeter.tsx +++ b/frontend/src/modules/common/sheeter/use-sheeter.tsx @@ -1,13 +1,11 @@ import type { ReactNode, RefObject } from 'react'; import { create } from 'zustand'; -import { fallbackContentRef } from '~/utils/fallback-content-ref'; +import { blurAndStashTrigger, removeAndNotify, withDefaults } from '~/modules/common/overlay-store-helpers'; /** Element focus returns to on close; read when the sheet closes, so a ref may resolve to a later DOM node. */ export type TriggerRef = RefObject; -type SheetContainerOptions = { - ref: RefObject; -}; +type SheetContainerOptions = { ref: RefObject }; export type SheetData = { id: string; @@ -23,7 +21,6 @@ export type SheetData = { disablePointerDismissal?: boolean; closeSheetOnRouteChange?: boolean; container?: SheetContainerOptions; - skipAnimation?: boolean; /** Key to identify content for animated transitions (used with AnimatePresence). */ contentKey?: string; /** Enable auto-scrolling when dragging elements near edges. */ @@ -31,11 +28,7 @@ export type SheetData = { onClose?: (isCleanup?: boolean) => void; }; -export type InternalSheet = SheetData & { - key: number; - content: ReactNode; - open?: boolean; -}; +export type InternalSheet = SheetData & { content: ReactNode; open?: boolean }; interface SheetStoreState { sheets: InternalSheet[]; @@ -47,33 +40,21 @@ interface SheetStoreState { removeOnRouteChange: (opts?: { isCleanup?: boolean }) => void; get(id: string): InternalSheet | undefined; - triggerRefs: Record; - + /** @deprecated No-op: focus returns through `triggerRef` or the focus fallback. Removed in the next release. */ setTriggerRef: (id: string, ref: TriggerRef) => void; - getTriggerRef: (id: string) => TriggerRef | null; } // Manages one or multiple sheets; on mobile they render as drawers. export const useSheeter = create()((set, get) => ({ sheets: [], - triggerRefs: {}, create: (content, data) => { - if (document.activeElement instanceof HTMLButtonElement || document.activeElement instanceof HTMLAnchorElement) { - fallbackContentRef.current = document.activeElement; - document.activeElement.blur(); - } - - const defaults = { - drawerOnMobile: true, - open: true, - modal: true, - key: Date.now(), - closeSheetOnRouteChange: true, - }; + blurAndStashTrigger(); + + const defaults = { open: true, modal: true, closeSheetOnRouteChange: true }; set((state) => ({ - sheets: [...state.sheets.filter((s) => s.id !== data.id), { ...defaults, ...data, content }], + sheets: [...state.sheets.filter((s) => s.id !== data.id), { ...withDefaults(defaults, data), content }], })); return data.id; }, @@ -82,52 +63,30 @@ export const useSheeter = create()((set, get) => ({ const existing = get().sheets.find((s) => s.id === data.id); if (!existing) return get().create(content, data); - set((state) => ({ - sheets: state.sheets.map((s) => (s.id === data.id ? { ...s, ...data, content, open: true } : s)), - })); + // Merges into the open sheet: an option passed as undefined clears its value. + set((state) => ({ sheets: state.sheets.map((s) => (s.id === data.id ? { ...s, ...data, content, open: true } : s)) })); return data.id; }, update: (id, updates) => { - set((state) => ({ - sheets: state.sheets.map((sheet) => (sheet.id === id ? { ...sheet, ...updates } : sheet)), - })); + set((state) => ({ sheets: state.sheets.map((sheet) => (sheet.id === id ? { ...sheet, ...updates } : sheet)) })); }, remove: (id, opts) => { const { sheets } = get(); - const removeSheets = id ? sheets.filter((sheet) => sheet.id === id) : sheets; - if (!removeSheets.length) return; - - // Update the store before onClose: a callback that navigates from inside set() would - // interleave a router update with this one and render a stale frame of the sheet. - set({ sheets: sheets.filter((sheet) => !removeSheets.includes(sheet)) }); - - for (const sheet of removeSheets) sheet.onClose?.(opts?.isCleanup); + const toRemove = id === undefined ? sheets : sheets.filter((sheet) => sheet.id === id); + removeAndNotify((remaining) => set({ sheets: remaining }), sheets, toRemove, opts); }, removeOnRouteChange: (opts) => { const { sheets } = get(); - const removeSheets = sheets.filter((sheet) => sheet.closeSheetOnRouteChange); - if (!removeSheets.length) return; - - // Same order as remove: store first, then onClose. - set({ sheets: sheets.filter((sheet) => !removeSheets.includes(sheet)) }); - - for (const sheet of removeSheets) sheet.onClose?.(opts?.isCleanup); + const toRemove = sheets.filter((sheet) => sheet.closeSheetOnRouteChange !== false); + removeAndNotify((remaining) => set({ sheets: remaining }), sheets, toRemove, opts); }, get: (id) => get().sheets.find((sheet) => sheet.id === id), - setTriggerRef: (id, ref) => { - set((state) => ({ - triggerRefs: { ...state.triggerRefs, [id]: ref }, - })); - }, - - getTriggerRef: (id) => { - return get().triggerRefs[id] ?? null; - }, + setTriggerRef: () => {}, })); // Non-hook alias for use outside React components, e.g. sheeter.getState() diff --git a/frontend/src/modules/common/sheeter/use-url-sheet.tsx b/frontend/src/modules/common/sheeter/use-url-sheet.tsx index 84b1751e2..cfa0f0be1 100644 --- a/frontend/src/modules/common/sheeter/use-url-sheet.tsx +++ b/frontend/src/modules/common/sheeter/use-url-sheet.tsx @@ -45,10 +45,9 @@ function useCloseOverlay(searchParamKey: string, additionalParamKeys: string[] = export function useUrlSheet(config: UseUrlSheetConfig) { const { searchParamKey, additionalSearchParamKeys, renderContent, onAfterClose, options } = config; - const searchParams = useSearch({ strict: false }) as Record; - const orgMatch = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false }); - const organizationId = orgMatch?.context?.organization?.id; - const value = searchParams[searchParamKey] ?? null; + // Primitive selects, so writes to other search keys don't re-render the host. + const value = useSearch({ strict: false, select: (s) => (s as Record)[searchParamKey] ?? null }); + const organizationId = useMatch({ from: '/_app/$tenantId/$organizationSlug', shouldThrow: false, select: (m) => m.context.organization?.id }); const close = useCloseOverlay(searchParamKey, additionalSearchParamKeys); useEffect(() => { @@ -65,12 +64,7 @@ export function useUrlSheet(config: UseUrlSheetConfig) { }; queueMicrotask(() => { - useSheeter.getState().create(renderContent(value, organizationId), { - id, - triggerRef: fallbackContentRef, - onClose: handleClose, - ...options, - }); + useSheeter.getState().create(renderContent(value, organizationId), { id, triggerRef: fallbackContentRef, onClose: handleClose, ...options }); }); return () => { diff --git a/frontend/src/modules/common/simple-header.tsx b/frontend/src/modules/common/simple-header.tsx index 0514fd304..70a2cf763 100644 --- a/frontend/src/modules/common/simple-header.tsx +++ b/frontend/src/modules/common/simple-header.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useHasScrolled } from '~/hooks/use-has-scrolled'; import type { TKey } from '~/lib/i18n-locales'; import { cn } from '~/utils/cn'; +import { tw } from '~/utils/tw'; interface SimpleHeaderProps { /** i18n key or already-translated text (t() renders non-keys verbatim). */ @@ -14,19 +15,13 @@ interface SimpleHeaderProps { collapseText?: boolean; } -const collapseTextClasses = - 'transition-[max-height,opacity,margin] duration-300 ease-in-out max-h-24 mt-2 md:mt-3 overflow-hidden group-data-[sticky=true]:opacity-0 group-data-[sticky=true]:max-h-0 group-data-[sticky=true]:mt-0'; +const collapseTextClasses = tw( + 'mt-2 max-h-24 overflow-hidden transition-[max-height,opacity,margin] duration-300 ease-in-out group-data-[sticky=true]:mt-0 group-data-[sticky=true]:max-h-0 group-data-[sticky=true]:opacity-0 md:mt-3', +); -const expandedTextClasses = 'transition-[max-height,opacity,margin] duration-300 ease-in-out max-h-24 mt-2 md:mt-3'; +const expandedTextClasses = tw('mt-2 max-h-24 transition-[max-height,opacity,margin] duration-300 ease-in-out md:mt-3'); -export function SimpleHeader({ - heading, - text, - children, - className = '', - textClassName = '', - collapseText, -}: SimpleHeaderProps) { +export function SimpleHeader({ heading, text, children, className = '', textClassName = '', collapseText }: SimpleHeaderProps) { const { t } = useTranslation(); const hasScrolled = useHasScrolled(); const useCollapse = collapseText || textClassName; @@ -36,11 +31,7 @@ export function SimpleHeader({ {heading &&

    {t(heading as TKey)}

    } {text && (

    {t(text as TKey)}

    diff --git a/frontend/src/modules/common/spinner.tsx b/frontend/src/modules/common/spinner.tsx index b36946878..7a5405605 100644 --- a/frontend/src/modules/common/spinner.tsx +++ b/frontend/src/modules/common/spinner.tsx @@ -1,17 +1,18 @@ -import { LoaderCircleIcon } from 'lucide-react'; import { useMountedState } from '~/hooks/use-mounted-state'; +import { Spinner as SpinnerIcon } from '~/modules/ui/spinner'; import { cn } from '~/utils/cn'; export function Spinner({ className = '', noDelay = false }) { const { hasStarted } = useMountedState(); return ( -
    - +
    +
    ); } + +/** Spinner for a page or panel that is still loading, placed near the vertical middle of the viewport. */ +export function PageSpinner({ className }: { className?: string }) { + return ; +} diff --git a/frontend/src/modules/common/spotlighter/provider.tsx b/frontend/src/modules/common/spotlighter/provider.tsx index 1b66ca63f..3812d6e4c 100644 --- a/frontend/src/modules/common/spotlighter/provider.tsx +++ b/frontend/src/modules/common/spotlighter/provider.tsx @@ -25,7 +25,7 @@ export function Spotlighter() { {hasActive && ( ; + + return ( + + ); +} + +// Tailwind reads group names from literal class strings +const groupClassNames = { + toc: { row: 'group/toc', active: 'group-data-spy-active/toc:opacity-100' }, + section: { row: 'group/section', active: 'group-data-spy-active/section:opacity-100' }, +}; + +interface SpyNavItemProps { + /** Section id: link hash and data-spy-link target. */ + id: string; + isActive: boolean; + layoutId: string; + group: keyof typeof groupClassNames; + staticIndicator?: boolean; + className?: string; + children: ReactNode; +} + +/** Row of a scroll-spy aside: the scroll spy marks it through data-spy-link, and a click scrolls to its section. */ +export function SpyNavItem({ id, isActive, layoutId, group, staticIndicator, className, children }: SpyNavItemProps) { + const groupClassName = groupClassNames[group]; + + return ( +
    + {isActive && } + +
    + ); +} diff --git a/frontend/src/modules/common/stepper/context.tsx b/frontend/src/modules/common/stepper/context.tsx deleted file mode 100644 index 56d5c8fe4..000000000 --- a/frontend/src/modules/common/stepper/context.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from 'react'; -import type { StepperProps } from '~/modules/common/stepper/types'; - -interface StepperContextValue extends StepperProps { - clickable?: boolean; - isError?: boolean; - isLoading?: boolean; - isVertical?: boolean; - stepCount?: number; - expandVerticalSteps?: boolean; - activeStep: number; - initialStep: number; -} - -type StepperContextProviderProps = { - value: Omit; - children: React.ReactNode; -}; - -export const StepperContext = React.createContext< - StepperContextValue & { - nextStep: () => void; - prevStep: () => void; - resetSteps: () => void; - setStep: (step: number) => void; - } ->({ - steps: [], - activeStep: 0, - initialStep: 0, - nextStep: () => {}, - prevStep: () => {}, - resetSteps: () => {}, - setStep: () => {}, -}); - -export function StepperProvider({ value, children }: StepperContextProviderProps) { - const isError = value.state === 'error'; - const isLoading = value.state === 'loading'; - - const [activeStep, setActiveStep] = React.useState(value.initialStep); - - const nextStep = () => { - setActiveStep((prev) => prev + 1); - }; - - const prevStep = () => { - setActiveStep((prev) => prev - 1); - }; - - const resetSteps = () => { - setActiveStep(value.initialStep); - }; - - const setStep = (step: number) => { - setActiveStep(step); - }; - - return ( - - {children} - - ); -} diff --git a/frontend/src/modules/common/stepper/horizontal-step.tsx b/frontend/src/modules/common/stepper/horizontal-step.tsx deleted file mode 100644 index 6b849d7d6..000000000 --- a/frontend/src/modules/common/stepper/horizontal-step.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from 'react'; -import { StepButtonContainer } from '~/modules/common/stepper/step-button-container'; -import { StepIcon } from '~/modules/common/stepper/step-icon'; -import { StepLabel } from '~/modules/common/stepper/step-label'; -import type { StepSharedProps } from '~/modules/common/stepper/types'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; -import { cn } from '~/utils/cn'; - -function HorizontalStepBase(props: StepSharedProps, ref: React.ForwardedRef) { - const { - isError, - isLoading, - onClickStep, - variant, - clickable, - checkIcon: checkIconContext, - errorIcon: errorIconContext, - styles, - steps, - setStep, - } = useStepper(); - - const { - index, - isCompletedStep, - isCurrentStep, - hasVisited, - icon, - label, - description, - isKeepError, - state, - checkIcon: checkIconProp, - errorIcon: errorIconProp, - } = props; - - const localIsLoading = isLoading || state === 'loading'; - const localIsError = isError || state === 'error'; - - const opacity = hasVisited ? 1 : 0.8; - - const active = variant === 'line' ? isCompletedStep || isCurrentStep : isCompletedStep; - - const checkIcon = checkIconProp || checkIconContext; - const errorIcon = errorIconProp || errorIconContext; - - return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: element is not keyboard-focusable and handled intentionally via mouse -
    onClickStep?.(index || 0, setStep)} - ref={ref} - > -
    - - - - -
    -
    - ); -} - -export const HorizontalStep = React.forwardRef(HorizontalStepBase); diff --git a/frontend/src/modules/common/stepper/step-button-container.tsx b/frontend/src/modules/common/stepper/step-button-container.tsx deleted file mode 100644 index ecbce1065..000000000 --- a/frontend/src/modules/common/stepper/step-button-container.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { StepSharedProps } from '~/modules/common/stepper/types'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; -import { Button } from '~/modules/ui/button'; -import { cn } from '~/utils/cn'; - -type StepButtonContainerProps = StepSharedProps & { - children?: React.ReactNode; -}; - -export function StepButtonContainer({ - isCurrentStep, - isCompletedStep, - children, - isError, - index, - isLoading: isLoadingProp, - onClickStep, -}: StepButtonContainerProps) { - const { - clickable, - isLoading: isLoadingContext, - variant, - styles, - setStep, - onClickStep: onClickStepGeneral, - } = useStepper(); - - const currentStepClickable = clickable || !!onClickStep; - - const isLoading = isLoadingProp || isLoadingContext; - - if (variant === 'line') { - return null; - } - - return ( - - ); -} diff --git a/frontend/src/modules/common/stepper/step-icon.tsx b/frontend/src/modules/common/stepper/step-icon.tsx deleted file mode 100644 index 0d183950e..000000000 --- a/frontend/src/modules/common/stepper/step-icon.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { cva } from 'class-variance-authority'; -import { CheckIcon, LoaderCircleIcon, XIcon } from 'lucide-react'; -import * as React from 'react'; -import type { IconType } from '~/modules/common/stepper/types'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; -import { cn } from '~/utils/cn'; - -interface StepIconProps { - isCompletedStep?: boolean; - isCurrentStep?: boolean; - isError?: boolean; - isLoading?: boolean; - isKeepError?: boolean; - icon?: IconType; - index?: number; - checkIcon?: IconType; - errorIcon?: IconType; -} - -const iconVariants = cva('', { - variants: { - size: { - sm: 'size-4', - md: 'size-4', - lg: 'size-5', - }, - }, - defaultVariants: { - size: 'md', - }, -}); - -function StepIconBase(props: StepIconProps, ref: React.ForwardedRef) { - const { size } = useStepper(); - - const { - isCompletedStep, - isCurrentStep, - isError, - isLoading, - isKeepError, - icon: CustomIcon, - index, - checkIcon: CustomCheckIcon, - errorIcon: CustomErrorIcon, - } = props; - - const Icon = CustomIcon ? CustomIcon : null; - - const ErrorIcon = CustomErrorIcon ? CustomErrorIcon : null; - - const Check = CustomCheckIcon ? CustomCheckIcon : CheckIcon; - - const iconContent = (() => { - if (isCompletedStep) { - if (isError && isKeepError) { - return ( -
    - -
    - ); - } - return ( -
    - -
    - ); - } - if (isCurrentStep) { - if (isError && ErrorIcon) { - return ( -
    - -
    - ); - } - if (isError) { - return ( -
    - -
    - ); - } - if (isLoading) { - return ; - } - } - if (Icon) { - return ( -
    - -
    - ); - } - return ( - - {(index || 0) + 1} - - ); - })(); - - return iconContent; -} - -export const StepIcon = React.forwardRef(StepIconBase); diff --git a/frontend/src/modules/common/stepper/step-label.tsx b/frontend/src/modules/common/stepper/step-label.tsx deleted file mode 100644 index d59204a3c..000000000 --- a/frontend/src/modules/common/stepper/step-label.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { cva } from 'class-variance-authority'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; -import { cn } from '~/utils/cn'; - -interface StepLabelProps { - isCurrentStep?: boolean; - opacity: number; - label?: string | React.ReactNode; - description?: string | null; -} - -const labelVariants = cva('', { - variants: { - size: { - sm: 'text-sm', - md: 'text-base', - lg: 'text-lg', - }, - }, - defaultVariants: { - size: 'md', - }, -}); - -const descriptionVariants = cva('', { - variants: { - size: { - sm: 'text-xs', - md: 'text-xs', - lg: 'text-sm', - }, - }, - defaultVariants: { - size: 'md', - }, -}); - -export function StepLabel({ isCurrentStep, opacity, label, description }: StepLabelProps) { - const { variant, styles, size, orientation } = useStepper(); - const shouldRender = !!label || !!description; - - return shouldRender ? ( -
    - {!!label && ( - {label} - )} - {!!description && ( - - {description} - - )} -
    - ) : null; -} diff --git a/frontend/src/modules/common/stepper/step.tsx b/frontend/src/modules/common/stepper/step.tsx deleted file mode 100644 index 25b2787ae..000000000 --- a/frontend/src/modules/common/stepper/step.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from 'react'; -import { HorizontalStep } from '~/modules/common/stepper/horizontal-step'; -import type { StepProps } from '~/modules/common/stepper/types'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; -import { VerticalStep } from '~/modules/common/stepper/vertical-step'; - -// Internal props, never passed by the caller -interface StepInternalConfig { - index: number; - isCompletedStep?: boolean; - isCurrentStep?: boolean; - isLastStep?: boolean; -} - -interface FullStepProps extends StepProps, StepInternalConfig {} - -export const Step = React.forwardRef( - // biome-ignore lint/suspicious/noExplicitAny: unable to infer type due to dynamic data structure - function Step(props, ref: React.Ref) { - const { - children, - description, - icon, - state, - checkIcon, - errorIcon, - index, - isCompletedStep, - isCurrentStep, - isLastStep, - isKeepError, - label, - onClickStep, - } = props as FullStepProps; - - const { isVertical, isError, isLoading, clickable } = useStepper(); - - const hasVisited = isCurrentStep || isCompletedStep; - - const sharedProps = { - isLastStep, - isCompletedStep, - isCurrentStep, - index, - isError, - isLoading, - clickable, - label, - description, - hasVisited, - icon, - isKeepError, - checkIcon, - state, - errorIcon, - onClickStep, - }; - - const renderStep = () => { - switch (isVertical) { - case true: - return ( - - {children} - - ); - default: - return ; - } - }; - - return renderStep(); - }, -); diff --git a/frontend/src/modules/common/stepper/stepper.tsx b/frontend/src/modules/common/stepper/stepper.tsx index 64b7d9742..02d0d8b6b 100644 --- a/frontend/src/modules/common/stepper/stepper.tsx +++ b/frontend/src/modules/common/stepper/stepper.tsx @@ -1,168 +1,105 @@ -'use client'; - -import * as React from 'react'; -import { StepperProvider } from '~/modules/common/stepper/context'; -import { Step } from '~/modules/common/stepper/step'; -import type { StepperProps } from '~/modules/common/stepper/types'; -import { useMediaQuery } from '~/modules/common/stepper/use-media-query'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; +import { CheckIcon } from 'lucide-react'; +import { Children, cloneElement, isValidElement, useState } from 'react'; +import type { StepProps, StepperProps } from '~/modules/common/stepper/types'; +import { StepperContext, useStepper } from '~/modules/common/stepper/use-stepper'; +import { Button } from '~/modules/ui/button'; +import { Collapsible, CollapsibleContent } from '~/modules/ui/collapsible'; import { cn } from '~/utils/cn'; -const VARIABLE_SIZES = { - sm: '2rem', - md: '2.5rem', - lg: '2.75rem', -}; - -function StepperBase(props: StepperProps, ref: React.Ref) { - const { - className, - children, - orientation: orientationProp = 'horizontal', - state, - responsive = true, - checkIcon, - errorIcon, - onClickStep, - mobileBreakpoint, - expandVerticalSteps = false, - initialStep = 0, - size = 'sm', - steps, - variant, - styles, - variables, - scrollTracking = false, - ...rest - } = props; - - const childArr = React.Children.toArray(children); - - const items: React.ReactElement[] = []; - - const footer = childArr.map((child, _index) => { - if (!React.isValidElement(child)) { - throw new Error('Stepper children must be valid React elements.'); - } - if (child.type === Step) { - items.push(child); - return null; - } - - return child; - }); - - const stepCount = items.length; +/** Vertical stepper: every step shows a numbered button and its label, only the current step's content is open. */ +export function Stepper({ children, className, initialStep = 0, steps, onClickStep }: StepperProps) { + const [activeStep, setActiveStep] = useState(initialStep); - const isMobile = useMediaQuery(`(max-width: ${mobileBreakpoint || '48rem'})`); - - const clickable = !!onClickStep; - - const orientation = isMobile && responsive ? 'vertical' : orientationProp; - - const isVertical = orientation === 'vertical'; + // Step reads its position from the index handed down here. + const items = Children.toArray(children).map((child, index) => + isValidElement<{ index?: number }>(child) ? cloneElement(child, { index }) : child, + ); return ( - setActiveStep((prev) => prev + 1), + setStep: setActiveStep, }} >
    - {items} + {items}
    - {orientation === 'horizontal' && {items}} - {footer} -
    + ); } -export const Stepper = React.forwardRef(StepperBase); - -function VerticalContent({ children }: { children: React.ReactNode }) { - const { activeStep } = useStepper(); +export function Step({ children, label, checkIcon: Check = CheckIcon, index = 0 }: StepProps & { index?: number }) { + const { steps, activeStep, isLastStep: isOnLastStep, onClickStep, setStep } = useStepper(); - const childArr = React.Children.toArray(children); - const stepCount = childArr.length; - - return ( - <> - {React.Children.map(children, (child, i) => { - const isCompletedStep = - (React.isValidElement(child) && - // biome-ignore lint/suspicious/noExplicitAny: unable to infer type due to dynamic data structure - (child.props as any).isCompletedStep) ?? - i < activeStep; - const isLastStep = i === stepCount - 1; - const isCurrentStep = i === activeStep; - - const stepProps = { - index: i, - isCompletedStep, - isCurrentStep, - isLastStep, - }; - - if (React.isValidElement(child)) { - return React.cloneElement(child, stepProps); - } - return null; - })} - - ); -} - -function HorizontalContent({ children }: { children: React.ReactNode }) { - const { activeStep } = useStepper(); - const childArr = React.Children.toArray(children); - - if (activeStep > childArr.length) { - return null; - } + const isCompletedStep = index < activeStep; + const isCurrentStep = index === activeStep; + const clickable = !!onClickStep; return ( - <> - {React.Children.map(childArr[activeStep], (node) => { - if (!React.isValidElement<{ children?: React.ReactNode }>(node)) { - return null; - } - return React.Children.map(node.props.children, (childNode) => childNode); - })} - +
    + {steps.length > 1 && ( +
    + + {!!label && ( +
    + {label} +
    + )} +
    + )} +
    + + + {children} + + +
    +
    ); } -export { Step, useStepper }; +export { useStepper }; diff --git a/frontend/src/modules/common/stepper/types.ts b/frontend/src/modules/common/stepper/types.ts index 3ba65095c..3e1492ecd 100644 --- a/frontend/src/modules/common/stepper/types.ts +++ b/frontend/src/modules/common/stepper/types.ts @@ -1,75 +1,24 @@ import type { IconComponent } from '~/modules/common/icons/types'; -// biome-ignore lint/suspicious/noExplicitAny: unable to infer type due to dynamic data structure -type IconType = IconComponent | React.ComponentType; +type StepItem = { id?: string; label?: string; description?: string; optional?: boolean }; -type StepItem = { - id?: string; - label?: string; - description?: string; - icon?: IconType; - optional?: boolean; -}; - -interface StepOptions { - orientation?: 'vertical' | 'horizontal'; - state?: 'loading' | 'error'; - responsive?: boolean; - checkIcon?: IconType; - errorIcon?: IconType; - onClickStep?: (step: number, setStep: (step: number) => void) => void; - mobileBreakpoint?: string; - variant?: 'circle' | 'circle-alt' | 'line'; - expandVerticalSteps?: boolean; - size?: 'sm' | 'md' | 'lg'; - styles?: { - 'main-container'?: string; - 'horizontal-step'?: string; - /** Styles for the horizontal step container (button and labels) */ - 'horizontal-step-container'?: string; - 'vertical-step'?: string; - /** Styles for the vertical step container (button and labels) */ - 'vertical-step-container'?: string; - 'vertical-step-content'?: string; - 'step-button-container'?: string; - /** Styles for the label and description container */ - 'step-label-container'?: string; - 'step-label'?: string; - 'step-description'?: string; - }; - variables?: { - '--step-icon-size'?: string; - '--step-gap'?: string; - }; - scrollTracking?: boolean; -} - -interface StepperProps extends StepOptions { +interface StepperProps { children?: React.ReactNode; className?: string; initialStep: number; steps: StepItem[]; -} - -interface StepProps extends React.HTMLAttributes { - label?: string | React.ReactNode; - description?: string; - icon?: IconType; - state?: 'loading' | 'error'; - checkIcon?: IconType; - errorIcon?: IconType; - isCompletedStep?: boolean; - isKeepError?: boolean; + orientation?: 'vertical'; + /** Makes the step buttons clickable; the handler decides whether to call `setStep`. */ onClickStep?: (step: number, setStep: (step: number) => void) => void; } -interface StepSharedProps extends StepProps { - isLastStep?: boolean; - isCurrentStep?: boolean; - index?: number; - hasVisited?: boolean; - isError?: boolean; - isLoading?: boolean; +interface StepProps { + children?: React.ReactNode; + label?: React.ReactNode; + /** Shown on the step button once the step is completed, in place of a check. */ + checkIcon?: IconComponent; + /** @deprecated Has no effect: steps have no error state. */ + isKeepError?: boolean; } -export type { IconType, StepItem, StepProps, StepperProps, StepSharedProps }; +export type { StepItem, StepProps, StepperProps }; diff --git a/frontend/src/modules/common/stepper/use-media-query.tsx b/frontend/src/modules/common/stepper/use-media-query.tsx deleted file mode 100644 index 9b7e7ca28..000000000 --- a/frontend/src/modules/common/stepper/use-media-query.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from 'react'; - -export function useMediaQuery(query: string) { - const [value, setValue] = React.useState(false); - - React.useEffect(() => { - function onChange(event: MediaQueryListEvent) { - setValue(event.matches); - } - - const result = matchMedia(query); - result.addEventListener('change', onChange); - setValue(result.matches); - - return () => result.removeEventListener('change', onChange); - }, [query]); - - return value; -} diff --git a/frontend/src/modules/common/stepper/use-stepper.ts b/frontend/src/modules/common/stepper/use-stepper.ts index 2c22098fa..67abb7b14 100644 --- a/frontend/src/modules/common/stepper/use-stepper.ts +++ b/frontend/src/modules/common/stepper/use-stepper.ts @@ -1,42 +1,31 @@ -import * as React from 'react'; -import { StepperContext } from '~/modules/common/stepper/context'; - -function usePrevious(value: T): T | undefined { - const ref = React.useRef(undefined); - - React.useEffect(() => { - ref.current = value; - }, [value]); - - return ref.current; +import { createContext, useContext } from 'react'; +import type { StepItem, StepperProps } from '~/modules/common/stepper/types'; + +interface StepperContextValue { + steps: StepItem[]; + activeStep: number; + onClickStep?: StepperProps['onClickStep']; + nextStep: () => void; + setStep: (step: number) => void; } -export const useStepper = () => { - const context = React.useContext(StepperContext); - - if (context === undefined) { - throw new Error('useStepper must be used within a StepperProvider'); - } - - const { children, className, ...rest } = context; - - const isLastStep = context.activeStep === context.steps.length - 1; - const hasCompletedAllSteps = context.activeStep === context.steps.length; - - const previousActiveStep = usePrevious(context.activeStep); +/** Outside a stepper the defaults apply, so forms that call `nextStep` also work on their own. */ +export const StepperContext = createContext({ + steps: [], + activeStep: 0, + nextStep: () => {}, + setStep: () => {}, +}); +export const useStepper = () => { + const context = useContext(StepperContext); const currentStep = context.steps[context.activeStep]; - const isOptionalStep = !!currentStep?.optional; - - const isDisabledStep = context.activeStep === 0; return { - ...rest, - isLastStep, - hasCompletedAllSteps, - isOptionalStep, - isDisabledStep, + ...context, currentStep, - previousActiveStep, + isOptionalStep: !!currentStep?.optional, + isLastStep: context.activeStep === context.steps.length - 1, + hasCompletedAllSteps: context.activeStep === context.steps.length, }; }; diff --git a/frontend/src/modules/common/stepper/vertical-step.tsx b/frontend/src/modules/common/stepper/vertical-step.tsx deleted file mode 100644 index 35bd9cf01..000000000 --- a/frontend/src/modules/common/stepper/vertical-step.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { cva } from 'class-variance-authority'; -import * as React from 'react'; -import { StepButtonContainer } from '~/modules/common/stepper/step-button-container'; -import { StepIcon } from '~/modules/common/stepper/step-icon'; -import { StepLabel } from '~/modules/common/stepper/step-label'; -import type { StepSharedProps } from '~/modules/common/stepper/types'; -import { useStepper } from '~/modules/common/stepper/use-stepper'; -import { Collapsible, CollapsibleContent } from '~/modules/ui/collapsible'; -import { cn } from '~/utils/cn'; - -type VerticalStepProps = StepSharedProps & { - children?: React.ReactNode; -}; - -const verticalStepVariants = cva( - [ - 'relative flex flex-col transition-all duration-200', - 'data-[completed=true]:not-last:after:bg-primary', - 'data-[invalid=true]:not-last:after:bg-destructive', - ], - { - variants: { - variant: { - circle: cn( - 'not-last:gap-(--step-gap) not-last:pb-(--step-gap)', - "not-last:after:w-0.5 not-last:after:bg-border not-last:after:content-['']", - 'not-last:after:inset-x-[calc(var(--step-icon-size)/2)]', - 'not-last:after:absolute', - 'not-last:after:top-[calc(var(--step-icon-size)+var(--step-gap))]', - 'not-last:after:bottom-(--step-gap)', - 'not-last:after:transition-all not-last:after:duration-200', - ), - line: 'mb-4 flex-1 border-t-0', - }, - }, - }, -); - -function VerticalStepBase(props: VerticalStepProps, ref: React.ForwardedRef) { - const { - children, - index, - isCompletedStep, - isCurrentStep, - label, - description, - isKeepError, - icon, - hasVisited, - state, - checkIcon: checkIconProp, - errorIcon: errorIconProp, - onClickStep, - } = props; - - const { - checkIcon: checkIconContext, - errorIcon: errorIconContext, - isError, - isLoading, - variant, - clickable, - expandVerticalSteps, - styles, - scrollTracking, - orientation, - steps, - isLastStep: isLastStepCurrentStep, - previousActiveStep, - } = useStepper(); - - const opacity = hasVisited ? 1 : 0.8; - const localIsLoading = isLoading || state === 'loading'; - const localIsError = isError || state === 'error'; - - const isLastStep = index === steps.length - 1; - - const active = variant === 'line' ? isCompletedStep || isCurrentStep : isCompletedStep; - const checkIcon = checkIconProp || checkIconContext; - const errorIcon = errorIconProp || errorIconContext; - - const renderChildren = () => { - if (!expandVerticalSteps) { - return ( - - { - if ( - // Prevents initial auto-scroll when the stepper isn't at the top of the view. - scrollTracking && - ((index === 0 && previousActiveStep && previousActiveStep === steps.length) || (index && index > 0)) - ) { - node?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - }} - className="overflow-hidden data-closed:animate-collapsible-up data-open:animate-collapsible-down" - > - {children} - - - ); - } - return children; - }; - - return ( -
    - {steps.length > 1 && ( -
    - - - - - -
    - )} -
    - {renderChildren()} -
    -
    - ); -} - -export const VerticalStep = React.forwardRef(VerticalStepBase); diff --git a/frontend/src/modules/common/sticky-box.tsx b/frontend/src/modules/common/sticky-box.tsx index a111c3c30..a82a5fd9b 100644 --- a/frontend/src/modules/common/sticky-box.tsx +++ b/frontend/src/modules/common/sticky-box.tsx @@ -1,6 +1,5 @@ import { type ComponentProps, useEffect, useRef, useState } from 'react'; import { isProgrammaticScroll } from '~/hooks/use-scroll-spy-store'; -import { cn } from '~/utils/cn'; /** Nearest scrolling ancestor of `node`, or `window` when none is found before `document.body`. */ export function getScrollParent(node: HTMLElement): HTMLElement | Window { @@ -27,8 +26,6 @@ type StickyBoxProps = Omit, 'ref'> & { enabled?: boolean; /** Hide the bar while scrolling down and reveal it while scrolling up. */ hideWhenOutOfView?: boolean; - /** Classes for the zero-height sentinel that marks the bar's natural top, e.g. `my-2` margin. */ - placeholderClassName?: string; /** CSS custom property the bar publishes its height to on the parent, e.g. `--sticky-stack-nav`. */ publishVar?: string; }; @@ -49,7 +46,6 @@ export function StickyBox({ publishVar, children, className, - placeholderClassName, style, ...rest }: StickyBoxProps) { @@ -99,11 +95,8 @@ export function StickyBox({ const barHeight = bar.offsetHeight; const scrollTop = scrollParent === window ? 0 : (scrollParent as HTMLElement).getBoundingClientRect().top; const barStyles = getComputedStyle(bar); - const stackPx = Math.max( - ...STACK_VARS.filter((v) => v !== publishVar).map( - (v) => Number.parseFloat(barStyles.getPropertyValue(v)) || 0, - ), - ); + const stackValues = STACK_VARS.filter((v) => v !== publishVar).map((v) => Number.parseFloat(barStyles.getPropertyValue(v)) || 0); + const stackPx = Math.max(...stackValues); const stickyBottom = scrollTop + stackPx + offsetTop + barHeight; const spaceBelow = parentRect.bottom - stickyBottom; // Offset from the sentinel: at the release boundary it equals the stuck position exactly @@ -192,11 +185,7 @@ export function StickyBox({ // `top` must never transition: it would interpolate the stuck/released switch and park at stale offsets const consumedVars = STACK_VARS.filter((v) => v !== publishVar).map((v) => `var(${v}, 0px)`); const stackExpr = consumedVars.length > 1 ? `max(${consumedVars.join(', ')})` : (consumedVars[0] ?? '0px'); - const barStyle: React.CSSProperties = { - ...style, - position: 'sticky', - top: `calc(${stackExpr} + ${offsetTop}px)`, - }; + const barStyle: React.CSSProperties = { ...style, position: 'sticky', top: `calc(${stackExpr} + ${offsetTop}px)` }; if (clampedTop !== null) { barStyle.position = 'relative'; barStyle.top = clampedTop; @@ -213,7 +202,7 @@ export function StickyBox({ // Only the sentinel precedes the bar, so its sticky containing block is the caller's parent return ( <> -
    +
    {children}
    diff --git a/frontend/src/modules/common/stories/alert-banner.stories.tsx b/frontend/src/modules/common/stories/alert-banner.stories.tsx index 905e526c6..0d405a575 100644 --- a/frontend/src/modules/common/stories/alert-banner.stories.tsx +++ b/frontend/src/modules/common/stories/alert-banner.stories.tsx @@ -3,38 +3,21 @@ import { CircleCheckBigIcon, InfoIcon, TriangleAlertIcon } from 'lucide-react'; import type { TKey } from '~/lib/i18n-locales'; import { AlertBanner } from '~/modules/common/alerter/alert-banner'; -const meta = { - title: 'common/AlertBanner', - component: AlertBanner, - tags: ['autodocs'], - parameters: { layout: 'padded' }, -} satisfies Meta; +const meta = { title: 'common/AlertBanner', component: AlertBanner, tags: ['autodocs'], parameters: { layout: 'padded' } } satisfies Meta< + typeof AlertBanner +>; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - id: 'story-default', - children: 'This is a default alert banner.', - }, -}; +export const Default: Story = { args: { id: 'story-default', children: 'This is a default alert banner.' } }; export const WithTitle: Story = { - args: { - id: 'story-title', - title: 'Heads up!' as TKey, - children: 'Something important happened that you should know about.', - }, + args: { id: 'story-title', title: 'Heads up!' as TKey, children: 'Something important happened that you should know about.' }, }; export const WithIcon: Story = { - args: { - id: 'story-icon', - icon: InfoIcon, - title: 'Information' as TKey, - children: 'This alert includes an icon for additional context.', - }, + args: { id: 'story-icon', icon: InfoIcon, title: 'Information' as TKey, children: 'This alert includes an icon for additional context.' }, }; export const Destructive: Story = { @@ -48,31 +31,13 @@ export const Destructive: Story = { }; export const Success: Story = { - args: { - id: 'story-success', - variant: 'success', - icon: CircleCheckBigIcon, - title: 'Success' as TKey, - children: 'Your changes have been saved.', - }, + args: { id: 'story-success', variant: 'success', icon: CircleCheckBigIcon, title: 'Success' as TKey, children: 'Your changes have been saved.' }, }; export const Warning: Story = { - args: { - id: 'story-warning', - variant: 'warning', - icon: TriangleAlertIcon, - title: 'Warning' as TKey, - children: 'This action cannot be undone.', - }, + args: { id: 'story-warning', variant: 'warning', icon: TriangleAlertIcon, title: 'Warning' as TKey, children: 'This action cannot be undone.' }, }; export const Animated: Story = { - args: { - id: 'story-animated', - animate: true, - icon: InfoIcon, - title: 'Animated alert' as TKey, - children: 'This alert uses enter/exit animations.', - }, + args: { id: 'story-animated', animate: true, icon: InfoIcon, title: 'Animated alert' as TKey, children: 'This alert uses enter/exit animations.' }, }; diff --git a/frontend/src/modules/common/stories/animated-arrow.stories.tsx b/frontend/src/modules/common/stories/animated-arrow.stories.tsx index 4195a9617..93047fcc8 100644 --- a/frontend/src/modules/common/stories/animated-arrow.stories.tsx +++ b/frontend/src/modules/common/stories/animated-arrow.stories.tsx @@ -1,12 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { AnimatedArrow } from '~/modules/common/animated-arrow'; -const meta = { - title: 'common/AnimatedArrow', - component: AnimatedArrow, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/AnimatedArrow', component: AnimatedArrow, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof AnimatedArrow +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/avatar-form-field.stories.tsx b/frontend/src/modules/common/stories/avatar-form-field.stories.tsx index e25542af8..d1679fc21 100644 --- a/frontend/src/modules/common/stories/avatar-form-field.stories.tsx +++ b/frontend/src/modules/common/stories/avatar-form-field.stories.tsx @@ -2,26 +2,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { FormProvider, useForm } from 'react-hook-form'; import { AvatarFormField } from '~/modules/common/form-fields/avatar'; -type AvatarStoryValues = { - thumbnailUrl: string | null; -}; +type AvatarStoryValues = { thumbnailUrl: string | null }; -type AvatarStoryProps = { - label: string; - type: 'user' | 'organization'; - entity: { - id?: string; - name?: string | null; - }; - defaultUrl?: string | null; -}; +type AvatarStoryProps = { label: string; type: 'user' | 'organization'; entity: { id?: string; name?: string | null }; defaultUrl?: string | null }; function AvatarFormFieldStory({ label, type, entity, defaultUrl = null }: AvatarStoryProps) { - const form = useForm({ - defaultValues: { - thumbnailUrl: defaultUrl, - }, - }); + const form = useForm({ defaultValues: { thumbnailUrl: defaultUrl } }); return ( @@ -32,37 +18,22 @@ function AvatarFormFieldStory({ label, type, entity, defaultUrl = null }: Avatar ); } -const meta = { - title: 'common/AvatarFormField', - component: AvatarFormField, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/AvatarFormField', component: AvatarFormField, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof AvatarFormField +>; export default meta; type Story = StoryObj; export const UserEmpty: Story = { - args: { - form: undefined as never, - name: 'thumbnailUrl', - label: 'Profile picture', - type: 'user', - entity: { id: 'user-1', name: 'Ada Lovelace' }, - }, + args: { form: undefined as never, name: 'thumbnailUrl', label: 'Profile picture', type: 'user', entity: { id: 'user-1', name: 'Ada Lovelace' } }, render: function Render() { return ; }, }; export const UserWithImage: Story = { - args: { - form: undefined as never, - name: 'thumbnailUrl', - label: 'Profile picture', - type: 'user', - entity: { id: 'user-2', name: 'Grace Hopper' }, - }, + args: { form: undefined as never, name: 'thumbnailUrl', label: 'Profile picture', type: 'user', entity: { id: 'user-2', name: 'Grace Hopper' } }, render: function Render() { return ( - ); + return ; }, }; diff --git a/frontend/src/modules/common/stories/close-button.stories.tsx b/frontend/src/modules/common/stories/close-button.stories.tsx index e7a6c7884..b6a7a7174 100644 --- a/frontend/src/modules/common/stories/close-button.stories.tsx +++ b/frontend/src/modules/common/stories/close-button.stories.tsx @@ -15,17 +15,11 @@ type Story = StoryObj; export const Default: Story = {}; -export const Small: Story = { - args: { size: 'sm' }, -}; +export const Small: Story = { args: { size: 'sm' } }; -export const Medium: Story = { - args: { size: 'md' }, -}; +export const Medium: Story = { args: { size: 'md' } }; -export const Large: Story = { - args: { size: 'lg' }, -}; +export const Large: Story = { args: { size: 'lg' } }; export const AllSizes: Story = { render: (args) => ( diff --git a/frontend/src/modules/ui/stories/combobox.stories.tsx b/frontend/src/modules/common/stories/combobox-select.stories.tsx similarity index 85% rename from frontend/src/modules/ui/stories/combobox.stories.tsx rename to frontend/src/modules/common/stories/combobox-select.stories.tsx index 1d1705bfc..15fa6cd25 100644 --- a/frontend/src/modules/ui/stories/combobox.stories.tsx +++ b/frontend/src/modules/common/stories/combobox-select.stories.tsx @@ -1,28 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import type { TKey } from '~/lib/i18n-locales'; -import { ComboboxSelect, type ComboboxSelectProps } from '~/modules/ui/combobox'; +import { ComboboxSelect, type ComboboxSelectProps } from '~/modules/common/form-fields/select-combobox/combobox-select'; /** * A searchable dropdown component built on base-ui Combobox. */ const meta = { - title: 'ui/Combobox', + title: 'common/ComboboxSelect', component: ComboboxSelect, tags: ['autodocs'], - parameters: { - layout: 'centered', - }, - argTypes: { - options: { control: 'object' }, - value: { control: 'text' }, - disabled: { control: 'boolean' }, - renderAvatar: { control: 'boolean' }, - }, - args: { - disabled: false, - renderAvatar: false, - }, + parameters: { layout: 'centered' }, + argTypes: { options: { control: 'object' }, value: { control: 'text' }, disabled: { control: 'boolean' }, renderAvatar: { control: 'boolean' } }, + args: { disabled: false, renderAvatar: false }, } satisfies Meta; export default meta; @@ -77,13 +67,7 @@ export const WithAvatars: Story = { const [value, setValue] = useState(args.value); return (
    - +
    ); }, @@ -158,13 +142,7 @@ export const LargeOptions: Story = { const [value, setValue] = useState(args.value); return (
    - +
    ); }, @@ -188,11 +166,7 @@ export const CustomPlaceholders: Story = { options={args.options} value={value} onChange={setValue} - placeholders={{ - trigger: 'c:select_resource' as TKey, - search: 'c:search' as TKey, - notFound: 'c:no_resource_found' as TKey, - }} + placeholders={{ trigger: 'c:select_resource' as TKey, search: 'c:search' as TKey, notFound: 'c:no_resource_found' as TKey }} />
    ); diff --git a/frontend/src/modules/common/stories/content-placeholder.stories.tsx b/frontend/src/modules/common/stories/content-placeholder.stories.tsx index eaabc7b5e..f4b32d839 100644 --- a/frontend/src/modules/common/stories/content-placeholder.stories.tsx +++ b/frontend/src/modules/common/stories/content-placeholder.stories.tsx @@ -14,19 +14,9 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - title: 'c:no_results' as TKey, - icon: InboxIcon, - }, -}; +export const Default: Story = { args: { title: 'c:no_results' as TKey, icon: InboxIcon } }; -export const SearchEmpty: Story = { - args: { - title: 'c:no_results' as TKey, - icon: SearchIcon, - }, -}; +export const SearchEmpty: Story = { args: { title: 'c:no_results' as TKey, icon: SearchIcon } }; export const WithChildren: Story = { args: { @@ -36,8 +26,4 @@ export const WithChildren: Story = { }, }; -export const NoIcon: Story = { - args: { - title: 'c:no_results' as TKey, - }, -}; +export const NoIcon: Story = { args: { title: 'c:no_results' as TKey } }; diff --git a/frontend/src/modules/common/stories/data-grid.stories.tsx b/frontend/src/modules/common/stories/data-grid.stories.tsx index 3de726803..c77d54041 100644 --- a/frontend/src/modules/common/stories/data-grid.stories.tsx +++ b/frontend/src/modules/common/stories/data-grid.stories.tsx @@ -2,6 +2,7 @@ import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { expect, userEvent, waitFor } from 'storybook/test'; +import { cn } from '~/utils/cn'; import { type CellSelectionMode, type Column, @@ -183,13 +184,7 @@ const sortableColumns: Column[] = [ { key: 'lastName', name: 'Last Name', width: 120, sortable: true }, { key: 'age', name: 'Age', width: 80, sortable: true }, { key: 'department', name: 'Department', width: 130, sortable: true }, - { - key: 'salary', - name: 'Salary', - width: 110, - sortable: true, - renderCell: ({ row }) => `$${row.salary.toLocaleString()}`, - }, + { key: 'salary', name: 'Salary', width: 110, sortable: true, renderCell: ({ row }) => `$${row.salary.toLocaleString()}` }, ]; const responsiveColumns: Column[] = [ @@ -209,30 +204,16 @@ const meta: Meta> = { title: 'common/DataGrid', component: DataGrid, tags: ['autodocs'], - parameters: { - layout: 'padded', - }, + parameters: { layout: 'padded' }, argTypes: { - cellSelectionMode: { - control: 'select', - options: ['none', 'cell', 'cell-range'], - }, - rowSelectionMode: { - control: 'select', - options: ['none', 'single', 'multi'], - }, + cellSelectionMode: { control: 'select', options: ['none', 'cell', 'cell-range'] }, + rowSelectionMode: { control: 'select', options: ['none', 'single', 'multi'] }, rowHeight: { control: { type: 'number', min: 25, max: 80 } }, headerRowHeight: { control: { type: 'number', min: 25, max: 80 } }, enableVirtualization: { control: 'boolean' }, isCompact: { control: 'boolean' }, }, - args: { - rows: sampleData, - columns: basicColumns, - rowHeight: 35, - headerRowHeight: 35, - enableVirtualization: true, - }, + args: { rows: sampleData, columns: basicColumns, rowHeight: 35, headerRowHeight: 35, enableVirtualization: true }, decorators: [ (Story) => (
    @@ -252,9 +233,7 @@ type Story = StoryObj>; export const Default: Story = {}; /** All columns including frozen ID, custom renderers, and many fields. */ -export const FullColumns: Story = { - args: { columns: fullColumns }, -}; +export const FullColumns: Story = { args: { columns: fullColumns } }; /** Toggle between all selection modes interactively. */ export const SelectionModes: Story = { @@ -262,10 +241,7 @@ export const SelectionModes: Story = { const [cellMode, setCellMode] = useState('cell'); const [rowMode, setRowMode] = useState('multi'); const [selectedRows, setSelectedRows] = useState>(new Set()); - const [selectedRange, setSelectedRange] = useState<{ - start: { idx: number; rowIdx: number }; - end: { idx: number; rowIdx: number }; - } | null>(null); + const [selectedRange, setSelectedRange] = useState<{ start: { idx: number; rowIdx: number }; end: { idx: number; rowIdx: number } } | null>(null); return (
    @@ -279,7 +255,7 @@ export const SelectionModes: Story = { setCellMode(m); setSelectedRange(null); }} - className={`rounded px-3 py-1 text-sm ${cellMode === m ? 'bg-primary text-primary-foreground' : 'bg-muted'}`} + className={cn('rounded px-3 py-1 text-sm', cellMode === m ? 'bg-primary text-primary-foreground' : 'bg-muted')} > {m} @@ -295,7 +271,7 @@ export const SelectionModes: Story = { setRowMode(m); setSelectedRows(new Set()); }} - className={`rounded px-3 py-1 text-sm ${rowMode === m ? 'bg-primary text-primary-foreground' : 'bg-muted'}`} + className={cn('rounded px-3 py-1 text-sm', rowMode === m ? 'bg-primary text-primary-foreground' : 'bg-muted')} > {m} @@ -353,9 +329,7 @@ export const Sorting: Story = { return (
    - Click headers to sort.{' '} - {sortColumns.length > 0 && - `Sorting by: ${sortColumns.map((s) => `${s.columnKey} ${s.direction}`).join(', ')}`} + Click headers to sort. {sortColumns.length > 0 && `Sorting by: ${sortColumns.map((s) => `${s.columnKey} ${s.direction}`).join(', ')}`}
    @@ -367,9 +341,7 @@ export const Sorting: Story = { }; /** Columns show/hide based on viewport breakpoints. Resize the browser to test. */ -export const ResponsiveColumns: Story = { - args: { columns: responsiveColumns }, -}; +export const ResponsiveColumns: Story = { args: { columns: responsiveColumns } }; const largeData = Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, @@ -385,9 +357,7 @@ const largeData = Array.from({ length: 1000 }, (_, i) => ({ })); /** 1000 rows with virtualization for smooth scrolling. */ -export const LargeDataset: Story = { - args: { columns: fullColumns, rows: largeData }, -}; +export const LargeDataset: Story = { args: { columns: fullColumns, rows: largeData } }; /** Click a cell and press Cmd+C to copy its value. */ export const CopyCell: Story = { @@ -484,12 +454,7 @@ export const ShouldSelectRowOnClick: Story = { const [selectedRows, setSelectedRows] = useState>(new Set()); return (
    - row.id} - /> + row.id} />
    ); }, @@ -507,10 +472,9 @@ export const ShouldSelectRowOnClick: Story = { const cell = await canvas.findByText('Bob'); const cellEl = cell.closest('.rdg-cell')!; const style = window.getComputedStyle(cellEl); - // Cell should NOT carry the per-cell selection ring (2px). Avoid asserting - // outlineStyle === 'none' because UA :focus-visible may set outline:auto - // on focused tabindex cells in headless Chromium. - expect(style.outlineWidth).not.toBe('2px'); + // The per-cell selection ring is a solid outline. Chromium keeps reporting its 2px width when the style is + // none, and UA :focus-visible may draw outline:auto on a focused cell, so the check reads the style. + expect(style.outlineStyle).not.toBe('solid'); }); }, }; @@ -549,9 +513,8 @@ export const ShouldSelectCellRange: Story = { const rangeCells = grid.querySelectorAll('.rdg-cell-in-range'); for (const cell of rangeCells) { const style = window.getComputedStyle(cell); - // See ShouldSelectRowOnClick: assert that no per-cell selection ring appears. - // of outlineStyle === 'none' to avoid UA :focus-visible interference. - expect(style.outlineWidth).not.toBe('2px'); + // See ShouldSelectRowOnClick: no cell draws the solid per-cell selection ring. + expect(style.outlineStyle).not.toBe('solid'); } }); @@ -696,9 +659,7 @@ export const ColumnDragDrop: Story = { return (
    -
    - Drag column headers to reorder them. A blue drop indicator shows the target position. -
    +
    Drag column headers to reorder them. A blue drop indicator shows the target position.
    [] = [ function reorderRows(prev: R[], fromIndex: number, toIndex: number, edge: Edge) { const next = [...prev]; const [moved] = next.splice(fromIndex, 1); - const insertIdx = - edge === 'bottom' ? toIndex + (fromIndex < toIndex ? 0 : 1) : toIndex > fromIndex ? toIndex - 1 : toIndex; + const insertIdx = edge === 'bottom' ? toIndex + (fromIndex < toIndex ? 0 : 1) : toIndex > fromIndex ? toIndex - 1 : toIndex; next.splice(insertIdx, 0, moved); return next.map((r, i) => ({ ...r, displayOrder: (i + 1) * 10 })); } @@ -898,11 +858,7 @@ export const ShouldReorderRowOnDragDrop: Story = { export const RowDragDropAutoScroll: Story = { render: function Render() { // 100 rows so the list is much taller than the 300px viewport - const initial = Array.from({ length: 100 }, (_, i) => ({ - ...sampleData[i % sampleData.length], - id: i + 1, - displayOrder: (i + 1) * 10, - })); + const initial = Array.from({ length: 100 }, (_, i) => ({ ...sampleData[i % sampleData.length], id: i + 1, displayOrder: (i + 1) * 10 })); const [rows, setRows] = useState(initial); const onRowReorder = (fromIdx: number, toIdx: number, edge: 'top' | 'bottom') => { @@ -912,9 +868,8 @@ export const RowDragDropAutoScroll: Story = { return (
    - Drag a row near the top or bottom edge of the scroll area: the list auto-scrolls so you can drop on rows that - started off-screen. The grid uses row virtualization, so off-screen rows mount on demand as scrolling reveals - them. + Drag a row near the top or bottom edge of the scroll area: the list auto-scrolls so you can drop on rows that started off-screen. The grid + uses row virtualization, so off-screen rows mount on demand as scrolling reveals them.
    ; +const meta = { title: 'common/DropIndicator', component: DropIndicator, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof DropIndicator +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/entity-avatar.stories.tsx b/frontend/src/modules/common/stories/entity-avatar.stories.tsx index f19312d8b..11b41b126 100644 --- a/frontend/src/modules/common/stories/entity-avatar.stories.tsx +++ b/frontend/src/modules/common/stories/entity-avatar.stories.tsx @@ -2,36 +2,20 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BuildingIcon, ShieldCheckIcon, UserIcon } from 'lucide-react'; import { EntityAvatar } from '~/modules/common/entity-avatar'; -const meta = { - title: 'common/EntityAvatar', - component: EntityAvatar, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/EntityAvatar', component: EntityAvatar, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof EntityAvatar +>; export default meta; type Story = StoryObj; -export const WithFallback: Story = { - args: { id: '1', name: 'Alice', type: 'user' }, -}; +export const WithFallback: Story = { args: { id: '1', name: 'Alice', type: 'user' } }; -export const WithImage: Story = { - args: { - id: '1', - name: 'Alice', - url: 'https://i.pravatar.cc/150?u=alice', - type: 'user', - }, -}; +export const WithImage: Story = { args: { id: '1', name: 'Alice', url: 'https://i.pravatar.cc/150?u=alice', type: 'user' } }; -export const WithIcon: Story = { - args: { icon: ShieldCheckIcon }, -}; +export const WithIcon: Story = { args: { icon: ShieldCheckIcon } }; -export const Organization: Story = { - args: { id: '2', name: 'Acme Corp', type: 'organization' }, -}; +export const Organization: Story = { args: { id: '2', name: 'Acme Corp', type: 'organization' } }; export const Sizes: Story = { render: () => ( diff --git a/frontend/src/modules/common/stories/focus-trap.stories.tsx b/frontend/src/modules/common/stories/focus-trap.stories.tsx index 7b72ab244..7f8321313 100644 --- a/frontend/src/modules/common/stories/focus-trap.stories.tsx +++ b/frontend/src/modules/common/stories/focus-trap.stories.tsx @@ -36,11 +36,7 @@ function TrapWithButtons({
    {label} {Array.from({ length: count }, (_, i) => ( - ))} @@ -53,12 +49,7 @@ function ToggleTrap() { const [active, setActive] = useState(true); return (
    - @@ -70,12 +61,7 @@ function InitialFocusTrap() { const [active, setActive] = useState(false); return (
    - {active && } @@ -87,12 +73,7 @@ function ReturnFocusTrap() { const [active, setActive] = useState(false); return (
    - {active && } @@ -114,12 +95,7 @@ function ContainFocusTrap() { function EscapeTrap() { return (
    -
    +
    Main element (Escape target)
    @@ -138,12 +114,7 @@ function DisableInactiveTrap() { const [active, setActive] = useState(true); return (
    - @@ -153,13 +124,7 @@ function DisableInactiveTrap() { // Meta -const meta = { - title: 'common/FocusTrap', - tags: ['autodocs'], - parameters: { - layout: 'centered', - }, -} satisfies Meta; +const meta = { title: 'common/FocusTrap', tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta; export default meta; diff --git a/frontend/src/modules/common/stories/help-text.stories.tsx b/frontend/src/modules/common/stories/help-text.stories.tsx index 29992e22f..b6620b58d 100644 --- a/frontend/src/modules/common/stories/help-text.stories.tsx +++ b/frontend/src/modules/common/stories/help-text.stories.tsx @@ -1,12 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { HelpText } from '~/modules/common/help-text'; -const meta = { - title: 'common/HelpText', - component: HelpText, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/HelpText', component: HelpText, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof HelpText +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/input-form-field.stories.tsx b/frontend/src/modules/common/stories/input-form-field.stories.tsx index dbf40e41c..98f7c702e 100644 --- a/frontend/src/modules/common/stories/input-form-field.stories.tsx +++ b/frontend/src/modules/common/stories/input-form-field.stories.tsx @@ -4,9 +4,7 @@ import type { ReactNode } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { InputFormField } from '~/modules/common/form-fields/input'; -type StoryFormValues = { - title: string; -}; +type StoryFormValues = { title: string }; type InputStoryProps = { label: string; @@ -35,11 +33,7 @@ function InputFormFieldStory({ defaultValue = '', value, }: InputStoryProps) { - const form = useForm({ - defaultValues: { - title: defaultValue, - }, - }); + const form = useForm({ defaultValues: { title: defaultValue } }); return ( @@ -63,73 +57,38 @@ function InputFormFieldStory({ ); } -const meta = { - title: 'common/InputFormField', - component: InputFormField, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta>; +const meta = { title: 'common/InputFormField', component: InputFormField, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof InputFormField +>; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - control: undefined as never, - name: 'title', - label: 'Title', - }, + args: { control: undefined as never, name: 'title', label: 'Title' }, render: function Render() { return ; }, }; export const WithDescription: Story = { - args: { - control: undefined as never, - name: 'title', - label: 'Email', - description: 'Used for notifications and account updates.', - type: 'email', - }, + args: { control: undefined as never, name: 'title', label: 'Email', description: 'Used for notifications and account updates.', type: 'email' }, render: function Render() { return ( - + ); }, }; export const RequiredWithIcon: Story = { - args: { - control: undefined as never, - name: 'title', - label: 'Search', - required: true, - }, + args: { control: undefined as never, name: 'title', label: 'Search', required: true }, render: function Render() { - return ( - } - /> - ); + return } />; }, }; export const Textarea: Story = { - args: { - control: undefined as never, - name: 'title', - label: 'Description', - type: 'textarea', - }, + args: { control: undefined as never, name: 'title', label: 'Description', type: 'textarea' }, render: function Render() { return ( } />; + return } />; }, }; export const ReadOnly: Story = { - args: { - control: undefined as never, - name: 'title', - label: 'Slug', - readOnly: true, - value: 'story-input-field', - }, + args: { control: undefined as never, name: 'title', label: 'Slug', readOnly: true, value: 'story-input-field' }, render: function Render() { return ; }, diff --git a/frontend/src/modules/common/stories/logo.stories.tsx b/frontend/src/modules/common/stories/logo.stories.tsx index 7d6d9d951..cad0dd805 100644 --- a/frontend/src/modules/common/stories/logo.stories.tsx +++ b/frontend/src/modules/common/stories/logo.stories.tsx @@ -1,30 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Logo } from '~/modules/common/logo'; -const meta = { - title: 'common/Logo', - component: Logo, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/Logo', component: Logo, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = {}; -export const IconOnly: Story = { - args: { iconOnly: true }, -}; +export const IconOnly: Story = { args: { iconOnly: true } }; -export const CustomColors: Story = { - args: { iconColor: '#FF6B00', textColor: '#1a1a1a' }, -}; +export const CustomColors: Story = { args: { iconColor: '#FF6B00', textColor: '#1a1a1a' } }; -export const Small: Story = { - args: { height: 30 }, -}; +export const Small: Story = { args: { height: 30 } }; -export const Large: Story = { - args: { height: 80 }, -}; +export const Large: Story = { args: { height: 80 } }; diff --git a/frontend/src/modules/common/stories/media-thumbnail.stories.tsx b/frontend/src/modules/common/stories/media-thumbnail.stories.tsx index 6b523e22b..183ccaf9e 100644 --- a/frontend/src/modules/common/stories/media-thumbnail.stories.tsx +++ b/frontend/src/modules/common/stories/media-thumbnail.stories.tsx @@ -1,39 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { MediaThumbnail } from '~/modules/common/media-thumbnail'; -const meta = { - title: 'common/MediaThumbnail', - component: MediaThumbnail, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/MediaThumbnail', component: MediaThumbnail, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof MediaThumbnail +>; export default meta; type Story = StoryObj; -export const WithImage: Story = { - args: { - name: 'Sample image', - url: 'https://picsum.photos/seed/cella/64', - contentType: 'image/jpeg', - }, -}; +export const WithImage: Story = { args: { name: 'Sample image', url: 'https://picsum.photos/seed/cella/64', contentType: 'image/jpeg' } }; -export const BrokenImage: Story = { - args: { - name: 'Broken image', - url: 'https://invalid.example.com/missing.jpg', - contentType: 'image/jpeg', - }, -}; +export const BrokenImage: Story = { args: { name: 'Broken image', url: 'https://invalid.example.com/missing.jpg', contentType: 'image/jpeg' } }; -export const NoUrl: Story = { - args: { - name: 'No url', - url: undefined, - contentType: 'image/png', - }, -}; +export const NoUrl: Story = { args: { name: 'No url', url: undefined, contentType: 'image/png' } }; export const ContentTypeVariants: Story = { args: { name: 'variants', contentType: 'image/png' }, diff --git a/frontend/src/modules/common/stories/render-expand-toggle.stories.tsx b/frontend/src/modules/common/stories/render-expand-toggle.stories.tsx index e9c3ea7e9..2e882efb9 100644 --- a/frontend/src/modules/common/stories/render-expand-toggle.stories.tsx +++ b/frontend/src/modules/common/stories/render-expand-toggle.stories.tsx @@ -48,34 +48,22 @@ type Story = StoryObj; export const Root: Story = {}; /** Root row with no children: renders nothing at depth 0. */ -export const RootLeaf: Story = { - args: { hasChildren: false }, -}; +export const RootLeaf: Story = { args: { hasChildren: false } }; /** Inner row (depth 1) with children. Solid 2px connectors. */ -export const InnerExpanded: Story = { - args: { depth: 1, expanded: true, hasChildren: true }, -}; +export const InnerExpanded: Story = { args: { depth: 1, expanded: true, hasChildren: true } }; /** Inner leaf row (depth 1, no children). Filled bullet on the centered track. */ -export const InnerLeaf: Story = { - args: { depth: 1, hasChildren: false }, -}; +export const InnerLeaf: Story = { args: { depth: 1, hasChildren: false } }; /** Deepest row (depth 2 of maxDepth 3) with no children. Thin lines + hollow bullet on the deeper track. */ -export const DeepestLeaf: Story = { - args: { depth: 2, hasChildren: false, maxDepth: 3 }, -}; +export const DeepestLeaf: Story = { args: { depth: 2, hasChildren: false, maxDepth: 3 } }; /** Deepest row that is also the last child; connector below should not be drawn. */ -export const DeepestLeafLastChild: Story = { - args: { depth: 2, hasChildren: false, isLastChild: true, maxDepth: 3 }, -}; +export const DeepestLeafLastChild: Story = { args: { depth: 2, hasChildren: false, isLastChild: true, maxDepth: 3 } }; /** Inner row whose parent is itself a last child; depth-1 trunk should NOT continue. */ -export const DeepestParentIsLast: Story = { - args: { depth: 2, hasChildren: false, parentIsLastChild: true, maxDepth: 3 }, -}; +export const DeepestParentIsLast: Story = { args: { depth: 2, hasChildren: false, parentIsLastChild: true, maxDepth: 3 } }; /** * Interaction test: clicking the toggle button calls `onToggle`. Tagged @@ -84,10 +72,7 @@ export const DeepestParentIsLast: Story = { export const ShouldFireOnToggle: Story = { name: 'when chevron clicked, should call onToggle', tags: ['!dev', '!autodocs'], - args: { - depth: 1, - hasChildren: true, - }, + args: { depth: 1, hasChildren: true }, render: function Render(args) { const [calls, setCalls] = useState(0); return ( diff --git a/frontend/src/modules/common/stories/scroll-spy.stories.tsx b/frontend/src/modules/common/stories/scroll-spy.stories.tsx index 851db4097..b03da86a4 100644 --- a/frontend/src/modules/common/stories/scroll-spy.stories.tsx +++ b/frontend/src/modules/common/stories/scroll-spy.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { createRootRoute, createRoute, createRouter, Link, Outlet, RouterProvider } from '@tanstack/react-router'; import { BookmarkIcon, InfoIcon, SettingsIcon, ShieldIcon, Trash2Icon } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; +import { expect, waitFor } from 'storybook/test'; import { useScrollSpy } from '~/hooks/use-scroll-spy'; import { getSection, scrollToSectionById } from '~/hooks/use-scroll-spy-store'; import { Button } from '~/modules/ui/button'; @@ -30,15 +31,7 @@ const tabs: SidebarTab[] = [ // ─── AsideAnchor (mirrors ~/modules/common/aside-anchor.tsx) ───────────────── -const AsideAnchor = ({ - id, - children, - extraOffset, -}: { - id: string; - children?: React.ReactNode; - extraOffset?: boolean; -}) => ( +const AsideAnchor = ({ id, children, extraOffset }: { id: string; children?: React.ReactNode; extraOffset?: boolean }) => (
    {children} @@ -53,8 +46,8 @@ const SectionCard = ({ id, title, lines = 8 }: { id: string; title: string; line

    {title}

    {Array.from({ length: lines }, (_, i) => (

    - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore - magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco. + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco.

    ))}
    @@ -72,11 +65,7 @@ const SidebarWithRouter = () => ( variant="ghost" size="lg" data-spy-link={id} - className={cn( - 'w-full justify-start text-left hover:bg-accent/50', - id.includes('delete') && 'text-red-600', - 'data-spy-active:bg-secondary', - )} + className={cn('w-full justify-start text-left hover:bg-accent/50', id.includes('delete') && 'text-red-600', 'data-spy-active:bg-secondary')} render={ ( /> } > - {label} + {label} ))}
    @@ -199,11 +188,7 @@ const TestPanel = () => { -
    @@ -212,10 +197,7 @@ const TestPanel = () => {
    {log.length === 0 &&
    Click a button or scroll manually…
    } {log.map((entry, i) => ( -
    +
    {entry}
    ))} @@ -226,22 +208,12 @@ const TestPanel = () => { // ─── Full page layout ──────────────────────────────────────────────────────── -const ScrollSpyPage = ({ - withRouter = true, - label, - showTests, -}: { - withRouter?: boolean; - label?: string; - showTests?: boolean; -}) => ( +const ScrollSpyPage = ({ withRouter = true, label, showTests }: { withRouter?: boolean; label?: string; showTests?: boolean }) => (
    {/* Sticky header */}

    {label ?? 'Scroll Spy Test'}

    -

    - {withRouter ? 'TanStack Router (production-like)' : 'No Router (isolation)'} -

    +

    {withRouter ? 'TanStack Router (production-like)' : 'No Router (isolation)'}

    @@ -285,12 +257,7 @@ const createStoryRouter = (label: string, showTests?: boolean) => { ), }); - const catchAllRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '$', - staticData: { isAuth: false }, - component: () => null, - }); + const catchAllRoute = createRoute({ getParentRoute: () => rootRoute, path: '$', staticData: { isAuth: false }, component: () => null }); return createRouter({ routeTree: rootRoute.addChildren([catchAllRoute]), @@ -349,3 +316,74 @@ export const WithRouter: Story = { export const Interactive: Story = { render: () => , }; + +// ─── Section rules ─────────────────────────────────────────────────────────── + +const ruleIds = ['intro', 'first', 'pinned', 'next']; + +/** Two short anchors at the top and two near the end, so each pair sits past the trigger line together. */ +const RulesPage = () => { + useScrollSpy(ruleIds); + return ( +
    +
    + intro +
    +
    + first +
    +
    +
    + pinned +
    +
    + next +
    +
    +
    + ); +}; + +/** A section a scroll was sent to stays current while its neighbour is past the trigger too; back at the top, the topmost wins. */ +export const SectionRules: Story = { + render: () => , + play: async () => { + scrollToSectionById('pinned'); + await waitFor(() => expect(getSection()).toBe('pinned')); + + // Past the programmatic block (an instant scroll blocks 500ms) and its re-evaluation + await new Promise((resolve) => setTimeout(resolve, 700)); + await expect(getSection()).toBe('pinned'); + + // A user scroll releases the pin; at the top, 'first' is past the trigger too but 'intro' is current + window.scrollTo({ top: 0, behavior: 'instant' }); + await waitFor(() => expect(getSection()).toBe('intro')); + }, +}; + +const articleIds = ['article', 'first-heading', 'last-heading']; + +/** A docs page shape: a section wrapping the whole article, two headings, and a long last section. */ +const ArticlePage = () => { + useScrollSpy(articleIds); + return ( +
    +
    +

    first heading

    +
    +

    last heading

    +
    +
    + ); +}; + +/** After a jump past every heading, the last heading above stays current, even while the article is still in view. */ +export const LongJump: Story = { + render: () => , + play: async () => { + await waitFor(() => expect(getSection()).toBe('article')); + + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }); + await waitFor(() => expect(getSection()).toBe('last-heading')); + }, +}; diff --git a/frontend/src/modules/common/stories/search-history.stories.tsx b/frontend/src/modules/common/stories/search-history.stories.tsx new file mode 100644 index 000000000..39cf35836 --- /dev/null +++ b/frontend/src/modules/common/stories/search-history.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { AppSearch } from '~/modules/navigation/app-search'; +import { useNavigationStore } from '~/modules/navigation/navigation-store'; +import { withApp } from '~/stories/with-app'; + +const history = ['passkeys', 'sessions', 'tenants']; + +/** Recent searches of the app search: a numbered history group and an index shortcut. Docs search: docs-search.test.tsx. */ +const meta = { title: 'common/SearchHistory', decorators: [withApp], parameters: { layout: 'padded' } } satisfies Meta; + +export default meta; +type Story = StoryObj; + +const historyOption = (canvas: ReturnType, value: string) => canvas.getByRole('option', { name: new RegExp(`^${value}\\s*\\d`) }); + +export const AppSearchHistory: Story = { + beforeEach: () => { + useNavigationStore.setState({ recentSearches: [...history] }); + return () => useNavigationStore.setState({ recentSearches: [] }); + }, + render: () => ( +
    + +
    + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const input = await canvas.findByRole('combobox'); + + await step('lists recent searches with their index', async () => { + for (const [index, value] of history.entries()) { + await expect(historyOption(canvas, value)).toHaveTextContent(`${value}${index}`); + } + }); + + await step('the remove button drops one entry', async () => { + await userEvent.click(within(historyOption(canvas, 'sessions')).getByRole('button')); + + await expect(useNavigationStore.getState().recentSearches).toEqual(['passkeys', 'tenants']); + await waitFor(() => expect(canvas.queryByRole('option', { name: /^sessions/ })).toBeNull()); + }); + + await step('a bare index typed into the empty input picks that entry', async () => { + await userEvent.type(input, '1'); + await expect(input).toHaveValue('tenants'); + }); + }, +}; diff --git a/frontend/src/modules/common/stories/search-spinner.stories.tsx b/frontend/src/modules/common/stories/search-spinner.stories.tsx index 6e90da5e2..0bcc6a459 100644 --- a/frontend/src/modules/common/stories/search-spinner.stories.tsx +++ b/frontend/src/modules/common/stories/search-spinner.stories.tsx @@ -3,27 +3,18 @@ import { useState } from 'react'; import { SearchSpinner } from '~/modules/common/search-spinner'; import { Button } from '~/modules/ui/button'; -const meta = { - title: 'common/SearchSpinner', - component: SearchSpinner, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SearchSpinner', component: SearchSpinner, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SearchSpinner +>; export default meta; type Story = StoryObj; -export const Idle: Story = { - args: { isSearching: false, value: '' }, -}; +export const Idle: Story = { args: { isSearching: false, value: '' } }; -export const IdleWithValue: Story = { - args: { isSearching: false, value: 'hello' }, -}; +export const IdleWithValue: Story = { args: { isSearching: false, value: 'hello' } }; -export const Searching: Story = { - args: { isSearching: true, value: 'hello' }, -}; +export const Searching: Story = { args: { isSearching: true, value: 'hello' } }; export const Interactive: Story = { args: { isSearching: false, value: 'test' }, diff --git a/frontend/src/modules/common/stories/select-emails.stories.tsx b/frontend/src/modules/common/stories/select-emails.stories.tsx index 29e6b878e..9a9762534 100644 --- a/frontend/src/modules/common/stories/select-emails.stories.tsx +++ b/frontend/src/modules/common/stories/select-emails.stories.tsx @@ -1,17 +1,20 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; +import { expect, spyOn, userEvent, within } from 'storybook/test'; import { SelectEmails } from '~/modules/common/form-fields/select-emails'; +import { toaster } from '~/modules/common/toaster/toaster'; /** - * Email input component with multi-email support, validation, and paste handling. - * Built on top of TagInput with email-specific validation and delimiter support. + * Email chip input of the invite form: typed or pasted addresses become chips when an invite submit would accept them. */ const meta: Meta = { title: 'common/SelectEmails', component: SelectEmails, tags: ['autodocs'], - parameters: { - layout: 'centered', + parameters: { layout: 'centered' }, + beforeEach: () => { + const warning = spyOn(toaster, 'warning'); + return () => warning.mockRestore(); }, } satisfies Meta; @@ -48,147 +51,214 @@ export const Empty: Story = { }; /** - * Email input with display name support. - * Accepts emails in format "Name ". + * Email input demonstrating paste functionality. + * Try pasting: "test1@example.com, test2@example.com; test3@example.com" */ -export const WithDisplayName: Story = { +export const PasteMultiple: Story = { render: function Render() { - const [emails, setEmails] = useState(['John Doe ', 'jane@example.com']); + const [emails, setEmails] = useState([]); return (
    - + +

    Try pasting: test1@example.com, test2@example.com; test3@example.com

    ); }, }; -/** - * Email input that strips display names to extract only the email address. - */ -export const StripDisplayName: Story = { - render: function Render() { - const [emails, setEmails] = useState([]); - return ( -
    - { - console.info('Emails:', newEmails); - setEmails(newEmails); - }} - allowDisplayName - stripDisplayName - placeholder="Try: John Doe " - /> -

    - Display names are stripped, only email addresses are stored. -

    -
    - ); +/** The field as the invite form uses it, inside a form that counts submits. */ +function InviteEmailsField({ initial = [] }: { initial?: string[] }) { + const [emails, setEmails] = useState(initial); + const [submits, setSubmits] = useState(0); + + return ( +
    { + event.preventDefault(); + setSubmits((count) => count + 1); + }} + > + + value: {JSON.stringify(emails)} +

    submits: {submits}

    + + + ); +} + +const playTags = ['!dev', '!autodocs']; + +export const ShouldCommitOnDelimiters: Story = { + name: 'when Enter, comma, semicolon or space follows an address, should add it as a chip', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await userEvent.type(input, 'a@x.com{Enter}'); + await userEvent.type(input, 'b@x.com,'); + await userEvent.type(input, 'c@x.com;'); + await userEvent.type(input, 'd@x.com '); + + await expect(canvas.getByText('value: ["a@x.com","b@x.com","c@x.com","d@x.com"]')).toBeVisible(); + for (const email of ['a@x.com', 'b@x.com', 'c@x.com', 'd@x.com']) await expect(canvas.getByText(email)).toBeVisible(); + await expect(input).toHaveValue(''); + await expect(canvas.getByText('submits: 0')).toBeVisible(); }, }; -/** - * Email input allowing duplicate entries. - */ -export const AllowDuplicates: Story = { - render: function Render() { - const [emails, setEmails] = useState(['user@example.com']); - return ( -
    - -

    Try adding the same email twice.

    -
    - ); +export const ShouldCommitOnBlur: Story = { + name: 'when the input loses focus with an address, should add it as a chip', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await userEvent.type(input, 'blur@x.com'); + await userEvent.tab(); + + await expect(canvas.getByText('value: ["blur@x.com"]')).toBeVisible(); + await expect(input).toHaveValue(''); }, }; -/** - * Email input with maximum tag limit. - */ -export const WithMaxEmails: Story = { - render: function Render() { - const [emails, setEmails] = useState(['first@example.com', 'second@example.com']); - return ( -
    - -
    - ); +export const ShouldRemoveChips: Story = { + name: 'when Backspace is pressed on an empty input or a chip is closed, should remove that chip', + tags: playTags, + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await step('Backspace on an empty input removes the last chip', async () => { + await userEvent.click(input); + await userEvent.keyboard('{Backspace}'); + await expect(canvas.getByText('value: ["a@x.com","b@x.com"]')).toBeVisible(); + await expect(canvas.queryByText('c@x.com')).not.toBeInTheDocument(); + }); + + await step('Backspace inside typed text only edits the text', async () => { + await userEvent.type(input, 'ab{Backspace}'); + await expect(input).toHaveValue('a'); + await expect(canvas.getByText('value: ["a@x.com","b@x.com"]')).toBeVisible(); + await userEvent.clear(input); + }); + + await step('the close button of a chip removes that chip', async () => { + await userEvent.click(within(canvas.getByText('a@x.com')).getByRole('button')); + await expect(canvas.getByText('value: ["b@x.com"]')).toBeVisible(); + }); }, }; -/** - * Email input with custom styling. - */ -export const CustomStyling: Story = { - render: function Render() { - const [emails, setEmails] = useState(['styled@example.com']); - return ( -
    - -
    - ); +export const ShouldIgnoreCaseDuplicates: Story = { + name: 'when an address differs from a chip only in case, should keep one chip', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.type(canvas.getByRole('textbox'), 'A@X.COM{Enter}'); + + await expect(canvas.getByText('value: ["a@x.com"]')).toBeVisible(); + await expect(canvas.queryByText('A@X.COM')).not.toBeInTheDocument(); }, }; -/** - * Email input demonstrating paste functionality. - * Try pasting: "test1@example.com, test2@example.com; test3@example.com" - */ -export const PasteMultiple: Story = { - render: function Render() { - const [emails, setEmails] = useState([]); - return ( -
    - -

    - Try pasting: test1@example.com, test2@example.com; test3@example.com -

    -
    - ); +export const ShouldNotSubmitOnEnter: Story = { + name: 'when Enter is pressed on an empty input, should not submit the form', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.type(canvas.getByRole('textbox'), '{Enter}'); + + await expect(canvas.getByText('submits: 0')).toBeVisible(); + await expect(canvas.getByText('value: ["a@x.com"]')).toBeVisible(); }, }; -/** - * Email input with event callbacks. - */ -export const WithCallbacks: Story = { - render: function Render() { - const [emails, setEmails] = useState([]); - return ( -
    - console.info('Added:', email)} - onTagRemove={(email) => console.info('Removed:', email)} - onInputChange={(value) => console.info('Input:', value)} - placeholder="Check console for events..." - /> -
    - ); +export const ShouldRejectInvalid: Story = { + name: 'when the typed text is not an email address, should warn and add no chip', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await userEvent.type(input, 'not-an-email{Enter}'); + + await expect(toaster.warning).toHaveBeenCalledTimes(1); + await expect(canvas.getByText('value: []')).toBeVisible(); + await expect(input).toHaveValue('not-an-email'); + await expect(canvas.getByText('submits: 0')).toBeVisible(); + }, +}; + +export const ShouldFollowSubmitRule: Story = { + name: 'when an address is typed, should accept it as a chip exactly when the invite submit would', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await userEvent.type(input, 'jöhn@example.com{Enter}'); + await expect(toaster.warning).toHaveBeenCalledTimes(1); + await expect(canvas.getByText('value: []')).toBeVisible(); + + await userEvent.clear(input); + await userEvent.type(input, 'user@example-.com{Enter}'); + await expect(canvas.getByText('value: ["user@example-.com"]')).toBeVisible(); + }, +}; + +export const ShouldKeepEveryPastedAddress: Story = { + name: 'when several addresses are pasted, should add each of them as a chip', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('a@x.com, b@x.com; c@x.com'); + + await expect(canvas.getByText('value: ["first@x.com","a@x.com","b@x.com","c@x.com"]')).toBeVisible(); + await expect(input).toHaveValue(''); + }, +}; + +export const ShouldKeepInvalidPastedText: Story = { + name: 'when a pasted list holds an invalid address, should add the others and leave it in the input', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('textbox'); + + await userEvent.click(input); + await userEvent.paste('a@x.com, nope, c@x.com'); + + await expect(canvas.getByText('value: ["a@x.com","c@x.com"]')).toBeVisible(); + await expect(toaster.warning).toHaveBeenCalled(); + await expect(input).toHaveValue('nope'); + }, +}; + +export const ShouldLabelRemoveButtons: Story = { + name: 'when chips render, should give each remove button an accessible name', + tags: playTags, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + for (const email of ['a@x.com', 'b@x.com']) { + await expect(within(canvas.getByText(email)).getByRole('button')).toHaveAccessibleName(); + } }, }; diff --git a/frontend/src/modules/common/stories/select-language.stories.tsx b/frontend/src/modules/common/stories/select-language.stories.tsx index b00e4e1aa..8b71fb266 100644 --- a/frontend/src/modules/common/stories/select-language.stories.tsx +++ b/frontend/src/modules/common/stories/select-language.stories.tsx @@ -2,12 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { SelectLanguage } from '~/modules/common/form-fields/select-language'; -const meta = { - title: 'common/SelectLanguage', - component: SelectLanguage, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SelectLanguage', component: SelectLanguage, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SelectLanguage +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/select-languages.stories.tsx b/frontend/src/modules/common/stories/select-languages.stories.tsx index 6b2273a20..a4a2dadcd 100644 --- a/frontend/src/modules/common/stories/select-languages.stories.tsx +++ b/frontend/src/modules/common/stories/select-languages.stories.tsx @@ -4,12 +4,9 @@ import { expect, userEvent, within } from 'storybook/test'; import { Dropdowner } from '~/modules/common/dropdowner/provider'; import { SelectLanguages } from '~/modules/common/form-fields/select-languages'; -const meta = { - title: 'common/SelectLanguages', - component: SelectLanguages, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SelectLanguages', component: SelectLanguages, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SelectLanguages +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/select-role-radio.stories.tsx b/frontend/src/modules/common/stories/select-role-radio.stories.tsx index fc2b93989..268296a7e 100644 --- a/frontend/src/modules/common/stories/select-role-radio.stories.tsx +++ b/frontend/src/modules/common/stories/select-role-radio.stories.tsx @@ -1,18 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { type EntityRole, hierarchy } from 'shared'; -import { expect, userEvent, waitFor } from 'storybook/test'; +import { expect, spyOn, userEvent, waitFor } from 'storybook/test'; import { SelectRoleRadio } from '~/modules/common/form-fields/select-role-radio'; /** The organization vocabulary's floor role: `member` in cella; apps with other vocabularies still run this file unchanged. */ const memberRole = hierarchy.getLeastPrivilegedRole('organization'); -const meta = { - title: 'common/SelectRoleRadio', - component: SelectRoleRadio, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SelectRoleRadio', component: SelectRoleRadio, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SelectRoleRadio +>; export default meta; type Story = StoryObj; @@ -71,6 +68,7 @@ export const ShouldSelectRole: Story = { return ; }, play: async ({ canvas, step }) => { + const consoleError = spyOn(console, 'error'); const radios = await canvas.findAllByRole('radio'); await step('select the first role', async () => { @@ -83,5 +81,9 @@ export const ShouldSelectRole: Story = { await waitFor(() => expect(radios[1]).toBeChecked()); await waitFor(() => expect(radios[0]).not.toBeChecked()); }); + + await step('stay a controlled radio group from no role to a role', async () => { + await expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining('changing the uncontrolled value')); + }); }, }; diff --git a/frontend/src/modules/common/stories/select-role.stories.tsx b/frontend/src/modules/common/stories/select-role.stories.tsx index 21515b93e..6cb70037e 100644 --- a/frontend/src/modules/common/stories/select-role.stories.tsx +++ b/frontend/src/modules/common/stories/select-role.stories.tsx @@ -7,12 +7,9 @@ import { SelectRole } from '~/modules/common/form-fields/select-role'; /** The organization vocabulary's floor role: `member` in cella; apps with other vocabularies still run this file unchanged. */ const memberRole = hierarchy.getLeastPrivilegedRole('organization'); -const meta = { - title: 'common/SelectRole', - component: SelectRole, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SelectRole', component: SelectRole, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SelectRole +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/select-roles.stories.tsx b/frontend/src/modules/common/stories/select-roles.stories.tsx index 0953c86ab..6222408ae 100644 --- a/frontend/src/modules/common/stories/select-roles.stories.tsx +++ b/frontend/src/modules/common/stories/select-roles.stories.tsx @@ -6,12 +6,9 @@ import { SelectRoles } from '~/modules/common/form-fields/select-roles'; /** The organization vocabulary's floor role: `member` in cella; apps with other vocabularies still run this file unchanged. */ const memberRole = hierarchy.getLeastPrivilegedRole('organization'); -const meta = { - title: 'common/SelectRoles', - component: SelectRoles, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SelectRoles', component: SelectRoles, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SelectRoles +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/select-sort.stories.tsx b/frontend/src/modules/common/stories/select-sort.stories.tsx index 9b0d34fd2..64b83b52a 100644 --- a/frontend/src/modules/common/stories/select-sort.stories.tsx +++ b/frontend/src/modules/common/stories/select-sort.stories.tsx @@ -10,12 +10,9 @@ const sortOptions = [ { name: 'c:filter', icon: ListFilterIcon, value: 'manual' }, ] as const; -const meta = { - title: 'common/SelectSort', - component: SelectSort, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta>; +const meta = { title: 'common/SelectSort', component: SelectSort, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SelectSort +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/simple-header.stories.tsx b/frontend/src/modules/common/stories/simple-header.stories.tsx index e41571e49..46471050f 100644 --- a/frontend/src/modules/common/stories/simple-header.stories.tsx +++ b/frontend/src/modules/common/stories/simple-header.stories.tsx @@ -1,26 +1,16 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { SimpleHeader } from '~/modules/common/simple-header'; -const meta = { - title: 'common/SimpleHeader', - component: SimpleHeader, - tags: ['autodocs'], - parameters: { layout: 'padded' }, -} satisfies Meta; +const meta = { title: 'common/SimpleHeader', component: SimpleHeader, tags: ['autodocs'], parameters: { layout: 'padded' } } satisfies Meta< + typeof SimpleHeader +>; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - heading: 'Page title', - text: 'This is a description of the page content.', - }, -}; +export const Default: Story = { args: { heading: 'Page title', text: 'This is a description of the page content.' } }; -export const HeadingOnly: Story = { - args: { heading: 'Settings' }, -}; +export const HeadingOnly: Story = { args: { heading: 'Settings' } }; export const WithChildren: Story = { args: { diff --git a/frontend/src/modules/common/stories/spinner.stories.tsx b/frontend/src/modules/common/stories/spinner.stories.tsx index 88d553373..6d25b0b1e 100644 --- a/frontend/src/modules/common/stories/spinner.stories.tsx +++ b/frontend/src/modules/common/stories/spinner.stories.tsx @@ -1,26 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Spinner } from '~/modules/common/spinner'; -const meta = { - title: 'common/Spinner', - component: Spinner, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/Spinner', component: Spinner, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = {}; -export const NoDelay: Story = { - args: { noDelay: true }, -}; +export const NoDelay: Story = { args: { noDelay: true } }; -export const Small: Story = { - args: { className: 'size-4' }, -}; +export const Small: Story = { args: { className: 'size-4' } }; -export const Large: Story = { - args: { className: 'size-12' }, -}; +export const Large: Story = { args: { className: 'size-12' } }; diff --git a/frontend/src/modules/common/stories/spy-asides.stories.tsx b/frontend/src/modules/common/stories/spy-asides.stories.tsx new file mode 100644 index 000000000..ca9af1997 --- /dev/null +++ b/frontend/src/modules/common/stories/spy-asides.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactNode } from 'react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { useScrollSpy } from '~/hooks/use-scroll-spy'; +import { getSection } from '~/hooks/use-scroll-spy-store'; +import type { LegalSubject } from '~/modules/auth/legal/legal-config'; +import { LegalAside } from '~/modules/marketing/legal/legal-aside'; +import { TocAside } from '~/modules/page/toc-aside'; +import { withApp } from '~/stories/with-app'; + +/** Scroll-spy asides: the docs page "on this page" nav and the legal page subject nav. */ +const meta = { title: 'common/SpyAsides', decorators: [withApp], parameters: { layout: 'fullscreen' } } satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Aside on the left, one screen-high section per id on the right, each with its `spy-` anchor. */ +function SpyPage({ ids, aside }: { ids: string[]; aside: ReactNode }) { + return ( +
    +
    {aside}
    +
    + {ids.map((id) => ( +
    +
    + {id} +
    + ))} +
    +
    +
    + ); +} + +const rowOf = (el: HTMLElement) => el.closest('[data-spy-link]') as HTMLElement; +const bars = (root: HTMLElement) => root.querySelectorAll('span.bg-primary.rounded-full'); + +// ─── TOC ───────────────────────────────────────────────────────────────────── + +const headings = [ + { id: 'intro', text: 'Intro', depth: 2 }, + { id: 'setup', text: 'Setup', depth: 2 }, + { id: 'setup-details', text: 'Details', depth: 3 }, +]; +const headingIds = headings.map((h) => h.id); + +function TocPage() { + useScrollSpy(headingIds); + return } />; +} + +/** One row per heading, deeper headings indented; the active row carries the bar, and a click scrolls there. */ +export const Toc: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const nav = await canvas.findByRole('navigation', { name: /on_this_page/ }); + + const intro = within(nav).getByRole('link', { name: 'Intro' }); + const details = within(nav).getByRole('link', { name: 'Details' }); + await expect(intro).toHaveAttribute('href', '/#intro'); + await expect(intro).toHaveClass('pl-5'); + await expect(details).toHaveClass('pl-8'); + + // The first registered section is current until something scrolls + await waitFor(() => expect(rowOf(intro)).toHaveAttribute('data-active', 'true')); + await expect(bars(nav)).toHaveLength(1); + await expect(rowOf(intro).contains(bars(nav)[0])).toBe(true); + + await userEvent.click(details); + + await waitFor(() => expect(getSection()).toBe('setup-details')); + await waitFor(() => expect(rowOf(details)).toHaveAttribute('data-active', 'true')); + await expect(rowOf(details)).toHaveAttribute('data-spy-active'); + await expect(rowOf(intro)).toHaveAttribute('data-active', 'false'); + await waitFor(() => expect(bars(nav)).toHaveLength(1)); + await expect(rowOf(details).contains(bars(nav)[0])).toBe(true); + }, +}; + +// ─── Legal ─────────────────────────────────────────────────────────────────── + +const subjects = [ + { + id: 'terms' as LegalSubject, + label: 'c:terms_of_use' as const, + sections: [ + { id: 'overview', label: 'Overview' }, + { id: 'introduction', label: 'Introduction' }, + { id: 'cookies', label: 'Cookies' }, + ], + }, + { + id: 'privacy' as LegalSubject, + label: 'c:privacy_policy' as const, + sections: [ + { id: 'overview', label: null }, + { id: 'introduction', label: 'Introduction' }, + { id: 'cookies', label: 'Cookies' }, + ], + }, +]; + +/** + * The current subject is expanded; its rows are active by section, falling back to 'overview' while the spy + * has none, and rows of another subject never are. + */ +export const Legal: Story = { + render: () => ( + } /> + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The subject link is the collapsible trigger, so it carries a button role + const termsLink = (await canvas.findByText(/terms_of_use/)).closest('a'); + await expect(termsLink).toHaveAttribute('href', '/legal/terms#overview'); + + const rows = (id: string) => [...canvasElement.querySelectorAll(`[data-spy-link="${id}"]`)]; + // The privacy overview has no label and no row + await expect(rows('overview')).toHaveLength(1); + const [termsOverview] = rows('overview'); + const [termsCookies, privacyCookies] = rows('cookies'); + + await expect(getSection()).toBe(''); + await expect(termsOverview).toHaveAttribute('data-active', 'true'); + await expect(privacyCookies).not.toBeVisible(); + await expect(bars(canvasElement)).toHaveLength(1); + + await userEvent.click(within(termsCookies).getByRole('link', { name: 'Cookies' })); + + await waitFor(() => expect(termsCookies).toHaveAttribute('data-active', 'true')); + await expect(termsOverview).toHaveAttribute('data-active', 'false'); + await expect(privacyCookies).toHaveAttribute('data-active', 'false'); + await expect(privacyCookies).toHaveAttribute('data-spy-active'); + await waitFor(() => expect(bars(canvasElement)).toHaveLength(1)); + await expect(termsCookies.contains(bars(canvasElement)[0])).toBe(true); + }, +}; + +const legalIds = ['overview', 'introduction', 'cookies']; + +function LegalSpyPage() { + useScrollSpy(legalIds); + return } />; +} + +/** Away from its overview a subject click scrolls back there and keeps the subject open; at the overview it collapses. */ +export const LegalSubjectReclick: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const termsLink = (await canvas.findByText(/terms_of_use/)).closest('a') as HTMLElement; + const [termsCookies] = canvasElement.querySelectorAll('[data-spy-link="cookies"]'); + + await userEvent.click(within(termsCookies).getByRole('link', { name: 'Cookies' })); + await waitFor(() => expect(getSection()).toBe('cookies')); + + await userEvent.click(termsLink); + await expect(termsLink).toHaveAttribute('aria-expanded', 'true'); + await waitFor(() => expect(getSection()).toBe('overview')); + await waitFor(() => expect(termsCookies).toHaveAttribute('data-active', 'false')); + + await userEvent.click(termsLink); + await waitFor(() => expect(termsLink).toHaveAttribute('aria-expanded', 'false')); + }, +}; diff --git a/frontend/src/modules/common/stories/stepper.stories.tsx b/frontend/src/modules/common/stories/stepper.stories.tsx new file mode 100644 index 000000000..9560610d7 --- /dev/null +++ b/frontend/src/modules/common/stories/stepper.stories.tsx @@ -0,0 +1,209 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { XIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { Step, Stepper, useStepper } from '~/modules/common/stepper/stepper'; +import type { StepItem } from '~/modules/common/stepper/types'; +import { Button } from '~/modules/ui/button'; + +const steps: StepItem[] = [ + { id: 'profile', label: 'Profile', optional: true }, + { id: 'organization', label: 'Organization', optional: true }, + { id: 'invite', label: 'Invite' }, +]; + +/** Stands in for the onboarding footer: reads the stepper state and moves on from inside a step. */ +function StepFooter({ onComplete }: { onComplete: () => void }) { + const { nextStep, currentStep, isOptionalStep, hasCompletedAllSteps } = useStepper(); + + useEffect(() => { + if (hasCompletedAllSteps) onComplete(); + }, [hasCompletedAllSteps]); + + return ( +
    + + {currentStep?.id} {isOptionalStep ? 'optional' : 'required'} + + +
    + ); +} + +function OnboardingLikeStepper({ items, initialStep = 0 }: { items: StepItem[]; initialStep?: number }) { + const [completed, setCompleted] = useState(false); + + return ( +
    + setStep(newStep)} orientation="vertical"> + {items.map(({ id, label }) => ( + +
    +

    {label} content

    + setCompleted(true)} /> +
    +
    + ))} +
    + {completed &&

    All steps completed

    } +
    + ); +} + +/** Uses the hook without a surrounding stepper, as the profile and organization forms do outside onboarding. */ +function StepperlessForm() { + const { nextStep, currentStep, isOptionalStep } = useStepper(); + const [submitted, setSubmitted] = useState(false); + + return ( +
    +

    current step: {currentStep?.id ?? 'none'}

    +

    optional: {String(isOptionalStep)}

    + + {submitted &&

    Submitted

    } +
    + ); +} + +/** + * The vertical stepper that onboarding renders: each step shows a numbered button and a label, and only the current + * step's content is expanded. Completed steps show a check, or the step's own `checkIcon`. + */ +const meta = { + title: 'common/Stepper', + component: OnboardingLikeStepper, + tags: ['autodocs'], + parameters: { layout: 'centered' }, + args: { items: steps }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const checkButton = (root: HTMLElement, icon: 'check' | 'x') => root.querySelector(`svg.lucide-${icon}`)?.closest('button') ?? null; + +export const Default: Story = {}; + +export const ShouldAdvanceAndCollapse: Story = { + name: 'when a step calls nextStep, should collapse it, mark it completed and expand the next one', + tags: ['!dev', '!autodocs'], + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('only the first step is expanded and current', async () => { + await expect(canvas.getByText('Profile content')).toBeVisible(); + await expect(canvas.queryByText('Organization content')).not.toBeInTheDocument(); + await expect(canvas.getByText('profile optional')).toBeVisible(); + await expect(canvas.getByRole('button', { current: 'step' })).toHaveTextContent('1'); + for (const label of ['Profile', 'Organization', 'Invite']) await expect(canvas.getByText(label)).toBeVisible(); + for (const number of ['1', '2', '3']) await expect(canvas.getByRole('button', { name: number })).toBeVisible(); + }); + + await step('nextStep from inside the step moves on', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'Next' })); + await expect(await canvas.findByText('Organization content')).toBeVisible(); + await waitFor(() => expect(canvas.queryByText('Profile content')).not.toBeInTheDocument()); + await expect(canvas.getByRole('button', { current: 'step' })).toHaveTextContent('2'); + }); + + await step('the completed step shows a check in place of its number', async () => { + await expect(canvas.queryByRole('button', { name: '1' })).not.toBeInTheDocument(); + await expect(checkButton(canvasElement, 'check')).not.toBeNull(); + }); + + await step('a step with its own checkIcon shows that icon once completed', async () => { + await expect(checkButton(canvasElement, 'x')).toBeNull(); + await userEvent.click(canvas.getByRole('button', { name: 'Next' })); + await expect(await canvas.findByText('invite required')).toBeVisible(); + await waitFor(() => expect(canvas.queryByText('Organization content')).not.toBeInTheDocument()); + await expect(checkButton(canvasElement, 'x')).not.toBeNull(); + await expect(canvas.queryByRole('button', { name: '2' })).not.toBeInTheDocument(); + }); + + await step('nextStep on the last step completes the stepper', async () => { + await expect(canvas.queryByText('All steps completed')).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole('button', { name: 'Next' })); + await expect(await canvas.findByText('All steps completed')).toBeVisible(); + await waitFor(() => expect(canvas.queryByText('Invite content')).not.toBeInTheDocument()); + await expect(canvas.queryByRole('button', { current: 'step' })).not.toBeInTheDocument(); + }); + }, +}; + +export const ShouldJumpOnClick: Story = { + name: 'when a step button is clicked, should expand that step, ahead or back', + tags: ['!dev', '!autodocs'], + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step('jump ahead to the last step', async () => { + await userEvent.click(canvas.getByRole('button', { name: '3' })); + await expect(await canvas.findByText('Invite content')).toBeVisible(); + await waitFor(() => expect(canvas.queryByText('Profile content')).not.toBeInTheDocument()); + await expect(canvas.getByRole('button', { current: 'step' })).toHaveTextContent('3'); + }); + + await step('steps before the current one show as completed', async () => { + await expect(checkButton(canvasElement, 'check')).not.toBeNull(); + await expect(checkButton(canvasElement, 'x')).not.toBeNull(); + }); + + await step('jump back to a completed step', async () => { + const first = checkButton(canvasElement, 'check'); + if (!first) throw new Error('completed step button not found'); + await userEvent.click(first); + await expect(await canvas.findByText('Profile content')).toBeVisible(); + await waitFor(() => expect(canvas.queryByText('Invite content')).not.toBeInTheDocument()); + await expect(canvas.getByRole('button', { current: 'step' })).toHaveTextContent('1'); + await expect(canvas.getByRole('button', { name: '2' })).toBeVisible(); + }); + }, +}; + +export const ShouldStartAtInitialStep: Story = { + name: 'when given an initial step, should start there with earlier steps completed', + tags: ['!dev', '!autodocs'], + args: { initialStep: 1 }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('Organization content')).toBeVisible(); + await expect(canvas.queryByText('Profile content')).not.toBeInTheDocument(); + await expect(canvas.getByRole('button', { current: 'step' })).toHaveTextContent('2'); + await expect(checkButton(canvasElement, 'check')).not.toBeNull(); + }, +}; + +export const SingleStep: Story = { + name: 'when there is one step, should hide the step header and show its content', + tags: ['!dev', '!autodocs'], + args: { items: [steps[0]] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('Profile content')).toBeVisible(); + await expect(canvas.queryByRole('button', { name: '1' })).not.toBeInTheDocument(); + await expect(canvas.queryByText('Profile')).not.toBeInTheDocument(); + }, +}; + +export const ShouldWorkWithoutStepper: Story = { + name: 'when useStepper runs outside a stepper, should return inert defaults', + tags: ['!dev', '!autodocs'], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('current step: none')).toBeVisible(); + await expect(canvas.getByText('optional: false')).toBeVisible(); + await userEvent.click(canvas.getByRole('button', { name: 'Submit' })); + await expect(await canvas.findByText('Submitted')).toBeVisible(); + }, +}; diff --git a/frontend/src/modules/common/stories/success-checkmark.stories.tsx b/frontend/src/modules/common/stories/success-checkmark.stories.tsx index b2950689d..59d48a6b2 100644 --- a/frontend/src/modules/common/stories/success-checkmark.stories.tsx +++ b/frontend/src/modules/common/stories/success-checkmark.stories.tsx @@ -2,25 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useEffect, useState } from 'react'; import { SuccessCheckmark } from '~/modules/common/success-checkmark'; -const meta = { - title: 'common/SuccessCheckmark', - component: SuccessCheckmark, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/SuccessCheckmark', component: SuccessCheckmark, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof SuccessCheckmark +>; export default meta; type Story = StoryObj; export const Default: Story = {}; -export const Small: Story = { - args: { className: 'size-12' }, -}; +export const Small: Story = { args: { className: 'size-12' } }; -export const Large: Story = { - args: { className: 'size-40' }, -}; +export const Large: Story = { args: { className: 'size-40' } }; export const Loop: Story = { render: (args) => { diff --git a/frontend/src/modules/common/stories/text-effect.stories.tsx b/frontend/src/modules/common/stories/text-effect.stories.tsx index 4270e0a4e..9d847649b 100644 --- a/frontend/src/modules/common/stories/text-effect.stories.tsx +++ b/frontend/src/modules/common/stories/text-effect.stories.tsx @@ -1,27 +1,15 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { TextEffect } from '~/modules/common/text-effect'; -const meta = { - title: 'common/TextEffect', - component: TextEffect, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/TextEffect', component: TextEffect, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof TextEffect +>; export default meta; type Story = StoryObj; -export const Default: Story = { - args: { text: 'Hello, World!' }, -}; +export const Default: Story = { args: { text: 'Hello, World!' } }; -export const LongText: Story = { - args: { text: 'The quick brown fox jumps over the lazy dog.' }, -}; +export const LongText: Story = { args: { text: 'The quick brown fox jumps over the lazy dog.' } }; -export const Styled: Story = { - args: { - text: 'Animated text reveal', - className: 'text-2xl font-bold', - }, -}; +export const Styled: Story = { args: { text: 'Animated text reveal', className: 'text-2xl font-bold' } }; diff --git a/frontend/src/modules/common/stories/tooltip-button.stories.tsx b/frontend/src/modules/common/stories/tooltip-button.stories.tsx index a2b617d63..f95dd2a0c 100644 --- a/frontend/src/modules/common/stories/tooltip-button.stories.tsx +++ b/frontend/src/modules/common/stories/tooltip-button.stories.tsx @@ -3,12 +3,9 @@ import { SettingsIcon } from 'lucide-react'; import { TooltipButton } from '~/modules/common/tooltip-button'; import { Button } from '~/modules/ui/button'; -const meta = { - title: 'common/TooltipButton', - component: TooltipButton, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/TooltipButton', component: TooltipButton, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof TooltipButton +>; export default meta; type Story = StoryObj; diff --git a/frontend/src/modules/common/stories/unsaved-badge.stories.tsx b/frontend/src/modules/common/stories/unsaved-badge.stories.tsx index ac43a2537..58f510c6f 100644 --- a/frontend/src/modules/common/stories/unsaved-badge.stories.tsx +++ b/frontend/src/modules/common/stories/unsaved-badge.stories.tsx @@ -1,12 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { UnsavedBadge } from '~/modules/common/unsaved-badge'; -const meta = { - title: 'common/UnsavedBadge', - component: UnsavedBadge, - tags: ['autodocs'], - parameters: { layout: 'centered' }, -} satisfies Meta; +const meta = { title: 'common/UnsavedBadge', component: UnsavedBadge, tags: ['autodocs'], parameters: { layout: 'centered' } } satisfies Meta< + typeof UnsavedBadge +>; export default meta; type Story = StoryObj; @@ -33,7 +30,4 @@ export const WithReactNodeTitle: Story = { ], }; -export const Hidden: Story = { - name: 'Without unsaved-changes context', - args: { title: 'Settings' }, -}; +export const Hidden: Story = { name: 'Without unsaved-changes context', args: { title: 'Settings' } }; diff --git a/frontend/src/modules/common/success-checkmark.css b/frontend/src/modules/common/success-checkmark.css index d8a4a3327..7fa41de8d 100644 --- a/frontend/src/modules/common/success-checkmark.css +++ b/frontend/src/modules/common/success-checkmark.css @@ -16,6 +16,6 @@ @keyframes checkmark-fill { 100% { - box-shadow: inset 0px 0px 0px 30px #4bb71b; + box-shadow: inset 0px 0px 0px 30px var(--success); } } diff --git a/frontend/src/modules/common/success-checkmark.tsx b/frontend/src/modules/common/success-checkmark.tsx index 78344d77a..3dbae0f0c 100644 --- a/frontend/src/modules/common/success-checkmark.tsx +++ b/frontend/src/modules/common/success-checkmark.tsx @@ -9,14 +9,11 @@ interface SuccessCheckmarkProps { export function SuccessCheckmark({ className, size = 50 }: SuccessCheckmarkProps) { return (
    diff --git a/frontend/src/modules/common/tests/delete-items.test.tsx b/frontend/src/modules/common/tests/delete-items.test.tsx new file mode 100644 index 000000000..bd56cc012 --- /dev/null +++ b/frontend/src/modules/common/tests/delete-items.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +import { onlineManager } from '@tanstack/react-query'; +import { act, type ReactElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { Organization, Request } from 'sdk'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BaseUser } from '~/modules/user/types'; + +/** What happened, in order, plus the last delete form's props and every mutation's variables. */ +const seen = vi.hoisted(() => ({ + events: [] as string[], + mutations: [] as unknown[], + form: undefined as { onDelete: () => void; onCancel: () => void; pending: boolean } | undefined, +})); + +const deleteMutation = vi.hoisted(() => () => ({ + isPending: false, + mutate: (variables: unknown, options?: { onSuccess?: (data: unknown, variables: unknown) => void }) => { + seen.mutations.push(variables); + options?.onSuccess?.(undefined, variables); + }, +})); + +vi.mock('~/query/query-client', async () => { + const { QueryClient } = await import('@tanstack/react-query'); + return { queryClient: new QueryClient() }; +}); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('~/modules/common/delete-form', () => ({ + DeleteForm: (props: typeof seen.form) => { + seen.form = props; + return null; + }, +})); +vi.mock('~/modules/common/dialoger/use-dialoger', () => { + const state = { remove: () => seen.events.push('close') }; + const useDialoger = (select: (s: typeof state) => unknown) => select(state); + return { useDialoger: Object.assign(useDialoger, { getState: () => state }) }; +}); +vi.mock('~/modules/common/toaster/toaster', () => ({ toaster: { warning: (message: string) => seen.events.push(`warning ${message}`) } })); +vi.mock('~/modules/user/query', () => ({ useUserDeleteMutation: deleteMutation })); +vi.mock('~/modules/organization/query', () => ({ useOrganizationDeleteMutation: deleteMutation })); +vi.mock('~/modules/requests/query', () => ({ useDeleteRequestMutation: deleteMutation })); + +const { DeleteUsers } = await import('~/modules/user/delete-users'); +const { DeleteOrganizations } = await import('~/modules/organization/delete-organizations'); +const { DeleteRequests } = await import('~/modules/requests/delete-requests'); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; + +async function render(element: ReactElement) { + root = createRoot(document.createElement('div')); + await act(async () => root?.render(element)); + if (!seen.form) throw new Error('no delete form rendered'); + return seen.form; +} + +const callback = (args: { status: string; data?: unknown }) => { + seen.events.push(`callback ${args.status}`); + if ('data' in args) seen.mutations.push({ callbackData: args.data }); +}; + +afterEach(async () => { + await act(async () => root?.unmount()); + seen.events.length = 0; + seen.mutations.length = 0; + seen.form = undefined; + onlineManager.setOnline(true); +}); + +const users = [{ id: 'u1' }, { id: 'u2' }] as BaseUser[]; +const organizations = [{ id: 'o1' }] as Organization[]; +const requests = [{ id: 'r1' }] as Request[]; + +describe('DeleteUsers', () => { + it('deletes, reports success and then closes the dialog', async () => { + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.mutations).toEqual([users, { callbackData: users }]); + expect(seen.events).toEqual(['callback success', 'close']); + expect(form.pending).toBe(false); + }); + + it('outside a dialog reports success without closing', async () => { + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.events).toEqual(['callback success']); + }); + + it('warns and sends nothing while offline', async () => { + onlineManager.setOnline(false); + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.mutations).toEqual([]); + expect(seen.events).toEqual(['warning c:action.offline.text']); + }); + + it('cancel settles, then closes only in a dialog', async () => { + const inDialog = await render(); + await act(async () => inDialog.onCancel()); + expect(seen.events).toEqual(['callback settle', 'close']); + + seen.events.length = 0; + const inline = await render(); + await act(async () => inline.onCancel()); + expect(seen.events).toEqual(['callback settle']); + }); +}); + +describe('DeleteOrganizations', () => { + it('deletes by id within the tenant, closes the dialog and then reports success', async () => { + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.mutations).toEqual([{ path: { tenantId: 'tenant-1' }, body: { ids: ['o1'] }, organizations }, { callbackData: organizations }]); + expect(seen.events).toEqual(['close', 'callback success']); + }); + + it('sends while offline and reports success without closing outside a dialog', async () => { + onlineManager.setOnline(false); + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.mutations).toHaveLength(2); + expect(seen.events).toEqual(['callback success']); + }); + + it('cancel closes only in a dialog, then settles', async () => { + const inDialog = await render(); + await act(async () => inDialog.onCancel()); + expect(seen.events).toEqual(['close', 'callback settle']); + + seen.events.length = 0; + const inline = await render(); + await act(async () => inline.onCancel()); + expect(seen.events).toEqual(['callback settle']); + }); +}); + +describe('DeleteRequests', () => { + it('deletes, closes the dialog and then reports success', async () => { + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.mutations).toEqual([requests, { callbackData: requests }]); + expect(seen.events).toEqual(['close', 'callback success']); + }); + + it('outside a dialog reports success without closing', async () => { + const form = await render(); + + await act(async () => form.onDelete()); + + expect(seen.events).toEqual(['callback success']); + }); + + it('cancel always closes the dialog and reports nothing', async () => { + const inline = await render(); + await act(async () => inline.onCancel()); + + expect(seen.events).toEqual(['close']); + }); +}); diff --git a/frontend/src/modules/common/tests/overlay-exit.test.tsx b/frontend/src/modules/common/tests/overlay-exit.test.tsx new file mode 100644 index 000000000..402b68d99 --- /dev/null +++ b/frontend/src/modules/common/tests/overlay-exit.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Dialoger } from '~/modules/common/dialoger/provider'; +import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; +import { Sheeter } from '~/modules/common/sheeter/provider'; +import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; +import { useUIStore } from '~/modules/ui/ui-store'; + +vi.mock('~/routes/-router-instance', () => ({ getRouter: () => ({ subscribe: () => () => {} }) })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(async () => { + useDialoger.setState({ dialogs: [] }); + useSheeter.setState({ sheets: [] }); + useUIStore.setState({ uiLocks: [] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + <> + + + , + ), + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + Reflect.deleteProperty(Element.prototype, 'getAnimations'); +}); + +/** Presses Escape inside the open popup, the way Base UI's dismiss handling expects it. */ +async function pressEscape() { + const popup = document.querySelector('[role="dialog"]') as HTMLElement; + await act(async () => { + popup.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + // Base UI reports the close once exit animations finish (none run in jsdom). + await act(async () => new Promise((resolve) => setTimeout(resolve, 50))); +} + +/** Holds Base UI exits until the returned function runs: jsdom has no getAnimations, so an exit otherwise ends at once. */ +function holdExits() { + let finish = () => {}; + const finished = new Promise((resolve) => { + finish = resolve; + }); + let held = true; + Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, value: () => (held ? [{ finished }] : []) }); + + return async () => { + held = false; + finish(); + // Base UI checks animations on the next frame + await act(async () => new Promise((resolve) => setTimeout(resolve, 50))); + }; +} + +/** Opens a sheet with a focusable button inside, returning focus to `trigger` on close. */ +async function openSheetWithButton(trigger: HTMLElement) { + await act(async () => { + useSheeter + .getState() + .create(, { id: 'sheet', side: 'left', triggerRef: { current: trigger }, title: 'Sheet' }); + }); + (document.querySelector('[role="dialog"] button') as HTMLElement).focus(); +} + +describe('overlay exit', () => { + it('runs a dismissed sheet onClose once, then removes it after its exit', async () => { + const onClose = vi.fn(); + await act(async () => { + useSheeter.getState().create(

    Sheet body

    , { id: 'sheet', side: 'right', triggerRef: createRef(), title: 'Sheet', onClose }); + }); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + + await pressEscape(); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(useSheeter.getState().sheets).toEqual([]); + expect(useUIStore.getState().uiLocks).not.toContain('sheeter'); + }); + + it('keeps a sheet closed through the store, so it can be reopened in place', async () => { + const onClose = vi.fn(); + await act(async () => { + useSheeter.getState().create(

    Nav

    , { id: 'nav-sheet', side: 'left', triggerRef: createRef(), title: 'Menu', onClose }); + }); + + await act(async () => useSheeter.getState().update('nav-sheet', { open: false })); + await act(async () => new Promise((resolve) => setTimeout(resolve, 50))); + + expect(useSheeter.getState().sheets.map((s) => [s.id, s.open])).toEqual([['nav-sheet', false]]); + expect(onClose).not.toHaveBeenCalled(); + }); + + it.each([ + ['a store remove', () => useSheeter.getState().remove('sheet')], + ['a route change', () => useSheeter.getState().removeOnRouteChange({ isCleanup: true })], + ])('keeps a sheet closed by %s rendered until its exit ends, without holding the UI lock', async (_, close) => { + const onClose = vi.fn(); + await act(async () => { + useSheeter.getState().create(

    Sheet body

    , { id: 'sheet', side: 'left', triggerRef: createRef(), title: 'Sheet', onClose }); + }); + const sheet = document.querySelector('[role="dialog"]') as HTMLElement; + const endExit = holdExits(); + + await act(async () => close()); + + expect(useSheeter.getState().sheets).toEqual([]); + expect(onClose).toHaveBeenCalledTimes(1); + expect(useUIStore.getState().uiLocks).not.toContain('sheeter'); + expect(document.body.classList.contains('sheeter-open')).toBe(false); + expect(sheet.isConnected).toBe(true); + expect(sheet.hasAttribute('data-closed')).toBe(true); + + await endExit(); + expect(sheet.isConnected).toBe(false); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('reopens a sheet created again with the same id while it slides out', async () => { + const create = () => useSheeter.getState().create(

    Sheet body

    , { id: 'sheet', side: 'left', triggerRef: createRef(), title: 'Sheet' }); + await act(async () => create()); + const endExit = holdExits(); + + await act(async () => useSheeter.getState().remove('sheet')); + await act(async () => create()); + await endExit(); + + const sheets = document.querySelectorAll('[role="dialog"]'); + expect(sheets).toHaveLength(1); + expect(sheets[0].hasAttribute('data-open')).toBe(true); + }); + + it('leaves focus where it moved while the sheet slid out', async () => { + const trigger = document.createElement('button'); + const target = document.createElement('button'); + document.body.append(trigger, target); + await openSheetWithButton(trigger); + const endExit = holdExits(); + + await act(async () => useSheeter.getState().remove('sheet')); + target.focus(); + await endExit(); + + expect(document.activeElement).toBe(target); + trigger.remove(); + target.remove(); + }); + + it('returns focus to the trigger when a sheet is dismissed from inside', async () => { + const trigger = document.createElement('button'); + document.body.append(trigger); + await openSheetWithButton(trigger); + + await pressEscape(); + + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it('removes a dismissed dialog after its exit and then runs onClose once', async () => { + const onClose = vi.fn(); + await act(async () => { + useDialoger.getState().create(

    Dialog body

    , { id: 'dialog', triggerRef: createRef(), title: 'Dialog', onClose }); + }); + + await pressEscape(); + + expect(useDialoger.getState().dialogs).toEqual([]); + expect(onClose).toHaveBeenCalledTimes(1); + expect(useUIStore.getState().uiLocks).not.toContain('dialoger'); + }); +}); diff --git a/frontend/src/modules/common/tests/pull-to-refresh.test.tsx b/frontend/src/modules/common/tests/pull-to-refresh.test.tsx new file mode 100644 index 000000000..42875a40f --- /dev/null +++ b/frontend/src/modules/common/tests/pull-to-refresh.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PullToRefresh } from '~/modules/common/pull-to-refresh'; +import { useUIStore } from '~/modules/ui/ui-store'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(async () => { + useUIStore.setState({ uiLocks: [] }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + + + , + ), + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +/** Dispatches a touch event on the window; jsdom has no Touch constructor, so the touch list is a plain array. */ +async function touch(type: string, y?: number) { + const event = new Event(type, { bubbles: true }); + const touches = y === undefined ? [] : [{ clientY: y, screenY: y }]; + Object.defineProperty(event, 'targetTouches', { value: touches }); + await act(async () => document.body.dispatchEvent(event)); +} + +const isIndicatorShown = () => !!container.querySelector('title'); + +describe('pull to refresh', () => { + it('drops a pull the browser cancels', async () => { + await touch('touchstart', 10); + await touch('touchmove', 200); + expect(isIndicatorShown()).toBe(true); + expect(document.body.classList.contains('overflow-hidden')).toBe(true); + + await touch('touchcancel'); + + expect(isIndicatorShown()).toBe(false); + expect(document.body.classList.contains('overflow-hidden')).toBe(false); + }); + + it('drops a pull when an overlay opens mid-pull', async () => { + await touch('touchstart', 10); + await touch('touchmove', 200); + + await act(async () => useUIStore.getState().lockUI('sheeter')); + + expect(isIndicatorShown()).toBe(false); + expect(document.body.classList.contains('overflow-hidden')).toBe(false); + }); + + it('starts no pull while an overlay is open', async () => { + await act(async () => useUIStore.getState().lockUI('sheeter')); + + await touch('touchstart', 10); + await touch('touchmove', 200); + + expect(isIndicatorShown()).toBe(false); + }); +}); diff --git a/frontend/src/modules/common/text-effect.tsx b/frontend/src/modules/common/text-effect.tsx index a686fa1e7..f0aff1145 100644 --- a/frontend/src/modules/common/text-effect.tsx +++ b/frontend/src/modules/common/text-effect.tsx @@ -2,19 +2,12 @@ import { motion, useInView } from 'motion/react'; import { useRef } from 'react'; import { cn } from '~/utils/cn'; -type TextEffectProps = { - text: string; - className?: string; -}; +type TextEffectProps = { text: string; className?: string }; export function TextEffect({ text, className = '' }: TextEffectProps) { const variants = { hidden: { opacity: 0 }, - show: (i: number) => ({ - y: 0, - opacity: 1, - transition: { delay: i * 0.02 }, - }), + show: (i: number) => ({ y: 0, opacity: 1, transition: { delay: i * 0.02 } }), }; const letters = text.split(''); diff --git a/frontend/src/modules/common/themer.tsx b/frontend/src/modules/common/themer.tsx index a0087de83..7d8ab6061 100644 --- a/frontend/src/modules/common/themer.tsx +++ b/frontend/src/modules/common/themer.tsx @@ -24,14 +24,15 @@ function setBrandColor(passedTheme: Theme) { } export const Themer = () => { - useEffect(() => { - uiStore.subscribe(({ mode }) => { - setModeClass(mode); - }); - uiStore.subscribe(({ theme }) => { - setBrandColor(theme); - }); - }, []); + // The listener fires on every uiStore write (overlay locks, focus view), so it acts only on mode and theme changes. + useEffect( + () => + uiStore.subscribe((state, prev) => { + if (state.mode !== prev.mode) setModeClass(state.mode); + if (state.theme !== prev.theme) setBrandColor(state.theme); + }), + [], + ); setModeClass(uiStore.getState().mode); setBrandColor(uiStore.getState().theme); diff --git a/frontend/src/modules/common/toaster/toaster.ts b/frontend/src/modules/common/toaster/toaster.ts index e82a14989..71547feb3 100644 --- a/frontend/src/modules/common/toaster/toaster.ts +++ b/frontend/src/modules/common/toaster/toaster.ts @@ -49,10 +49,4 @@ function show(type?: ToastSeverity) { } /** Shows a toast and returns its id: `toaster(message)` plain, `toaster.(message)` with an icon. */ -export const toaster = Object.assign(show(), { - success: show('success'), - info: show('info'), - warning: show('warning'), - error: show('error'), - close, -}); +export const toaster = Object.assign(show(), { success: show('success'), info: show('info'), warning: show('warning'), error: show('error'), close }); diff --git a/frontend/src/modules/common/tooltip-button.tsx b/frontend/src/modules/common/tooltip-button.tsx index d9a4c1e6e..ffdccc1a8 100644 --- a/frontend/src/modules/common/tooltip-button.tsx +++ b/frontend/src/modules/common/tooltip-button.tsx @@ -14,17 +14,7 @@ interface TooltipButtonProps { } export const TooltipButton = React.forwardRef(function TooltipButton( - { - children, - toolTipContent, - disabled, - side = 'bottom', - sideOffset = 8, - className, - hideWhenDetached, - portal = true, - ...props - }, + { children, toolTipContent, disabled, side = 'bottom', sideOffset = 8, className, hideWhenDetached, portal = true, ...props }, _ref, ) { if (disabled) return children; diff --git a/frontend/src/modules/common/unsaved-badge.tsx b/frontend/src/modules/common/unsaved-badge.tsx index b83ed4bc3..c01eedf50 100644 --- a/frontend/src/modules/common/unsaved-badge.tsx +++ b/frontend/src/modules/common/unsaved-badge.tsx @@ -8,7 +8,7 @@ export function UnsavedBadge({ title }: { title?: string | React.ReactNode }) { <> {typeof title === 'string' ? {title} : title} - + {t('c:unsaved_changes')} diff --git a/frontend/src/modules/common/uploader/helpers/image-editor-options.ts b/frontend/src/modules/common/uploader/helpers/image-editor-options.ts index a97b8ba92..0deb0cc44 100644 --- a/frontend/src/modules/common/uploader/helpers/image-editor-options.ts +++ b/frontend/src/modules/common/uploader/helpers/image-editor-options.ts @@ -30,11 +30,7 @@ const baseActions = { }; export const getImageEditorOptions = (mode: UploadTemplateId | undefined): ImageEditorOptions => { - const options: ImageEditorOptions = { - quality: 0.9, - actions: baseActions, - cropperOptions: baseCropperOptions, - }; + const options: ImageEditorOptions = { quality: 0.9, actions: baseActions, cropperOptions: baseCropperOptions }; if (!options.cropperOptions) return options; diff --git a/frontend/src/modules/common/uploader/helpers/prepare-for-offline.ts b/frontend/src/modules/common/uploader/helpers/prepare-for-offline.ts index 5f24e8804..0c865756a 100644 --- a/frontend/src/modules/common/uploader/helpers/prepare-for-offline.ts +++ b/frontend/src/modules/common/uploader/helpers/prepare-for-offline.ts @@ -83,8 +83,6 @@ export const prepareFilesForOffline: PrepareFilesForOffline = async (files, toke bytes_received: localFiles.reduce((total, file) => total + (file.size || 0), 0), bytes_expected: localFiles.reduce((total, file) => total + (file.size || 0), 0), uploads: localFiles, - results: { - [templateKey]: localFiles, - }, + results: { [templateKey]: localFiles }, }; }; diff --git a/frontend/src/modules/common/uploader/helpers/restrictions-note.ts b/frontend/src/modules/common/uploader/helpers/restrictions-note.ts index 12c54a2a5..d7068e585 100644 --- a/frontend/src/modules/common/uploader/helpers/restrictions-note.ts +++ b/frontend/src/modules/common/uploader/helpers/restrictions-note.ts @@ -19,10 +19,7 @@ const formatCategories = (categories: string[]) => { }; export const generateRestrictionNote = (passedRestrictions?: Partial): string => { - const { allowedFileTypes, minNumberOfFiles, maxNumberOfFiles, maxFileSize } = { - ...appConfig.uppy.defaultRestrictions, - ...passedRestrictions, - }; + const { allowedFileTypes, minNumberOfFiles, maxNumberOfFiles, maxFileSize } = { ...appConfig.uppy.defaultRestrictions, ...passedRestrictions }; const categories = (() => { if (allowedFileTypes?.includes('*/*')) return ['Images', 'Videos', 'Audio', 'Files']; @@ -44,11 +41,7 @@ export const generateRestrictionNote = (passedRestrictions?: Partial => { +export const createBaseTransloaditUppy = async (uppyOptions: CustomUppyOpt, tokenQuery: UploadTokenQuery): Promise => { let cloudToken: UploadToken | null = null; let hasCloudUpload = false; @@ -88,10 +85,7 @@ export const createBaseTransloaditUppy = async ( uppy.use(Transloadit, { waitForEncoding: true, alwaysRunAssembly: true, - assemblyOptions: { - params: cloudToken.params, - signature: cloudToken.signature, - }, + assemblyOptions: { params: cloudToken.params, signature: cloudToken.signature }, }); uppy.on('transloadit:complete', async (assembly) => { diff --git a/frontend/src/modules/common/uploader/types.ts b/frontend/src/modules/common/uploader/types.ts index 7814455cb..47f4d9f4f 100644 --- a/frontend/src/modules/common/uploader/types.ts +++ b/frontend/src/modules/common/uploader/types.ts @@ -38,6 +38,4 @@ type UserMeta = Stringified & { type: string; }; -export type UploadedFile> = AssemblyResult & { - user_meta: T; -}; +export type UploadedFile> = AssemblyResult & { user_meta: T }; diff --git a/frontend/src/modules/common/uploader/uploader.tsx b/frontend/src/modules/common/uploader/uploader.tsx index 0239d38b1..8a9a24916 100644 --- a/frontend/src/modules/common/uploader/uploader.tsx +++ b/frontend/src/modules/common/uploader/uploader.tsx @@ -1,4 +1,5 @@ import Dashboard from '@uppy/react/dashboard'; +import { useTranslation } from 'react-i18next'; import { generateRestrictionNote } from '~/modules/common/uploader/helpers/restrictions-note'; import { useUploader } from '~/modules/common/uploader/use-uploader'; import { useUploadUppy } from '~/modules/common/uploader/use-uppy-upload'; @@ -8,6 +9,7 @@ import { useUIStore } from '~/modules/ui/ui-store'; import '~/modules/common/uploader/uppy-styles'; export function Uploader() { + const { t } = useTranslation(); const mode = useUIStore((state) => state.mode); const remove = useUploader((state) => state.remove); @@ -15,7 +17,7 @@ export function Uploader() { if (!uppy || !uploaderData) return null; - if (error) return
    {error}
    ; + if (error) return
    {error}
    ; return ( - - {uploaderData.title} - - - {uploaderData.description} - + {uploaderData.title || t('c:upload')} + {uploaderData.description && {uploaderData.description}} - - {/* Guarantee an accessible name without a visible title */} - {!uploaderData.title && } { try { @@ -73,9 +59,7 @@ export function useUploadUppy() { .on('transloadit:complete', (assembly) => { if (assembly?.error) throw new Error(assembly?.error); console.info('Upload complete:', assembly); - Promise.resolve( - statusEventHandler.onComplete?.(assembly.results as UploadedUppyFile), - ).catch((err) => { + Promise.resolve(statusEventHandler.onComplete?.(assembly.results as UploadedUppyFile)).catch((err) => { console.error('onComplete handler failed:', err); toaster.error(t('error:create_resource', { resource: t('c:attachment').toLowerCase() })); }); diff --git a/frontend/src/modules/docs/docs-landing-page.tsx b/frontend/src/modules/docs/docs-landing-page.tsx index 77f5bb473..d5bb4c9a9 100644 --- a/frontend/src/modules/docs/docs-landing-page.tsx +++ b/frontend/src/modules/docs/docs-landing-page.tsx @@ -5,6 +5,7 @@ import { type ComponentType, lazy, Suspense, useMemo } from 'react'; import { Spinner } from '~/modules/common/spinner'; import { type DocsTile, docsConfig, getDocPageLoader, getResolvedDocPageComponent } from '~/modules/page/content'; import { mdxComponents } from '~/modules/page/mdx-components'; +import { tw } from '~/utils/tw'; /** The /docs landing page, driven by the global docs config (content root index.mdx frontmatter). */ export function DocsLandingPage() { @@ -22,7 +23,7 @@ export function DocsLandingPage() {

    {docsConfig.title}

    {Content && ( - }> + }> @@ -45,7 +46,7 @@ export function DocsLandingPage() { /** Landing tile; internal targets go through the router. */ function DocsTileCard({ tile }: { tile: DocsTile }) { const isInternal = tile.to.startsWith('/'); - const cardClass = 'group flex items-center gap-3 rounded-lg border p-4 transition-colors hover:bg-accent/50'; + const cardClass = tw('group flex items-center gap-3 rounded-lg border p-4 transition-colors hover:bg-accent/50'); const TrailingIcon = isInternal ? ChevronRightIcon : ExternalLinkIcon; const inner = ( <> diff --git a/frontend/src/modules/docs/docs-layout.tsx b/frontend/src/modules/docs/docs-layout.tsx index 70693c1ce..cfb8e7ebd 100644 --- a/frontend/src/modules/docs/docs-layout.tsx +++ b/frontend/src/modules/docs/docs-layout.tsx @@ -1,19 +1,19 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { Outlet, useNavigate } from '@tanstack/react-router'; +import i18n from 'i18next'; import { ArrowUpIcon, MenuIcon } from 'lucide-react'; import type { CSSProperties } from 'react'; import { useEffect, useRef, useState } from 'react'; import { useBreakpointAbove } from '~/hooks/use-breakpoints'; import { useHotkeys } from '~/hooks/use-hot-keys'; -import { useScrollVisibility } from '~/hooks/use-scroll-visibility'; +import { useScrolledPast } from '~/hooks/use-scrolled-past'; import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; import { tagsQueryOptions } from '~/modules/docs/query'; import { toggleDocsSearch } from '~/modules/docs/search/open-docs-search'; import { DocsSidebar } from '~/modules/docs/sidebar/docs-sidebar'; import { FloatingNav, type FloatingNavItem } from '~/modules/navigation/floating-nav/floating-nav'; import { ScrollArea } from '~/modules/ui/scroll-area'; -import { useUIStore } from '~/modules/ui/ui-store'; -import { cn } from '~/utils/cn'; +import { tw } from '~/utils/tw'; const MIN_SIDEBAR_WIDTH = 220; const MAX_SIDEBAR_WIDTH = 400; @@ -21,29 +21,38 @@ const MAX_SIDEBAR_WIDTH = 400; function DocsLayout() { const navigate = useNavigate(); const isDesktop = useBreakpointAbove('md'); - const focusView = useUIStore((state) => state.focusView); const triggerRef = useRef(null); const sidebarRef = useRef(null); + const wrapperRef = useRef(null); // Resizable sidebar width (desktop only); main content uses window scroll offset by the same CSS variable const [resizedSidebarWidth, setResizedSidebarWidth] = useState(null); - const { scrollTop } = useScrollVisibility(!isDesktop); - const showScrollTop = scrollTop > 300; + const showScrollTop = useScrolledPast(300, !isDesktop); const startSidebarResize = (e: React.PointerEvent) => { e.preventDefault(); const startX = e.clientX; const startWidth = sidebarRef.current?.getBoundingClientRect().width ?? MIN_SIDEBAR_WIDTH; + // The drag writes the variable to the DOM once per frame and commits state on release, so it doesn't re-render the layout and sidebar + let width: number | null = null; + let frame = 0; + const writeWidth = () => { + frame = 0; + if (width !== null) wrapperRef.current?.style.setProperty('--docs-sidebar-width', `${width}px`); + }; const onMove = (ev: PointerEvent) => { - const next = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + (ev.clientX - startX))); - setResizedSidebarWidth(next); + width = Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, startWidth + (ev.clientX - startX))); + if (!frame) frame = requestAnimationFrame(writeWidth); }; const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.removeEventListener('pointercancel', onUp); document.body.style.cursor = ''; + cancelAnimationFrame(frame); + writeWidth(); + if (width !== null) setResizedSidebarWidth(width); }; document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); @@ -53,8 +62,7 @@ function DocsLayout() { const { data: tags } = useSuspenseQuery(tagsQueryOptions); - const sheets = useSheeter((state) => state.sheets); - const sidebarOpen = sheets.some((s) => s.id === 'docs-sidebar'); + const sidebarOpen = useSheeter((state) => state.sheets.some((s) => s.id === 'docs-sidebar')); const sidebarContent = ; @@ -75,12 +83,7 @@ function DocsLayout() { useSheeter.getState().remove('docs-sidebar'); return; } - navigate({ - to: '.', - search: (prev) => ({ ...prev, operationTag: undefined }), - resetScroll: false, - replace: true, - }); + navigate({ to: '.', search: (prev) => ({ ...prev, operationTag: undefined }), resetScroll: false, replace: true }); }, ], ]); @@ -93,7 +96,9 @@ function DocsLayout() { id: 'docs-sidebar', side: 'left', triggerRef, - className: 'w-72 p-0', + title: i18n.t('c:docs'), + headerClassName: 'hidden', + className: tw('w-72 p-0'), closeSheetOnRouteChange: false, }); } @@ -119,34 +124,27 @@ function DocsLayout() { return (
    -
    +
    ); } - const sidebarWidthStyle = - resizedSidebarWidth === null - ? undefined - : ({ - '--docs-sidebar-width': `${resizedSidebarWidth}px`, - } as CSSProperties); + const sidebarWidthStyle = resizedSidebarWidth === null ? undefined : ({ '--docs-sidebar-width': `${resizedSidebarWidth}px` } as CSSProperties); return ( -
    - {!focusView && ( - - )} -
    +
    + +
    diff --git a/frontend/src/modules/docs/helpers/extract-types.ts b/frontend/src/modules/docs/helpers/extract-types.ts index 1ac986aea..932bfb709 100644 --- a/frontend/src/modules/docs/helpers/extract-types.ts +++ b/frontend/src/modules/docs/helpers/extract-types.ts @@ -78,12 +78,7 @@ const toPascalCase = (str: string): string => { }; /** Error responses (status >= 400) resolve by responseName, success responses by operationId + 'Response'. */ -export const getZodCodeForResponse = ( - zodIndex: DefinitionIndex, - operationId: string, - status: number, - responseName?: string, -): string => { +export const getZodCodeForResponse = (zodIndex: DefinitionIndex, operationId: string, status: number, responseName?: string): string => { const isError = status >= 400; const schemaName = isError && responseName ? responseName : `${toPascalCase(operationId)}Response`; const name = `z${schemaName}`; @@ -112,9 +107,7 @@ export const getTypeCodeForResponse = (typesIndex: DefinitionIndex, operationId: /** Combines the available Path / Query / Body schemas: zod.gen.ts has no composite `Data` schema. */ export const getZodCodeForRequest = (zodIndex: DefinitionIndex, operationId: string): string => { const base = `z${toPascalCase(operationId)}`; - const parts = (['Path', 'Query', 'Body'] as const) - .map((part) => zodIndex.get(`${base}${part}`)) - .filter((def): def is string => Boolean(def)); + const parts = (['Path', 'Query', 'Body'] as const).map((part) => zodIndex.get(`${base}${part}`)).filter((def): def is string => Boolean(def)); if (parts.length === 0) { return `// No request schemas (${base}Path / ${base}Query / ${base}Body) found in zod.gen.ts`; diff --git a/frontend/src/modules/docs/json-actions.tsx b/frontend/src/modules/docs/json-actions.tsx index 5fc40da1e..647204ed3 100644 --- a/frontend/src/modules/docs/json-actions.tsx +++ b/frontend/src/modules/docs/json-actions.tsx @@ -21,15 +21,7 @@ interface JsonActionsProps { viewerUrl?: string; } -export function JsonActions({ - url, - data, - filename = 'data.json', - resourceName, - className, - smallMode, - viewerUrl, -}: JsonActionsProps) { +export function JsonActions({ url, data, filename = 'data.json', resourceName, className, smallMode, viewerUrl }: JsonActionsProps) { const { t } = useTranslation(); const isMobile = useBreakpointBelow('sm', false); diff --git a/frontend/src/modules/docs/openapi-spec-viewer.tsx b/frontend/src/modules/docs/openapi-spec-viewer.tsx index 23406fc04..a014041ad 100644 --- a/frontend/src/modules/docs/openapi-spec-viewer.tsx +++ b/frontend/src/modules/docs/openapi-spec-viewer.tsx @@ -105,11 +105,10 @@ export function OpenApiSpecViewer() { if (isLoading) return ; if (error) { + const resource = t('c:docs.openapi_specification').toLowerCase(); return (
    - - {t('error:load_resource', { resource: t('c:docs.openapi_specification').toLowerCase() })} - + {t('error:load_resource', { resource })}
    ); } @@ -132,12 +131,7 @@ export function OpenApiSpecViewer() { - handleSearchChange(e.target.value)} - /> + handleSearchChange(e.target.value)} /> {isSearching ? ( - {matchCount > 0 - ? currentMatchIndex >= 0 - ? `${currentMatchIndex + 1}/${matchCount}` - : `${matchCount}` - : t('c:no_results')} + {matchCount > 0 ? (currentMatchIndex >= 0 ? `${currentMatchIndex + 1}/${matchCount}` : `${matchCount}`) : t('c:no_results')} ) : null} @@ -201,13 +191,7 @@ export function OpenApiSpecViewer() { {/* Desktop-only actions; the mobile copy sits above the sticky bar */} - +
    diff --git a/frontend/src/modules/docs/operations/operation-detail.tsx b/frontend/src/modules/docs/operations/operation-detail.tsx index 42bbd4d32..b7e74612c 100644 --- a/frontend/src/modules/docs/operations/operation-detail.tsx +++ b/frontend/src/modules/docs/operations/operation-detail.tsx @@ -2,15 +2,17 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import i18n from 'i18next'; import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenOperationDetail, GenOperationSummary } from 'sdk/docs-types'; import { useScrollSpy } from '~/hooks/use-scroll-spy'; import { HashUrlButton } from '~/modules/common/hash-url-button'; import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; import { OperationRequest } from '~/modules/docs/operations/operation-request'; import { OperationResponses } from '~/modules/docs/operations/operation-responses'; -import type { GenOperationDetail, GenOperationSummary } from '~/modules/docs/types'; +import { SwitchedOffBadge } from '~/modules/docs/operations/switched-off-badge'; import { Badge } from '~/modules/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/modules/ui/card'; import { cn } from '~/utils/cn'; +import { tw } from '~/utils/tw'; import { Spinner } from '../../common/spinner'; import { getHashUrl } from '../hash-url'; import { getMethodColor } from '../helpers/get-method-color'; @@ -27,7 +29,7 @@ export function openOperationSheet(operation: GenOperationSummary, trigger: HTML id: `operation-${operation.id}`, triggerRef: { current: trigger }, side: 'right', - className: 'max-w-full lg:max-w-4xl', + className: tw('max-w-full lg:max-w-4xl'), title: i18n.t('c:docs.operation_detail'), }, ); @@ -58,27 +60,22 @@ export function OperationDetail({ operation, detail: detailProp, className }: Op {operation.summary} -
    - {operation.id} -
    +
    {operation.id}
    - {operation.description && ( - {operation.description} - )} + {operation.description && {operation.description}}
    - + {operation.method.toUpperCase()} - {operation.path} + {operation.path} {operation.deprecated && ( - + {t('c:deprecated')} )} +
    }> diff --git a/frontend/src/modules/docs/operations/operation-examples.tsx b/frontend/src/modules/docs/operations/operation-examples.tsx index 6da73f1b8..fe7cd3d57 100644 --- a/frontend/src/modules/docs/operations/operation-examples.tsx +++ b/frontend/src/modules/docs/operations/operation-examples.tsx @@ -2,92 +2,13 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import i18n from 'i18next'; import { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenOperationSummary } from 'sdk/docs-types'; import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; import { Spinner } from '~/modules/common/spinner'; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '~/modules/ui/accordion'; -import { - type DefinitionIndex, - getTypeCodeForResponse, - getZodCodeForResponse, - typesIndexQueryOptions, - zodIndexQueryOptions, -} from '../helpers/extract-types'; -import { getStatusColor } from '../helpers/get-status-color'; +import { tw } from '~/utils/tw'; +import { typesIndexQueryOptions, zodIndexQueryOptions } from '../helpers/extract-types'; import { schemasQueryOptions, tagDetailsQueryOptions } from '../query'; -import type { GenComponentSchema, GenOperationSummary, GenResponseSummary, GenSchema } from '../types'; -import { ViewerGroup } from '../viewer-group'; - -function resolveResponseSchema(response: GenResponseSummary, schemas: GenComponentSchema[]): GenSchema | undefined { - if (response.schema) return response.schema; - if (response.name) { - const schemaEntry = schemas.find((s) => s.name === response.name); - return schemaEntry?.schema; - } - return undefined; -} - -interface ExamplesAccordionProps { - responses: GenResponseSummary[]; - schemas: GenComponentSchema[]; - operationId: string; - zodIndex: DefinitionIndex; - typesIndex: DefinitionIndex; -} - -/** Lists only responses that carry an example, with the example view preselected. */ -function ExamplesAccordion({ responses, schemas, operationId, zodIndex, typesIndex }: ExamplesAccordionProps) { - const { t } = useTranslation(); - - const responsesWithExamples = responses.filter((r) => r.example !== undefined); - - if (responsesWithExamples.length === 0) { - return
    {t('c:docs.no_examples_defined')}
    ; - } - - const defaultValue = [String(responsesWithExamples[0].status)]; - - return ( - - {responsesWithExamples.map((response) => { - const schema = resolveResponseSchema(response, schemas); - return ( - - -
    -
    - {response.status} -
    -
    - {response.description} -
    - {response.name && ( - - {response.name} - - )} -
    -
    - - {schema ? ( - - ) : ( -
    {t('c:docs.no_response_body')}
    - )} -
    -
    - ); - })} -
    - ); -} +import { ResponsesAccordion } from './operation-responses'; interface OperationExamplesProps { operationId: string; @@ -105,7 +26,7 @@ export function openExamplesSheet(operation: GenOperationSummary, trigger: HTMLB id: `examples-${operation.id}`, triggerRef: { current: trigger }, side: 'right', - className: 'max-w-full lg:max-w-4xl', + className: tw('max-w-full lg:max-w-4xl'), title: i18n.t('c:docs.success_response'), }, ); @@ -123,17 +44,17 @@ export function OperationExamples({ operationId, tagName }: OperationExamplesPro const operation = operations.find((op) => op.operationId === operationId); const responses = operation?.responses ?? []; - const successResponsesWithExamples = responses.filter( - (r) => r.status >= 200 && r.status < 300 && r.example !== undefined, - ); + // The sheet is titled "Success response": error examples stay on the operation page. + const successResponsesWithExamples = responses.filter((r) => r.status >= 200 && r.status < 300 && r.example !== undefined); if (successResponsesWithExamples.length === 0) { return
    {t('c:docs.no_examples_defined')}
    ; } return ( - } /> - +
    r.example !== undefined) : allResponses; + if (responses.length === 0) { - return
    {t('c:docs.no_responses_defined')}
    ; + return ( +
    {examplesOnly ? t('c:docs.no_examples_defined') : t('c:docs.no_responses_defined')}
    + ); } return ( - + {responses.map((response) => { const schema = resolveResponseSchema(response, schemas); return ( - +
    {response.status}
    -
    {response.description}
    +
    + {response.description} +
    {response.name && ( - - {response.name} - + {response.name} )}
    @@ -69,6 +87,7 @@ function ResponsesAccordion({ responses, schemas, operationId, zodIndex, typesIn zodCode={getZodCodeForResponse(zodIndex, operationId, response.status, response.name)} typeCode={getTypeCodeForResponse(typesIndex, operationId, response.status)} example={response.example} + defaultViewMode={examplesOnly ? 'example' : undefined} /> ) : (
    {t('c:docs.no_response_body')}
    @@ -87,6 +106,7 @@ interface OperationResponsesProps { export function OperationResponses({ detail }: OperationResponsesProps) { const { t } = useTranslation(); + // Height keyframes run on the main thread and drop frames while the opening panel mounts the accordion const { data: schemas } = useSuspenseQuery(schemasQueryOptions); const { data: zodIndex } = useSuspenseQuery(zodIndexQueryOptions); @@ -105,7 +125,7 @@ export function OperationResponses({ detail }: OperationResponsesProps) { } /> - +
    - +
    - +
    diff --git a/frontend/src/modules/docs/operations/operations-table/operations-bar.tsx b/frontend/src/modules/docs/operations/operations-table/operations-bar.tsx index 4774e317e..c5f488c70 100644 --- a/frontend/src/modules/docs/operations/operations-table/operations-bar.tsx +++ b/frontend/src/modules/docs/operations/operations-table/operations-bar.tsx @@ -1,20 +1,15 @@ import type { Dispatch, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenOperationSummary } from 'sdk/docs-types'; import { ColumnsView } from '~/modules/common/data-table/columns-view'; import { TableBarContainer } from '~/modules/common/data-table/table-bar-container'; import { TableCount } from '~/modules/common/data-table/table-count'; -import { - FilterBarActions, - FilterBarFilters, - FilterBarSearch, - TableFilterBar, -} from '~/modules/common/data-table/table-filter-bar'; +import { FilterBarActions, FilterBarFilters, FilterBarSearch, TableFilterBar } from '~/modules/common/data-table/table-filter-bar'; import { TableSearch } from '~/modules/common/data-table/table-search'; import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; import { FocusView } from '~/modules/common/focus-view'; +import { ResponsiveSelect } from '~/modules/common/form-fields/responsive-select'; import { ViewModeToggle } from '~/modules/docs/operations/view-mode-toggle'; -import type { GenOperationSummary } from '~/modules/docs/types'; -import { ResponsiveSelect } from '~/modules/ui/responsive-select'; interface OperationsTableBarProps { total: number; @@ -36,14 +31,7 @@ const labelFor = (kind: string, value: string): string => { return cap; }; -export function OperationsTableBar({ - total, - searchVars, - setSearch, - columns, - setColumns, - tagFilters, -}: OperationsTableBarProps) { +export function OperationsTableBar({ total, searchVars, setSearch, columns, setColumns, tagFilters }: OperationsTableBarProps) { const { t } = useTranslation(); const { q, tag } = searchVars; @@ -66,15 +54,8 @@ export function OperationsTableBar({ ...Object.keys(tagFilters).filter((k) => !KIND_ORDER.includes(k) && tagFilters[k]?.length), ]; - const filterOptions = [ - { value: 'all', label: t('c:all') }, - ...orderedKinds.flatMap((kind) => - tagFilters[kind].map((value) => ({ - value: `${kind}:${value}`, - label: labelFor(kind, value), - })), - ), - ]; + const tagOptions = orderedKinds.flatMap((kind) => tagFilters[kind].map((value) => ({ value: `${kind}:${value}`, label: labelFor(kind, value) }))); + const filterOptions = [{ value: 'all', label: t('c:all') }, ...tagOptions]; return ( diff --git a/frontend/src/modules/docs/operations/operations-table/operations-columns.tsx b/frontend/src/modules/docs/operations/operations-table/operations-columns.tsx index 081627b50..9ef9d3534 100644 --- a/frontend/src/modules/docs/operations/operations-table/operations-columns.tsx +++ b/frontend/src/modules/docs/operations/operations-table/operations-columns.tsx @@ -1,13 +1,15 @@ import { BirdIcon } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenExtensionDefinition, GenOperationSummary } from 'sdk/docs-types'; import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; import { openOperationSheet } from '~/modules/docs/operations/operation-detail'; import { openExamplesSheet } from '~/modules/docs/operations/operation-examples'; -import type { GenExtensionDefinition, GenOperationSummary } from '~/modules/docs/types'; +import { SwitchedOffBadge } from '~/modules/docs/operations/switched-off-badge'; import { Badge } from '~/modules/ui/badge'; import { Button } from '~/modules/ui/button'; import { Input } from '~/modules/ui/input'; +import { cn } from '~/utils/cn'; import { getMethodColor } from '../../helpers/get-method-color'; export const useColumns = (extensions: GenExtensionDefinition[] = [], tagKinds: string[] = []) => { @@ -32,11 +34,7 @@ export const useColumns = (extensions: GenExtensionDefinition[] = [], tagKinds: {values.map((value: string) => { const meta = ext.values?.[value]; const label = meta?.name ?? value; - const tooltipContent = meta?.description - ? `${value}: ${meta.description}` - : label !== value - ? value - : undefined; + const tooltipContent = meta?.description ? `${value}: ${meta.description}` : label !== value ? value : undefined; return ( ( - + {row.method.toUpperCase()} ), @@ -110,6 +105,7 @@ export const useColumns = (extensions: GenExtensionDefinition[] = [], tagKinds: ‎{row.path} + ), }, @@ -120,8 +116,7 @@ export const useColumns = (extensions: GenExtensionDefinition[] = [], tagKinds: width: 50, renderCell: ({ row, tabIndex }) => { // No response body means examples are not applicable - if (!row.hasResponseBody) - return na; + if (!row.hasResponseBody) return na; // Has response body but no example yet if (!row.hasExample) return -; return ( @@ -132,7 +127,7 @@ export const useColumns = (extensions: GenExtensionDefinition[] = [], tagKinds: className="justify-center opacity-60 hover:opacity-100" onClick={(e) => openExamplesSheet(row, e.currentTarget)} > - + ); }, @@ -144,7 +139,7 @@ export const useColumns = (extensions: GenExtensionDefinition[] = [], tagKinds: minBreakpoint: 'md', resizable: true, width: 200, - renderCell: ({ row }) => {row.id}, + renderCell: ({ row }) => {row.id}, }, { key: 'summary', diff --git a/frontend/src/modules/docs/operations/operations-table/operations-table.tsx b/frontend/src/modules/docs/operations/operations-table/operations-table.tsx index 843ab0d76..c9b47d1ee 100644 --- a/frontend/src/modules/docs/operations/operations-table/operations-table.tsx +++ b/frontend/src/modules/docs/operations/operations-table/operations-table.tsx @@ -1,6 +1,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenOperationSummary } from 'sdk/docs-types'; import { useSearchParams } from '~/hooks/use-search-params'; import { DataTable } from '~/modules/common/data-table/data-table'; import { useSortColumns } from '~/modules/common/data-table/sort-columns'; @@ -10,19 +11,14 @@ import { OperationsTableBar } from '~/modules/docs/operations/operations-table/o import { useColumns } from '~/modules/docs/operations/operations-table/operations-columns'; import { useFilteredOperations } from '~/modules/docs/operations/operations-table/use-filtered-operations'; import { useSortedOperations } from '~/modules/docs/operations/operations-table/use-sorted-operations'; +import { isSwitchedOff } from '~/modules/docs/operations/switched-off-badge'; import { infoQueryOptions, operationsQueryOptions } from '~/modules/docs/query'; -import type { GenOperationSummary } from '~/modules/docs/types'; import { useUIStore } from '~/modules/ui/ui-store'; function OperationsTable() { const { t } = useTranslation(); const focusView = useUIStore((state) => state.focusView); - const { search, setSearch } = useSearchParams<{ - q?: string; - sort?: string; - order?: 'asc' | 'desc'; - tag?: string; - }>({ + const { search, setSearch } = useSearchParams<{ q?: string; sort?: string; order?: 'asc' | 'desc'; tag?: string }>({ from: '/_public/_content/docs/operations_/table', }); @@ -84,6 +80,7 @@ function OperationsTable() { cellSelectionMode="none" hasNextPage={false} rowKeyGetter={(row) => row.hash} + rowClass={(row) => (isSwitchedOff(row) ? 'opacity-60' : undefined)} isLoading={false} isFetching={false} limit={sortedOperations.length} diff --git a/frontend/src/modules/docs/operations/operations-table/use-filtered-operations.ts b/frontend/src/modules/docs/operations/operations-table/use-filtered-operations.ts index 6480e883d..0b3fc1640 100644 --- a/frontend/src/modules/docs/operations/operations-table/use-filtered-operations.ts +++ b/frontend/src/modules/docs/operations/operations-table/use-filtered-operations.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import type { GenOperationSummary } from '~/modules/docs/types'; +import type { GenOperationSummary } from 'sdk/docs-types'; interface FilterOptions { /** Free-text query; space-separated terms are AND-combined. */ diff --git a/frontend/src/modules/docs/operations/operations-table/use-sorted-operations.ts b/frontend/src/modules/docs/operations/operations-table/use-sorted-operations.ts index ead258aa8..08a3e208c 100644 --- a/frontend/src/modules/docs/operations/operations-table/use-sorted-operations.ts +++ b/frontend/src/modules/docs/operations/operations-table/use-sorted-operations.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; +import type { GenOperationSummary } from 'sdk/docs-types'; import type { SortColumn } from '~/modules/common/data-grid'; -import type { GenOperationSummary } from '~/modules/docs/types'; /** Comparable key from string values, `''` when missing or empty so the comparator can pin those last. */ const arrayKey = (values: string[] | undefined): string => { diff --git a/frontend/src/modules/docs/operations/switched-off-badge.tsx b/frontend/src/modules/docs/operations/switched-off-badge.tsx new file mode 100644 index 000000000..3773f6640 --- /dev/null +++ b/frontend/src/modules/docs/operations/switched-off-badge.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from 'react-i18next'; +import type { GenOperationSummary } from 'sdk/docs-types'; +import { type ConfigSwitch, isSwitchOn } from 'shared'; +import { Badge } from '~/modules/ui/badge'; + +/** Whether the operation's config switch is off in this app: the API refuses it, the docs still list it. */ +export const isSwitchedOff = ({ enabledBy }: GenOperationSummary) => !!enabledBy && !isSwitchOn(enabledBy); + +interface SwitchedOffBadgeProps { + enabledBy?: ConfigSwitch; + /** Spells out the reason beside the badge; without it, the reason is the badge's tooltip. */ + withReason?: boolean; +} + +/** Marks an operation whose config switch is off in this app, naming the service, sign-in method or provider. */ +export function SwitchedOffBadge({ enabledBy, withReason }: SwitchedOffBadgeProps) { + const { t } = useTranslation(); + if (!enabledBy || isSwitchOn(enabledBy)) return null; + + const reason = + 'service' in enabledBy + ? t('c:docs.switched_off_service', { name: enabledBy.service }) + : t('c:docs.switched_off_strategy', { name: enabledBy.provider ?? enabledBy.strategy }); + + return ( + <> + + {t('c:docs.switched_off')} + + {withReason && {reason}} + + ); +} diff --git a/frontend/src/modules/docs/operations/view-mode-toggle.tsx b/frontend/src/modules/docs/operations/view-mode-toggle.tsx index bc25c149b..6d6d009cd 100644 --- a/frontend/src/modules/docs/operations/view-mode-toggle.tsx +++ b/frontend/src/modules/docs/operations/view-mode-toggle.tsx @@ -7,8 +7,7 @@ interface ViewModeToggleProps { } export function ViewModeToggle({ size = 'default' }: ViewModeToggleProps) { - const { location } = useRouterState(); - const isTableRoute = location.pathname === '/docs/operations/table'; + const isTableRoute = useRouterState({ select: (state) => state.location.pathname === '/docs/operations/table' }); const viewMode = isTableRoute ? 'table' : 'list'; return ( diff --git a/frontend/src/modules/docs/query.ts b/frontend/src/modules/docs/query.ts index f773067f4..a851f930f 100644 --- a/frontend/src/modules/docs/query.ts +++ b/frontend/src/modules/docs/query.ts @@ -1,13 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; +import type { GenComponentSchema, GenInfoSummary, GenOperationDetail, GenOperationSummary, GenSchemaTagSummary, GenTagSummary } from 'sdk/docs-types'; import { appConfig } from 'shared'; -import type { - GenComponentSchema, - GenInfoSummary, - GenOperationDetail, - GenOperationSummary, - GenSchemaTagSummary, - GenTagSummary, -} from '~/modules/docs/types'; /** Append the build SHA to a /static URL, so browser and service worker caches roll over per release. */ export const versionedUrl = (url: string) => `${url}?v=${__APP_VERSION__}`; @@ -104,9 +97,6 @@ export const schemaTagsQueryOptions = queryOptions({ export const tagDetailsQueryOptions = (tagName: string) => queryOptions({ queryKey: docsKeys.tagDetails(tagName), - queryFn: () => - tagName - ? fetchJson(`${docsBaseUrl}/details.gen/${tagName}.gen.json`) - : ([] as GenOperationDetail[]), + queryFn: () => (tagName ? fetchJson(`${docsBaseUrl}/details.gen/${tagName}.gen.json`) : ([] as GenOperationDetail[])), staleTime: Number.POSITIVE_INFINITY, }); diff --git a/frontend/src/modules/docs/schemas/schema-detail.tsx b/frontend/src/modules/docs/schemas/schema-detail.tsx index e0f462e78..5543d7f95 100644 --- a/frontend/src/modules/docs/schemas/schema-detail.tsx +++ b/frontend/src/modules/docs/schemas/schema-detail.tsx @@ -1,17 +1,12 @@ import { useSuspenseQuery } from '@tanstack/react-query'; +import type { GenComponentSchema } from 'sdk/docs-types'; import { useScrollSpy } from '~/hooks/use-scroll-spy'; import { HashUrlButton } from '~/modules/common/hash-url-button'; -import type { GenComponentSchema } from '~/modules/docs/types'; import { ViewerGroup } from '~/modules/docs/viewer-group'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/modules/ui/card'; import { cn } from '~/utils/cn'; import { getHashUrl } from '../hash-url'; -import { - getTypeCodeForSchema, - getZodCodeForSchema, - typesIndexQueryOptions, - zodIndexQueryOptions, -} from '../helpers/extract-types'; +import { getTypeCodeForSchema, getZodCodeForSchema, typesIndexQueryOptions, zodIndexQueryOptions } from '../helpers/extract-types'; interface SchemaDetailProps { schema: GenComponentSchema; @@ -31,9 +26,7 @@ function SchemaDetail({ schema, className }: SchemaDetailProps) { {schema.name} - {schema.description && ( - {schema.description} - )} + {schema.description && {schema.description}}
    diff --git a/frontend/src/modules/docs/schemas/schemas-page.tsx b/frontend/src/modules/docs/schemas/schemas-page.tsx index 1b9efb267..9bf12c748 100644 --- a/frontend/src/modules/docs/schemas/schemas-page.tsx +++ b/frontend/src/modules/docs/schemas/schemas-page.tsx @@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { useSearch } from '@tanstack/react-router'; import { Suspense, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenComponentSchema, GenSchemaTagSummary } from 'sdk/docs-types'; import { usePrerenderSection, usePrerenderTrigger } from '~/hooks/use-prerender'; import { useScrollSpy } from '~/hooks/use-scroll-spy'; import { scrollToSectionById } from '~/hooks/use-scroll-spy-store'; @@ -11,7 +12,6 @@ import { schemasByTagQueryOptions, schemasQueryOptions, schemaTagsQueryOptions } import { TagSchemasList } from '~/modules/docs/schemas/schema-detail'; import { TagSchemasTable } from '~/modules/docs/schemas/tag-schemas-table'; import { TagExpandLink } from '~/modules/docs/tag-expand-link'; -import type { GenComponentSchema, GenSchemaTagSummary } from '~/modules/docs/types'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/modules/ui/card'; import { Collapsible, CollapsibleContent } from '~/modules/ui/collapsible'; import { cn } from '~/utils/cn'; diff --git a/frontend/src/modules/docs/schemas/tag-schemas-table.tsx b/frontend/src/modules/docs/schemas/tag-schemas-table.tsx index 14bdb2dbb..db5ad75cf 100644 --- a/frontend/src/modules/docs/schemas/tag-schemas-table.tsx +++ b/frontend/src/modules/docs/schemas/tag-schemas-table.tsx @@ -1,8 +1,6 @@ -import { Link, useNavigate } from '@tanstack/react-router'; -import { scrollToSectionById } from '~/hooks/use-scroll-spy-store'; -import { DataTable } from '~/modules/common/data-table/data-table'; +import type { GenComponentSchema } from 'sdk/docs-types'; import type { ColumnOrColumnGroup } from '~/modules/common/data-table/types'; -import type { GenComponentSchema } from '~/modules/docs/types'; +import { TagHashLink, TagTable } from '~/modules/docs/tag-table'; import { Badge } from '~/modules/ui/badge'; interface TagSchemasTableProps { @@ -16,19 +14,6 @@ interface TagSchemasTableProps { } function useColumns(tagName: string, tagKinds: string[]): ColumnOrColumnGroup[] { - const navigate = useNavigate(); - - const handleSchemaClick = (hash: string) => { - scrollToSectionById(hash); - navigate({ - to: '.', - search: (prev) => ({ ...prev, schemaTag: tagName }), - hash, - replace: true, - resetScroll: false, - }); - }; - const tagKindColumns: ColumnOrColumnGroup[] = tagKinds.map((kind) => ({ key: `tag-${kind}`, name: kind.replace(/^\w/, (c) => c.toUpperCase()), @@ -55,28 +40,11 @@ function useColumns(tagName: string, tagKinds: string[]): ColumnOrColumnGroup { - const schemaId = row.ref.replace(/^#/, ''); - return ( - ({ ...prev, schemaTag: tagName })} - hash={schemaId} - replace - onClick={(e) => { - if (e.metaKey || e.ctrlKey) return; - e.preventDefault(); - handleSchemaClick(schemaId); - }} - resetScroll={false} - draggable={false} - tabIndex={tabIndex} - className="truncate font-mono text-sm decoration-foreground/30 underline-offset-3 hover:underline" - > - {row.name} - - ); - }, + renderCell: ({ row, tabIndex }) => ( + + {row.name} + + ), }, ...tagKindColumns, ]; @@ -85,22 +53,5 @@ function useColumns(tagName: string, tagKinds: string[]): ColumnOrColumnGroup - - className="mb-0" - columns={columns} - rows={schemas} - hasNextPage={false} - rowKeyGetter={(row) => row.name} - isLoading={false} - isFetching={false} - limit={schemas.length} - isFiltered={false} - rowHeight={36} - enableVirtualization={false} - readOnly - /> -
    - ); + return rows={schemas} columns={columns} rowKeyGetter={(row) => row.name} onPrerender={onPrerender} />; } diff --git a/frontend/src/modules/docs/search-params-schemas.ts b/frontend/src/modules/docs/search-params-schemas.ts index 16c81af04..37f4ebd18 100644 --- a/frontend/src/modules/docs/search-params-schemas.ts +++ b/frontend/src/modules/docs/search-params-schemas.ts @@ -1,12 +1,7 @@ import z from 'zod'; /** operationTag is a string because tag names are fetched at runtime. */ -export const operationsRouteSearchParamsSchema = z.object({ - operationTag: z.string().optional(), - q: z.string().optional(), -}); +export const operationsRouteSearchParamsSchema = z.object({ operationTag: z.string().optional(), q: z.string().optional() }); /** schemaTag is a string because bucket names are backend-configured (tags with `kind: 'schema'`). */ -export const schemasRouteSearchParamsSchema = z.object({ - schemaTag: z.string().optional(), -}); +export const schemasRouteSearchParamsSchema = z.object({ schemaTag: z.string().optional() }); diff --git a/frontend/src/modules/docs/search/docs-search-row.tsx b/frontend/src/modules/docs/search/docs-search-row.tsx index 4f3387fbe..716ee95b7 100644 --- a/frontend/src/modules/docs/search/docs-search-row.tsx +++ b/frontend/src/modules/docs/search/docs-search-row.tsx @@ -3,6 +3,7 @@ import { Fragment } from 'react'; import { getMethodColor } from '~/modules/docs/helpers/get-method-color'; import type { DocsSearchResult } from '~/modules/docs/search/types'; import { Badge } from '~/modules/ui/badge'; +import { cn } from '~/utils/cn'; /** Render engine-highlighted text: `` ranges become styled spans (no raw HTML). */ function MarkedText({ text }: { text: string }) { @@ -46,7 +47,7 @@ export function DocsSearchRow({ item }: { item: DocsSearchResult }) {
    @@ -60,14 +61,11 @@ export function DocsSearchRow({ item }: { item: DocsSearchResult }) {
    {item.type === 'schema' && } {item.method && ( - + {item.method} )} - +
    diff --git a/frontend/src/modules/docs/search/docs-search-store.ts b/frontend/src/modules/docs/search/docs-search-store.ts index 221ad7a85..8bf117abf 100644 --- a/frontend/src/modules/docs/search/docs-search-store.ts +++ b/frontend/src/modules/docs/search/docs-search-store.ts @@ -11,10 +11,7 @@ interface DocsSearchStoreState { * and idb no-ops while signed out, which would drop history for anonymous visitors. */ export const useDocsSearchStore = create()( - persist(() => ({ recentSearches: [] as string[] }), { - name: 'docs-search', - storage: createJSONStorage(() => localStorage), - }), + persist(() => ({ recentSearches: [] as string[] }), { name: 'docs-search', storage: createJSONStorage(() => localStorage) }), ); export const deleteRecentSearch = (value: string) => { diff --git a/frontend/src/modules/docs/search/docs-search.test.tsx b/frontend/src/modules/docs/search/docs-search.test.tsx new file mode 100644 index 000000000..b5d3dc886 --- /dev/null +++ b/frontend/src/modules/docs/search/docs-search.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The docs content index is a build-time virtual module; the search engine is not under test here. +vi.mock('~/modules/page/content', () => ({ docsConfig: { sections: [] } })); +vi.mock('~/modules/docs/search/client', () => ({ + getDocsSearchClient: async () => ({ search: async () => [] }), +})); +vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn() })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +const { DocsSearch } = await import('~/modules/docs/search/docs-search'); +const { useDocsSearchStore } = await import('~/modules/docs/search/docs-search-store'); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +// jsdom lacks the Web Animations API the scroll area waits on +Element.prototype.getAnimations ??= () => []; + +const history = ['passkeys', 'sessions', 'tenants']; + +let root: Root; +let container: HTMLDivElement; + +const input = () => container.querySelector('input') as HTMLInputElement; +const historyRows = () => [...container.querySelectorAll('[role="option"]')].map((row) => row.textContent); +const historyRow = (value: string) => + [...container.querySelectorAll('[role="option"]')].find((row) => row.textContent?.startsWith(value)); + +/** Sets the input value the way a keystroke or paste does, so React sees a change event. */ +async function enter(value: string) { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + await act(async () => { + setValue?.call(input(), value); + input().dispatchEvent(new Event('input', { bubbles: true })); + }); +} + +beforeEach(async () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + useDocsSearchStore.setState({ recentSearches: [...history] }); + + container = document.body.appendChild(document.createElement('div')); + root = createRoot(container); + await act(async () => + root.render( + + + , + ), + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +describe('docs search history', () => { + it('lists recent searches with their index', () => { + expect(historyRows()).toEqual(['passkeys0', 'sessions1', 'tenants2']); + }); + + it('picks the entry for a bare index typed into the empty input', async () => { + await enter('1'); + + expect(input().value).toBe('sessions'); + }); + + it('keeps a value that starts with a digit', async () => { + await enter('2fa'); + + expect(input().value).toBe('2fa'); + }); + + it('keeps a digit typed after other text', async () => { + await enter('a'); + await enter('a1'); + + expect(input().value).toBe('a1'); + }); + + it('drops one entry through its remove button', async () => { + await act(async () => historyRow('passkeys')?.querySelector('button')?.click()); + + expect(useDocsSearchStore.getState().recentSearches).toEqual(['sessions', 'tenants']); + expect(historyRows()).toEqual(['sessions0', 'tenants1']); + }); + + it('runs the query of a picked entry', async () => { + await act(async () => historyRow('tenants')?.click()); + + expect(input().value).toBe('tenants'); + }); +}); diff --git a/frontend/src/modules/docs/search/docs-search.tsx b/frontend/src/modules/docs/search/docs-search.tsx index 95cc91837..7cc9cf375 100644 --- a/frontend/src/modules/docs/search/docs-search.tsx +++ b/frontend/src/modules/docs/search/docs-search.tsx @@ -1,32 +1,26 @@ import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { HistoryIcon, SearchIcon, XIcon } from 'lucide-react'; +import { SearchIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDebounce } from '~/hooks/use-debounce'; import { useFocusByRef } from '~/hooks/use-focus-by-ref'; import { scrollToSectionById } from '~/hooks/use-scroll-spy-store'; +import { ComboboxSearchInput } from '~/modules/common/combobox-search-input'; import { ContentPlaceholder } from '~/modules/common/content-placeholder'; import { useDialoger } from '~/modules/common/dialoger/use-dialoger'; +import { type HistoryEntry, SearchHistoryGroup } from '~/modules/common/search-history-group'; import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; import { getDocsSearchClient } from '~/modules/docs/search/client'; import { DocsSearchRow } from '~/modules/docs/search/docs-search-row'; import { deleteRecentSearch, updateRecentSearches, useDocsSearchStore } from '~/modules/docs/search/docs-search-store'; import type { DocsSearchResult, DocsSearchScope } from '~/modules/docs/search/types'; import { docsConfig } from '~/modules/page/content'; -import { Button } from '~/modules/ui/button'; -import { - Combobox, - ComboboxGroup, - ComboboxGroupLabel, - ComboboxItem, - ComboboxList, - ComboboxSearchInput, -} from '~/modules/ui/combobox'; +import { Combobox, ComboboxItem, ComboboxList } from '~/modules/ui/combobox'; import { ScrollArea } from '~/modules/ui/scroll-area'; import { cn } from '~/utils/cn'; +import { resolveSearchInput } from '~/utils/recent-searches'; -type HistoryEntry = { kind: 'history'; value: string }; type SearchSelection = DocsSearchResult | HistoryEntry; /** Scope chips, labeled by the config-driven sidebar section labels. */ @@ -102,9 +96,7 @@ export function DocsSearch() { // Scoped placeholder mirrors the active chip ("Search API reference..."). const activeChipLabel = scopeChips.find((chip) => chip.value === scope)?.label; - const placeholder = activeChipLabel - ? t('c:placeholder.search_resource', { resource: activeChipLabel }) - : t('c:docs.search.placeholder'); + const placeholder = activeChipLabel ? t('c:placeholder.search_resource', { resource: activeChipLabel }) : t('c:docs.search.placeholder'); return ( @@ -115,15 +107,7 @@ export function DocsSearch() { if (selection) onSelect(selection); }} inputValue={searchValue} - onInputValueChange={(value) => { - // Typing a bare history index (shown next to the row) re-runs that search. - const historyIndexes = recentSearches.map((_, index) => index); - if (historyIndexes.includes(Number.parseInt(value, 10))) { - setSearchValue(recentSearches[+value]); - return; - } - setSearchValue(value); - }} + onInputValueChange={(value) => setSearchValue(resolveSearchInput(searchValue, value, recentSearches))} filter={() => true} >
    @@ -139,37 +123,7 @@ export function DocsSearch() { {/* Height and scrolling live on the ScrollArea viewport; the list's own max-h/overflow are neutralized */} - {results === null && recentSearches.length > 0 && ( - - {t('c:history')} - {recentSearches.map((search, index) => ( - -
    - - {search} -
    -
    - {index} - -
    -
    - ))} -
    - )} + {results === null && recentSearches.length > 0 && } {results === null && recentSearches.length === 0 && ( )} diff --git a/frontend/src/modules/docs/search/engine.test.ts b/frontend/src/modules/docs/search/engine.test.ts index e7b747fb7..d1e6e7897 100644 --- a/frontend/src/modules/docs/search/engine.test.ts +++ b/frontend/src/modules/docs/search/engine.test.ts @@ -1,5 +1,5 @@ +import type { GenComponentSchema, GenOperationSummary } from 'sdk/docs-types'; import { describe, expect, it } from 'vitest'; -import type { GenComponentSchema, GenOperationSummary } from '~/modules/docs/types'; import { createEngine, type EnginePage } from './engine'; /** @@ -67,12 +67,7 @@ describe('docs search engine', () => { it('finds a page by title and puts the page row first', async () => { const rows = await engine.search('architecture'); - expect(rows[0]).toMatchObject({ - type: 'page', - pageId: 'architecture', - to: '/docs/page/$', - params: { _splat: 'architecture' }, - }); + expect(rows[0]).toMatchObject({ type: 'page', pageId: 'architecture', to: '/docs/page/$', params: { _splat: 'architecture' } }); expect(rows[0].title).toContain(''); }); diff --git a/frontend/src/modules/docs/search/engine.ts b/frontend/src/modules/docs/search/engine.ts index 4b3e63e0f..72b0156e2 100644 --- a/frontend/src/modules/docs/search/engine.ts +++ b/frontend/src/modules/docs/search/engine.ts @@ -1,7 +1,7 @@ import { create, insertMultiple, search } from '@orama/orama'; +import type { GenComponentSchema, GenOperationSummary } from 'sdk/docs-types'; import { markMatches, trimAroundMatch } from '~/modules/docs/search/highlight'; import type { DocsSearchResult, DocsSearchResultType, DocsSearchScope } from '~/modules/docs/search/types'; -import type { GenComponentSchema, GenOperationSummary } from '~/modules/docs/types'; /** Everything the engine indexes for one docs page. */ export type EnginePage = { @@ -152,8 +152,7 @@ export function createEngine( id: doc.id, pageId: doc.pageId, type: doc.kind, - title: - doc.kind === 'text' ? markMatches(trimAroundMatch(doc.content, terms), terms) : markMatches(doc.title, terms), + title: doc.kind === 'text' ? markMatches(trimAroundMatch(doc.content, terms), terms) : markMatches(doc.title, terms), breadcrumbs: doc.breadcrumbs, to: doc.to, params: doc.params, diff --git a/frontend/src/modules/docs/search/open-docs-search.tsx b/frontend/src/modules/docs/search/open-docs-search.tsx index af70b27c2..177ec0950 100644 --- a/frontend/src/modules/docs/search/open-docs-search.tsx +++ b/frontend/src/modules/docs/search/open-docs-search.tsx @@ -1,5 +1,7 @@ +import i18n from 'i18next'; import { type TriggerRef, useDialoger } from '~/modules/common/dialoger/use-dialoger'; import { DocsSearch } from '~/modules/docs/search/docs-search'; +import { tw } from '~/utils/tw'; /** Fallback focus target when opened via hotkey (no triggering button). */ const hotkeyTriggerRef: TriggerRef = { current: null }; @@ -8,7 +10,8 @@ export function openDocsSearch(triggerRef: TriggerRef = hotkeyTriggerRef) { return useDialoger.getState().create(, { id: 'docs-search', triggerRef, - className: 'sm:max-w-3xl p-0 border-0 mb-4', + title: i18n.t('c:search'), + className: tw('mb-4 border-0 p-0 sm:max-w-3xl'), headerClassName: 'hidden', drawerOnMobile: false, }); diff --git a/frontend/src/modules/docs/sidebar/active-indicator.tsx b/frontend/src/modules/docs/sidebar/active-indicator.tsx index 03d79f8dd..e76895f9d 100644 --- a/frontend/src/modules/docs/sidebar/active-indicator.tsx +++ b/frontend/src/modules/docs/sidebar/active-indicator.tsx @@ -1,4 +1,4 @@ -import { motion } from 'motion/react'; +import { IndicatorBar } from '~/modules/common/spy-nav-item'; // Use rem values for proper mobile scaling const ITEM_HEIGHT_REM = 2; // 32px at base 16px @@ -11,32 +11,20 @@ type ActiveIndicatorProps = { activeIndex: number; /** Unique id for the motion layout animation between sibling lists. */ layoutId: string; - /** When true, falls back to a CSS transition (no motion shared layout). */ + /** When true, falls back to a CSS transform transition (no motion shared layout). */ isMobile: boolean; }; /** Assumes a `relative` parent, items stacked with no gap, and each item `h-8`. */ export function ActiveIndicator({ activeIndex, layoutId, isMobile }: ActiveIndicatorProps) { if (activeIndex < 0) return null; - const style = { - top: `${LIST_PADDING_TOP_REM + activeIndex * ITEM_HEIGHT_REM + INDICATOR_OFFSET_REM}rem`, - height: `${INDICATOR_HEIGHT_REM}rem`, - }; + const top = LIST_PADDING_TOP_REM + INDICATOR_OFFSET_REM; + const rowOffset = activeIndex * ITEM_HEIGHT_REM; + const height = `${INDICATOR_HEIGHT_REM}rem`; + // Mobile moves the bar by transform: that transition stays on the compositor, where a `top` transition relayouts every frame + const style = isMobile ? { top: `${top}rem`, height, transform: `translateY(${rowOffset}rem)` } : { top: `${top + rowOffset}rem`, height }; - if (isMobile) { - return ( - - ); - } return ( - + ); } diff --git a/frontend/src/modules/docs/sidebar/api-reference-section.tsx b/frontend/src/modules/docs/sidebar/api-reference-section.tsx index b24993350..7cefa5e68 100644 --- a/frontend/src/modules/docs/sidebar/api-reference-section.tsx +++ b/frontend/src/modules/docs/sidebar/api-reference-section.tsx @@ -3,49 +3,48 @@ import { Link, useRouterState } from '@tanstack/react-router'; import { ChevronDownIcon } from 'lucide-react'; import { Suspense, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenTagSummary } from 'sdk/docs-types'; import { operationsQueryOptions, schemasQueryOptions, tagsQueryOptions } from '~/modules/docs/query'; import { OperationsSidebar } from '~/modules/docs/sidebar/operations-sidebar'; import { SchemasSidebar } from '~/modules/docs/sidebar/schemas-sidebar'; -import type { GenTagSummary } from '~/modules/docs/types'; import { buttonVariants } from '~/modules/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/modules/ui/collapsible'; import { SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenuItem } from '~/modules/ui/sidebar'; import { queryClient } from '~/query/query-client'; import { cn } from '~/utils/cn'; +/** Search params of the operations and schemas routes; the router types location.search as the union of all routes. */ +type DocsSearch = { operationTag?: string; schemaTag?: string; q?: string }; + interface ApiReferenceSectionProps { label: string; tags: GenTagSummary[]; - isMobile: boolean; } /** Expansion is derived from the route, mutually exclusive, with a per-section forced-collapse override. */ -export function ApiReferenceSection({ label, tags, isMobile }: ApiReferenceSectionProps) { +export function ApiReferenceSection({ label, tags }: ApiReferenceSectionProps) { const { t } = useTranslation(); const { data: schemas } = useQuery(schemasQueryOptions); - const { location } = useRouterState(); - const isOperationsRoute = location.pathname === '/docs/operations'; - const isOperationsTableRoute = location.pathname === '/docs/operations/table'; - const isSchemasRoute = location.pathname.includes('/docs/schemas'); + // Narrow selects: the whole router state changes several times per navigation + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const activeOperationTag = useRouterState({ select: (state) => (state.location.search as DocsSearch).operationTag }); + const activeSchemaTag = useRouterState({ select: (state) => (state.location.search as DocsSearch).schemaTag }); + const hasQuery = useRouterState({ select: (state) => !!(state.location.search as DocsSearch).q }); + const isOperationsRoute = pathname === '/docs/operations'; + const isOperationsTableRoute = pathname === '/docs/operations/table'; + const isSchemasRoute = pathname.includes('/docs/schemas'); // Operations expand only in list view, not table view const expandedSection = isOperationsRoute ? 'operations' : isSchemasRoute ? 'schemas' : null; // Start collapsed when landing directly via URL without search params - const searchParams = location.search as Record; - const activeOperationTag = searchParams.operationTag as string | undefined; - const activeSchemaTag = searchParams.schemaTag as string | undefined; - const hasOperationSearchParams = !!activeOperationTag || !!searchParams.q; + const hasOperationSearchParams = !!activeOperationTag || hasQuery; const hasSchemasSearchParams = !!activeSchemaTag; - const [forcedCollapsed, setForcedCollapsed] = useState( - isOperationsRoute && !hasOperationSearchParams - ? 'operations' - : isSchemasRoute && !hasSchemasSearchParams - ? 'schemas' - : null, - ); + const initialForcedCollapsed = + isOperationsRoute && !hasOperationSearchParams ? 'operations' : isSchemasRoute && !hasSchemasSearchParams ? 'schemas' : null; + const [forcedCollapsed, setForcedCollapsed] = useState(initialForcedCollapsed); const prefetchOperations = () => { queryClient.prefetchQuery(operationsQueryOptions); @@ -58,7 +57,7 @@ export function ApiReferenceSection({ label, tags, isMobile }: ApiReferenceSecti return (
    - {label} + {label}
    @@ -93,9 +92,7 @@ export function ApiReferenceSection({ label, tags, isMobile }: ApiReferenceSecti > {t('c:operation', { count: 2 })} {(!isListMode || expandedSection !== 'operations' || forcedCollapsed === 'operations') && ( - - {tags.reduce((sum, tag) => sum + tag.count, 0)} - + {tags.reduce((sum, tag) => sum + tag.count, 0)} )} - + @@ -147,7 +139,7 @@ export function ApiReferenceSection({ label, tags, isMobile }: ApiReferenceSecti > {t('c:schema', { count: 2 })} {(expandedSection !== 'schemas' || forcedCollapsed === 'schemas') && schemas && ( - {schemas.length} + {schemas.length} )} - + diff --git a/frontend/src/modules/docs/sidebar/collapsible-tag-item.tsx b/frontend/src/modules/docs/sidebar/collapsible-tag-item.tsx index 09d9f02d7..efeef3681 100644 --- a/frontend/src/modules/docs/sidebar/collapsible-tag-item.tsx +++ b/frontend/src/modules/docs/sidebar/collapsible-tag-item.tsx @@ -8,6 +8,7 @@ import { buttonVariants } from '~/modules/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/modules/ui/collapsible'; import { SidebarMenuItem } from '~/modules/ui/sidebar'; import { cn } from '~/utils/cn'; +import { tw } from '~/utils/tw'; import { useSheeter } from '../../common/sheeter/use-sheeter'; import { ActiveIndicator } from './active-indicator'; @@ -22,7 +23,7 @@ const tagTypeConfig = { linkTo: '/docs/schemas' as const, getHash: (name: string) => name, getSearch: (collapse: boolean, name: string) => ({ schemaTag: collapse ? undefined : name }), - triggerClassName: 'justify-start lowercase', + triggerClassName: tw('justify-start lowercase'), }, }; @@ -33,7 +34,10 @@ type CollapsibleTagItemProps = { tag: { name: string; count: number }; items: T[]; isExpanded: boolean; + /** The tag or one of its items is in view: highlights the row. */ isActive: boolean; + /** The tag's own section is current, not one of its items. */ + isAtTag: boolean; activeItemIndex: number; layoutId: string; renderItem: (item: T, index: number, isActive: boolean) => ReactNode; @@ -48,6 +52,7 @@ function CollapsibleTagItemBase({ items, isExpanded, isActive, + isAtTag, activeItemIndex, layoutId, renderItem, @@ -58,9 +63,9 @@ function CollapsibleTagItemBase({ const isMobile = useBreakpointBelow('md', false); const { linkTo, getSearch, getHash, triggerClassName } = tagTypeConfig[type]; const hash = getHash(tag.name); - // Collapsing is a re-click on the section you are already reading. While expanded but scrolled + // Collapsing is a re-click at the tag itself. While expanded but at one of its items or scrolled // elsewhere, the click jumps to this section and leaves it open. - const collapseOnClick = isExpanded && isActive; + const collapseOnClick = isExpanded && isAtTag; return ( @@ -91,19 +96,14 @@ function CollapsibleTagItemBase({ /> } > -
    +
    {tag.name} - + {tag.count} - +
    {items.map((item, index) => ( @@ -120,6 +120,7 @@ function collapsibleTagItemEqual(prev: CollapsibleTagItemProps, next: Coll return ( prev.type === next.type && prev.isActive === next.isActive && + prev.isAtTag === next.isAtTag && prev.isExpanded === next.isExpanded && prev.activeItemIndex === next.activeItemIndex && prev.tag === next.tag && @@ -128,7 +129,4 @@ function collapsibleTagItemEqual(prev: CollapsibleTagItemProps, next: Coll } // memo doesn't preserve generics, so we cast -export const CollapsibleTagItem = memo( - CollapsibleTagItemBase, - collapsibleTagItemEqual, -) as typeof CollapsibleTagItemBase; +export const CollapsibleTagItem = memo(CollapsibleTagItemBase, collapsibleTagItemEqual) as typeof CollapsibleTagItemBase; diff --git a/frontend/src/modules/docs/sidebar/docs-sidebar.tsx b/frontend/src/modules/docs/sidebar/docs-sidebar.tsx index 2cd24703b..eb4a98e1f 100644 --- a/frontend/src/modules/docs/sidebar/docs-sidebar.tsx +++ b/frontend/src/modules/docs/sidebar/docs-sidebar.tsx @@ -1,7 +1,8 @@ import { Link } from '@tanstack/react-router'; import { SearchIcon } from 'lucide-react'; -import { Suspense, useRef } from 'react'; +import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import type { GenTagSummary } from 'sdk/docs-types'; import { useBreakpointBelow } from '~/hooks/use-breakpoints'; import { Logo } from '~/modules/common/logo'; import { useSheeter } from '~/modules/common/sheeter/use-sheeter'; @@ -9,16 +10,10 @@ import { openDocsSearch } from '~/modules/docs/search/open-docs-search'; import { ApiReferenceSection } from '~/modules/docs/sidebar/api-reference-section'; import { LinksSection } from '~/modules/docs/sidebar/links-section'; import { PagesSection } from '~/modules/docs/sidebar/pages-section'; -import type { GenTagSummary } from '~/modules/docs/types'; import { UserTheme } from '~/modules/me/user-theme'; import { docsConfig } from '~/modules/page/content'; import { Button } from '~/modules/ui/button'; import { SidebarContent } from '~/modules/ui/sidebar'; -import { lazyNamed } from '~/utils/lazy-named'; - -const DebugDropdown = __DEV_TOOLS__ - ? lazyNamed(() => import('~/modules/common/debug-dropdown'), 'DebugDropdown') - : () => null; interface DocsSidebarProps { tags: GenTagSummary[]; @@ -37,7 +32,7 @@ export function DocsSidebar({ tags }: DocsSidebarProps) { }; return ( - +