diff --git a/README.md b/README.md index 8e97bfe7..7fd98746 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,9 @@ Edit `packages/backend/wrangler.toml` with the created resource values: - `[vars].MAX_RECEIVED_EMAILS_PER_ADDRESS` (default: `100`) - `[vars].MAX_INTEGRATIONS_PER_ORGANIZATION` (default: `3`) - `[vars].MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY` (default: `100`) + - `[vars].OPERATIONAL_EVENT_RETENTION_DAYS` (default: `30`) + - `[vars].OPERATIONAL_EVENT_MAX_METADATA_BYTES` (default: `4096`) + - `[vars].OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS` and `[vars].OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX` (default: `300` seconds and `1` stored event per noisy event identity) - `[vars].API_KEY_RATE_LIMIT_WINDOW` and `[vars].API_KEY_RATE_LIMIT_MAX` (default: `60` seconds and `120` requests for `x-api-key` app traffic, including Better Auth runtime checks on `/get-session` and `/organization/get-full-organization`; these apply in addition to `AUTH_RATE_LIMIT_*` and `AUTH_CHANGE_EMAIL_RATE_LIMIT_*`) - `[vars].AUTH_RATE_LIMIT_WINDOW` (default: `60`) - `[vars].AUTH_RATE_LIMIT_MAX` (optional Better Auth global max override) @@ -379,6 +382,19 @@ pnpm -C packages/backend db:migrate:dev # for production, run `pnpm -C packages/backend db:migrate:prod` ``` +### Bootstrap the first platform admin + +Spinupmail does not auto-promote the first user and does not use env-based admin +IDs. After migrations have added the Better Auth admin fields, promote the first +admin directly in D1: + +```bash +pnpm -C packages/backend exec wrangler d1 execute SUM_DB --local --command "UPDATE users SET role = 'admin' WHERE email = 'you@example.com';" +``` + +For production, run the same statement with `--remote` after confirming the +target user has signed up and verified their email. + ## 5. Deploy the Backend Worker ```bash @@ -620,6 +636,9 @@ Limits: - `EMAIL_ATTACHMENTS_ENABLED`: when `false`, inbound attachments are ignored and attachment UI/API surfaces are disabled (`true` by default). - `MAX_RECEIVED_EMAILS_PER_ORGANIZATION`: hard cap across all stored emails in one organization (default `1000`). - `MAX_RECEIVED_EMAILS_PER_ADDRESS`: hard cap across stored emails in one address (default `100`). +- `OPERATIONAL_EVENT_RETENTION_DAYS`: operational event rows older than this are pruned by the scheduled Worker (default `30`). +- `OPERATIONAL_EVENT_MAX_METADATA_BYTES`: serialized metadata cap per operational event row (default `4096`). +- `OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS` / `OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX`: cap repeated low-value inbound operational events such as rejects, duplicates, limit hits, and abuse blocks (default `1` stored event per `300` seconds per normalized event identity). - `EMAIL_STORE_HEADERS_IN_DB`: persist full header JSON in D1 (`false` by default). - `EMAIL_STORE_RAW_IN_DB`: persist full raw MIME in D1 (`false` by default). - `EMAIL_STORE_RAW_IN_R2`: persist full raw MIME in private R2 (`false` by default). diff --git a/packages/backend/drizzle/0011_wet_sleeper.sql b/packages/backend/drizzle/0011_wet_sleeper.sql new file mode 100644 index 00000000..1379c64a --- /dev/null +++ b/packages/backend/drizzle/0011_wet_sleeper.sql @@ -0,0 +1,28 @@ +CREATE TABLE `operational_events` ( + `id` text PRIMARY KEY NOT NULL, + `severity` text NOT NULL, + `type` text NOT NULL, + `organization_id` text, + `address_id` text, + `email_id` text, + `integration_id` text, + `dispatch_id` text, + `message` text NOT NULL, + `metadata_json` text, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`address_id`) REFERENCES `email_addresses`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`email_id`) REFERENCES `emails`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`integration_id`) REFERENCES `organization_integrations`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`dispatch_id`) REFERENCES `integration_dispatches`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `operational_events_created_idx` ON `operational_events` (`created_at`);--> statement-breakpoint +CREATE INDEX `operational_events_severity_created_idx` ON `operational_events` (`severity`,`created_at`);--> statement-breakpoint +CREATE INDEX `operational_events_type_created_idx` ON `operational_events` (`type`,`created_at`);--> statement-breakpoint +CREATE INDEX `operational_events_org_created_idx` ON `operational_events` (`organization_id`,`created_at`);--> statement-breakpoint +ALTER TABLE `sessions` ADD `impersonated_by` text;--> statement-breakpoint +ALTER TABLE `users` ADD `role` text;--> statement-breakpoint +ALTER TABLE `users` ADD `banned` integer DEFAULT false;--> statement-breakpoint +ALTER TABLE `users` ADD `ban_reason` text;--> statement-breakpoint +ALTER TABLE `users` ADD `ban_expires` integer; diff --git a/packages/backend/drizzle/0012_flaky_hercules.sql b/packages/backend/drizzle/0012_flaky_hercules.sql new file mode 100644 index 00000000..4c82a5e4 --- /dev/null +++ b/packages/backend/drizzle/0012_flaky_hercules.sql @@ -0,0 +1,9 @@ +DROP INDEX `operational_events_created_idx`;--> statement-breakpoint +DROP INDEX `operational_events_severity_created_idx`;--> statement-breakpoint +DROP INDEX `operational_events_type_created_idx`;--> statement-breakpoint +DROP INDEX `operational_events_org_created_idx`;--> statement-breakpoint +CREATE INDEX `operational_events_org_severity_type_created_idx` ON `operational_events` (`organization_id`,`severity`,`type`,"created_at" desc);--> statement-breakpoint +CREATE INDEX `operational_events_created_idx` ON `operational_events` ("created_at" desc);--> statement-breakpoint +CREATE INDEX `operational_events_severity_created_idx` ON `operational_events` (`severity`,"created_at" desc);--> statement-breakpoint +CREATE INDEX `operational_events_type_created_idx` ON `operational_events` (`type`,"created_at" desc);--> statement-breakpoint +CREATE INDEX `operational_events_org_created_idx` ON `operational_events` (`organization_id`,"created_at" desc); \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0011_snapshot.json b/packages/backend/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..4d5ebfe6 --- /dev/null +++ b/packages/backend/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2527 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "361d1d82-28b1-4bcb-9cfe-b08cbe6af3cb", + "prevId": "9f9b4137-2961-4840-9424-673106c842b0", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikeys": { + "name": "apikeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitations_organizationId_idx": { + "name": "invitations_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_uidx": { + "name": "organizations_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "colo": { + "name": "colo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latitude": { + "name": "latitude", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "longitude": { + "name": "longitude", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "two_factors": { + "name": "two_factors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified": { + "name": "verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "twoFactors_secret_idx": { + "name": "twoFactors_secret_idx", + "columns": [ + "secret" + ], + "isUnique": false + }, + "twoFactors_userId_idx": { + "name": "twoFactors_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "two_factors_user_id_users_id_fk": { + "name": "two_factors_user_id_users_id_fk", + "tableFrom": "two_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_normalized_email_unique": { + "name": "users_normalized_email_unique", + "columns": [ + "normalized_email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_addresses": { + "name": "email_addresses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_part": { + "name": "local_part", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_count": { + "name": "email_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_created": { + "name": "auto_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_received_at": { + "name": "last_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_addresses_address_unique": { + "name": "email_addresses_address_unique", + "columns": [ + "address" + ], + "isUnique": true + }, + "email_addresses_domain_idx": { + "name": "email_addresses_domain_idx", + "columns": [ + "domain" + ], + "isUnique": false + }, + "email_addresses_org_id_uidx": { + "name": "email_addresses_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "email_addresses_org_created_idx": { + "name": "email_addresses_org_created_idx", + "columns": [ + "organization_id", + "created_at" + ], + "isUnique": false + }, + "email_addresses_org_user_created_idx": { + "name": "email_addresses_org_user_created_idx", + "columns": [ + "organization_id", + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_addresses_organization_id_organizations_id_fk": { + "name": "email_addresses_organization_id_organizations_id_fk", + "tableFrom": "email_addresses", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_addresses_user_id_users_id_fk": { + "name": "email_addresses_user_id_users_id_fk", + "tableFrom": "email_addresses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_attachments": { + "name": "email_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "email_attachments_r2_key_unique": { + "name": "email_attachments_r2_key_unique", + "columns": [ + "r2_key" + ], + "isUnique": true + }, + "email_attachments_org_email_created_idx": { + "name": "email_attachments_org_email_created_idx", + "columns": [ + "organization_id", + "email_id", + "created_at" + ], + "isUnique": false + }, + "email_attachments_org_address_created_idx": { + "name": "email_attachments_org_address_created_idx", + "columns": [ + "organization_id", + "address_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_attachments_email_id_emails_id_fk": { + "name": "email_attachments_email_id_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_organization_id_organizations_id_fk": { + "name": "email_attachments_organization_id_organizations_id_fk", + "tableFrom": "email_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_address_id_email_addresses_id_fk": { + "name": "email_attachments_address_id_email_addresses_id_fk", + "tableFrom": "email_attachments", + "tableTo": "email_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_user_id_users_id_fk": { + "name": "email_attachments_user_id_users_id_fk", + "tableFrom": "email_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "emails": { + "name": "emails", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_size": { + "name": "raw_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_truncated": { + "name": "raw_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_sample": { + "name": "is_sample", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "emails_address_received_idx": { + "name": "emails_address_received_idx", + "columns": [ + "address_id", + "received_at" + ], + "isUnique": false + }, + "emails_address_message_id_unique": { + "name": "emails_address_message_id_unique", + "columns": [ + "address_id", + "message_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "emails_address_id_email_addresses_id_fk": { + "name": "emails_address_id_email_addresses_id_fk", + "tableFrom": "emails", + "tableTo": "email_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extension_auth_handoffs": { + "name": "extension_auth_handoffs", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "extension_auth_handoffs_expires_idx": { + "name": "extension_auth_handoffs_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "address_integration_subscriptions": { + "name": "address_integration_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "address_integration_subscriptions_org_id_uidx": { + "name": "address_integration_subscriptions_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "address_integration_subscriptions_address_event_uidx": { + "name": "address_integration_subscriptions_address_event_uidx", + "columns": [ + "address_id", + "integration_id", + "event_type" + ], + "isUnique": true + }, + "address_integration_subscriptions_org_address_event_idx": { + "name": "address_integration_subscriptions_org_address_event_idx", + "columns": [ + "organization_id", + "address_id", + "event_type" + ], + "isUnique": false + }, + "address_integration_subscriptions_integration_event_idx": { + "name": "address_integration_subscriptions_integration_event_idx", + "columns": [ + "integration_id", + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": { + "address_integration_subscriptions_organization_id_organizations_id_fk": { + "name": "address_integration_subscriptions_organization_id_organizations_id_fk", + "tableFrom": "address_integration_subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "address_integration_subscriptions_organization_id_address_id_email_addresses_organization_id_id_fk": { + "name": "address_integration_subscriptions_organization_id_address_id_email_addresses_organization_id_id_fk", + "tableFrom": "address_integration_subscriptions", + "tableTo": "email_addresses", + "columnsFrom": [ + "organization_id", + "address_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "address_integration_subscriptions_organization_id_integration_id_organization_integrations_organization_id_id_fk": { + "name": "address_integration_subscriptions_organization_id_integration_id_organization_integrations_organization_id_id_fk", + "tableFrom": "address_integration_subscriptions", + "tableTo": "organization_integrations", + "columnsFrom": [ + "organization_id", + "integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_delivery_attempts": { + "name": "integration_delivery_attempts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_status": { + "name": "error_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_retry_after_seconds": { + "name": "error_retry_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration_delivery_attempts_dispatch_attempt_uidx": { + "name": "integration_delivery_attempts_dispatch_attempt_uidx", + "columns": [ + "dispatch_id", + "attempt_number" + ], + "isUnique": true + }, + "integration_delivery_attempts_org_started_idx": { + "name": "integration_delivery_attempts_org_started_idx", + "columns": [ + "organization_id", + "started_at" + ], + "isUnique": false + }, + "integration_delivery_attempts_integration_started_idx": { + "name": "integration_delivery_attempts_integration_started_idx", + "columns": [ + "integration_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_delivery_attempts_organization_id_organizations_id_fk": { + "name": "integration_delivery_attempts_organization_id_organizations_id_fk", + "tableFrom": "integration_delivery_attempts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_dispatches": { + "name": "integration_dispatches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_email_id": { + "name": "source_email_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempt_window_ms": { + "name": "max_attempt_window_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_status": { + "name": "last_error_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_retry_after_seconds": { + "name": "last_error_retry_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_message_id": { + "name": "queue_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "integration_dispatches_org_id_uidx": { + "name": "integration_dispatches_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "integration_dispatches_idempotency_key_uidx": { + "name": "integration_dispatches_idempotency_key_uidx", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "integration_dispatches_integration_status_created_idx": { + "name": "integration_dispatches_integration_status_created_idx", + "columns": [ + "integration_id", + "status", + "created_at" + ], + "isUnique": false + }, + "integration_dispatches_org_status_created_idx": { + "name": "integration_dispatches_org_status_created_idx", + "columns": [ + "organization_id", + "status", + "created_at" + ], + "isUnique": false + }, + "integration_dispatches_status_next_attempt_idx": { + "name": "integration_dispatches_status_next_attempt_idx", + "columns": [ + "status", + "next_attempt_at" + ], + "isUnique": false + }, + "integration_dispatches_source_email_idx": { + "name": "integration_dispatches_source_email_idx", + "columns": [ + "source_email_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_dispatches_organization_id_organizations_id_fk": { + "name": "integration_dispatches_organization_id_organizations_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_dispatches_source_email_id_emails_id_fk": { + "name": "integration_dispatches_source_email_id_emails_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "emails", + "columnsFrom": [ + "source_email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_dispatches_organization_id_integration_id_organization_integrations_organization_id_id_fk": { + "name": "integration_dispatches_organization_id_integration_id_organization_integrations_organization_id_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "organization_integrations", + "columnsFrom": [ + "organization_id", + "integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_dispatches_organization_id_subscription_id_address_integration_subscriptions_organization_id_id_fk": { + "name": "integration_dispatches_organization_id_subscription_id_address_integration_subscriptions_organization_id_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "address_integration_subscriptions", + "columnsFrom": [ + "organization_id", + "subscription_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_integration_secrets": { + "name": "organization_integration_secrets", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_config_json": { + "name": "encrypted_config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_integration_secrets_integration_version_uidx": { + "name": "organization_integration_secrets_integration_version_uidx", + "columns": [ + "integration_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_integration_secrets_integration_id_organization_integrations_id_fk": { + "name": "organization_integration_secrets_integration_id_organization_integrations_id_fk", + "tableFrom": "organization_integration_secrets", + "tableTo": "organization_integrations", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_integration_secrets_pk": { + "columns": [ + "integration_id", + "version" + ], + "name": "organization_integration_secrets_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_integrations": { + "name": "organization_integrations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_config_json": { + "name": "public_config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_secret_version": { + "name": "active_secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_integrations_org_id_uidx": { + "name": "organization_integrations_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "organization_integrations_org_provider_status_idx": { + "name": "organization_integrations_org_provider_status_idx", + "columns": [ + "organization_id", + "provider", + "status" + ], + "isUnique": false + }, + "organization_integrations_org_provider_name_uidx": { + "name": "organization_integrations_org_provider_name_uidx", + "columns": [ + "organization_id", + "provider", + "name" + ], + "isUnique": true + }, + "organization_integrations_org_provider_config_uidx": { + "name": "organization_integrations_org_provider_config_uidx", + "columns": [ + "organization_id", + "provider", + "public_config_json" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_integrations_organization_id_organizations_id_fk": { + "name": "organization_integrations_organization_id_organizations_id_fk", + "tableFrom": "organization_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_integrations_created_by_user_id_users_id_fk": { + "name": "organization_integrations_created_by_user_id_users_id_fk", + "tableFrom": "organization_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operational_events": { + "name": "operational_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "operational_events_created_idx": { + "name": "operational_events_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "operational_events_severity_created_idx": { + "name": "operational_events_severity_created_idx", + "columns": [ + "severity", + "created_at" + ], + "isUnique": false + }, + "operational_events_type_created_idx": { + "name": "operational_events_type_created_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "operational_events_org_created_idx": { + "name": "operational_events_org_created_idx", + "columns": [ + "organization_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operational_events_organization_id_organizations_id_fk": { + "name": "operational_events_organization_id_organizations_id_fk", + "tableFrom": "operational_events", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_address_id_email_addresses_id_fk": { + "name": "operational_events_address_id_email_addresses_id_fk", + "tableFrom": "operational_events", + "tableTo": "email_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_email_id_emails_id_fk": { + "name": "operational_events_email_id_emails_id_fk", + "tableFrom": "operational_events", + "tableTo": "emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_integration_id_organization_integrations_id_fk": { + "name": "operational_events_integration_id_organization_integrations_id_fk", + "tableFrom": "operational_events", + "tableTo": "organization_integrations", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_dispatch_id_integration_dispatches_id_fk": { + "name": "operational_events_dispatch_id_integration_dispatches_id_fk", + "tableFrom": "operational_events", + "tableTo": "integration_dispatches", + "columnsFrom": [ + "dispatch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/backend/drizzle/meta/0012_snapshot.json b/packages/backend/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..d22df4b9 --- /dev/null +++ b/packages/backend/drizzle/meta/0012_snapshot.json @@ -0,0 +1,2573 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "de916cc6-dfed-4b8c-81a0-08fa7a6785d7", + "prevId": "361d1d82-28b1-4bcb-9cfe-b08cbe6af3cb", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "accounts_userId_idx": { + "name": "accounts_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikeys": { + "name": "apikeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikeys_configId_idx": { + "name": "apikeys_configId_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikeys_referenceId_idx": { + "name": "apikeys_referenceId_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikeys_key_idx": { + "name": "apikeys_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitations_organizationId_idx": { + "name": "invitations_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_uidx": { + "name": "organizations_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "colo": { + "name": "colo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latitude": { + "name": "latitude", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "longitude": { + "name": "longitude", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessions_userId_idx": { + "name": "sessions_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "two_factors": { + "name": "two_factors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified": { + "name": "verified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "twoFactors_secret_idx": { + "name": "twoFactors_secret_idx", + "columns": [ + "secret" + ], + "isUnique": false + }, + "twoFactors_userId_idx": { + "name": "twoFactors_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "two_factors_user_id_users_id_fk": { + "name": "two_factors_user_id_users_id_fk", + "tableFrom": "two_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_normalized_email_unique": { + "name": "users_normalized_email_unique", + "columns": [ + "normalized_email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_addresses": { + "name": "email_addresses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_part": { + "name": "local_part", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "meta": { + "name": "meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_count": { + "name": "email_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_created": { + "name": "auto_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_received_at": { + "name": "last_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_addresses_address_unique": { + "name": "email_addresses_address_unique", + "columns": [ + "address" + ], + "isUnique": true + }, + "email_addresses_domain_idx": { + "name": "email_addresses_domain_idx", + "columns": [ + "domain" + ], + "isUnique": false + }, + "email_addresses_org_id_uidx": { + "name": "email_addresses_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "email_addresses_org_created_idx": { + "name": "email_addresses_org_created_idx", + "columns": [ + "organization_id", + "created_at" + ], + "isUnique": false + }, + "email_addresses_org_user_created_idx": { + "name": "email_addresses_org_user_created_idx", + "columns": [ + "organization_id", + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_addresses_organization_id_organizations_id_fk": { + "name": "email_addresses_organization_id_organizations_id_fk", + "tableFrom": "email_addresses", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_addresses_user_id_users_id_fk": { + "name": "email_addresses_user_id_users_id_fk", + "tableFrom": "email_addresses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_attachments": { + "name": "email_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "email_attachments_r2_key_unique": { + "name": "email_attachments_r2_key_unique", + "columns": [ + "r2_key" + ], + "isUnique": true + }, + "email_attachments_org_email_created_idx": { + "name": "email_attachments_org_email_created_idx", + "columns": [ + "organization_id", + "email_id", + "created_at" + ], + "isUnique": false + }, + "email_attachments_org_address_created_idx": { + "name": "email_attachments_org_address_created_idx", + "columns": [ + "organization_id", + "address_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "email_attachments_email_id_emails_id_fk": { + "name": "email_attachments_email_id_emails_id_fk", + "tableFrom": "email_attachments", + "tableTo": "emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_organization_id_organizations_id_fk": { + "name": "email_attachments_organization_id_organizations_id_fk", + "tableFrom": "email_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_address_id_email_addresses_id_fk": { + "name": "email_attachments_address_id_email_addresses_id_fk", + "tableFrom": "email_attachments", + "tableTo": "email_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_attachments_user_id_users_id_fk": { + "name": "email_attachments_user_id_users_id_fk", + "tableFrom": "email_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "emails": { + "name": "emails", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sender": { + "name": "sender", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_size": { + "name": "raw_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_truncated": { + "name": "raw_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_sample": { + "name": "is_sample", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "emails_address_received_idx": { + "name": "emails_address_received_idx", + "columns": [ + "address_id", + "received_at" + ], + "isUnique": false + }, + "emails_address_message_id_unique": { + "name": "emails_address_message_id_unique", + "columns": [ + "address_id", + "message_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "emails_address_id_email_addresses_id_fk": { + "name": "emails_address_id_email_addresses_id_fk", + "tableFrom": "emails", + "tableTo": "email_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "extension_auth_handoffs": { + "name": "extension_auth_handoffs", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "extension_auth_handoffs_expires_idx": { + "name": "extension_auth_handoffs_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "address_integration_subscriptions": { + "name": "address_integration_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "address_integration_subscriptions_org_id_uidx": { + "name": "address_integration_subscriptions_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "address_integration_subscriptions_address_event_uidx": { + "name": "address_integration_subscriptions_address_event_uidx", + "columns": [ + "address_id", + "integration_id", + "event_type" + ], + "isUnique": true + }, + "address_integration_subscriptions_org_address_event_idx": { + "name": "address_integration_subscriptions_org_address_event_idx", + "columns": [ + "organization_id", + "address_id", + "event_type" + ], + "isUnique": false + }, + "address_integration_subscriptions_integration_event_idx": { + "name": "address_integration_subscriptions_integration_event_idx", + "columns": [ + "integration_id", + "event_type" + ], + "isUnique": false + } + }, + "foreignKeys": { + "address_integration_subscriptions_organization_id_organizations_id_fk": { + "name": "address_integration_subscriptions_organization_id_organizations_id_fk", + "tableFrom": "address_integration_subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "address_integration_subscriptions_organization_id_address_id_email_addresses_organization_id_id_fk": { + "name": "address_integration_subscriptions_organization_id_address_id_email_addresses_organization_id_id_fk", + "tableFrom": "address_integration_subscriptions", + "tableTo": "email_addresses", + "columnsFrom": [ + "organization_id", + "address_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "address_integration_subscriptions_organization_id_integration_id_organization_integrations_organization_id_id_fk": { + "name": "address_integration_subscriptions_organization_id_integration_id_organization_integrations_organization_id_id_fk", + "tableFrom": "address_integration_subscriptions", + "tableTo": "organization_integrations", + "columnsFrom": [ + "organization_id", + "integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_delivery_attempts": { + "name": "integration_delivery_attempts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_status": { + "name": "error_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_retry_after_seconds": { + "name": "error_retry_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "integration_delivery_attempts_dispatch_attempt_uidx": { + "name": "integration_delivery_attempts_dispatch_attempt_uidx", + "columns": [ + "dispatch_id", + "attempt_number" + ], + "isUnique": true + }, + "integration_delivery_attempts_org_started_idx": { + "name": "integration_delivery_attempts_org_started_idx", + "columns": [ + "organization_id", + "started_at" + ], + "isUnique": false + }, + "integration_delivery_attempts_integration_started_idx": { + "name": "integration_delivery_attempts_integration_started_idx", + "columns": [ + "integration_id", + "started_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_delivery_attempts_organization_id_organizations_id_fk": { + "name": "integration_delivery_attempts_organization_id_organizations_id_fk", + "tableFrom": "integration_delivery_attempts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_dispatches": { + "name": "integration_dispatches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_email_id": { + "name": "source_email_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempt_window_ms": { + "name": "max_attempt_window_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_status": { + "name": "last_error_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_retry_after_seconds": { + "name": "last_error_retry_after_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_message_id": { + "name": "queue_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "integration_dispatches_org_id_uidx": { + "name": "integration_dispatches_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "integration_dispatches_idempotency_key_uidx": { + "name": "integration_dispatches_idempotency_key_uidx", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "integration_dispatches_integration_status_created_idx": { + "name": "integration_dispatches_integration_status_created_idx", + "columns": [ + "integration_id", + "status", + "created_at" + ], + "isUnique": false + }, + "integration_dispatches_org_status_created_idx": { + "name": "integration_dispatches_org_status_created_idx", + "columns": [ + "organization_id", + "status", + "created_at" + ], + "isUnique": false + }, + "integration_dispatches_status_next_attempt_idx": { + "name": "integration_dispatches_status_next_attempt_idx", + "columns": [ + "status", + "next_attempt_at" + ], + "isUnique": false + }, + "integration_dispatches_source_email_idx": { + "name": "integration_dispatches_source_email_idx", + "columns": [ + "source_email_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_dispatches_organization_id_organizations_id_fk": { + "name": "integration_dispatches_organization_id_organizations_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_dispatches_source_email_id_emails_id_fk": { + "name": "integration_dispatches_source_email_id_emails_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "emails", + "columnsFrom": [ + "source_email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_dispatches_organization_id_integration_id_organization_integrations_organization_id_id_fk": { + "name": "integration_dispatches_organization_id_integration_id_organization_integrations_organization_id_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "organization_integrations", + "columnsFrom": [ + "organization_id", + "integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_dispatches_organization_id_subscription_id_address_integration_subscriptions_organization_id_id_fk": { + "name": "integration_dispatches_organization_id_subscription_id_address_integration_subscriptions_organization_id_id_fk", + "tableFrom": "integration_dispatches", + "tableTo": "address_integration_subscriptions", + "columnsFrom": [ + "organization_id", + "subscription_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_integration_secrets": { + "name": "organization_integration_secrets", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_config_json": { + "name": "encrypted_config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_integration_secrets_integration_version_uidx": { + "name": "organization_integration_secrets_integration_version_uidx", + "columns": [ + "integration_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_integration_secrets_integration_id_organization_integrations_id_fk": { + "name": "organization_integration_secrets_integration_id_organization_integrations_id_fk", + "tableFrom": "organization_integration_secrets", + "tableTo": "organization_integrations", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_integration_secrets_pk": { + "columns": [ + "integration_id", + "version" + ], + "name": "organization_integration_secrets_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_integrations": { + "name": "organization_integrations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_config_json": { + "name": "public_config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_secret_version": { + "name": "active_secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_integrations_org_id_uidx": { + "name": "organization_integrations_org_id_uidx", + "columns": [ + "organization_id", + "id" + ], + "isUnique": true + }, + "organization_integrations_org_provider_status_idx": { + "name": "organization_integrations_org_provider_status_idx", + "columns": [ + "organization_id", + "provider", + "status" + ], + "isUnique": false + }, + "organization_integrations_org_provider_name_uidx": { + "name": "organization_integrations_org_provider_name_uidx", + "columns": [ + "organization_id", + "provider", + "name" + ], + "isUnique": true + }, + "organization_integrations_org_provider_config_uidx": { + "name": "organization_integrations_org_provider_config_uidx", + "columns": [ + "organization_id", + "provider", + "public_config_json" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_integrations_organization_id_organizations_id_fk": { + "name": "organization_integrations_organization_id_organizations_id_fk", + "tableFrom": "organization_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_integrations_created_by_user_id_users_id_fk": { + "name": "organization_integrations_created_by_user_id_users_id_fk", + "tableFrom": "organization_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "operational_events": { + "name": "operational_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_id": { + "name": "email_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "operational_events_created_idx": { + "name": "operational_events_created_idx", + "columns": [ + "\"created_at\" desc" + ], + "isUnique": false + }, + "operational_events_severity_created_idx": { + "name": "operational_events_severity_created_idx", + "columns": [ + "severity", + "\"created_at\" desc" + ], + "isUnique": false + }, + "operational_events_type_created_idx": { + "name": "operational_events_type_created_idx", + "columns": [ + "type", + "\"created_at\" desc" + ], + "isUnique": false + }, + "operational_events_org_created_idx": { + "name": "operational_events_org_created_idx", + "columns": [ + "organization_id", + "\"created_at\" desc" + ], + "isUnique": false + }, + "operational_events_org_severity_type_created_idx": { + "name": "operational_events_org_severity_type_created_idx", + "columns": [ + "organization_id", + "severity", + "type", + "\"created_at\" desc" + ], + "isUnique": false + } + }, + "foreignKeys": { + "operational_events_organization_id_organizations_id_fk": { + "name": "operational_events_organization_id_organizations_id_fk", + "tableFrom": "operational_events", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_address_id_email_addresses_id_fk": { + "name": "operational_events_address_id_email_addresses_id_fk", + "tableFrom": "operational_events", + "tableTo": "email_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_email_id_emails_id_fk": { + "name": "operational_events_email_id_emails_id_fk", + "tableFrom": "operational_events", + "tableTo": "emails", + "columnsFrom": [ + "email_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_integration_id_organization_integrations_id_fk": { + "name": "operational_events_integration_id_organization_integrations_id_fk", + "tableFrom": "operational_events", + "tableTo": "organization_integrations", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "operational_events_dispatch_id_integration_dispatches_id_fk": { + "name": "operational_events_dispatch_id_integration_dispatches_id_fk", + "tableFrom": "operational_events", + "tableTo": "integration_dispatches", + "columnsFrom": [ + "dispatch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "operational_events_created_idx": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + } + } + }, + "operational_events_severity_created_idx": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + } + } + }, + "operational_events_type_created_idx": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + } + } + }, + "operational_events_org_created_idx": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + } + } + }, + "operational_events_org_severity_type_created_idx": { + "columns": { + "\"created_at\" desc": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 1abf7ccc..d8d70745 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -78,6 +78,20 @@ "when": 1776959886106, "tag": "0010_colossal_piledriver", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1777318511993, + "tag": "0011_wet_sleeper", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1777623785767, + "tag": "0012_flaky_hercules", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/app/middleware/require-platform-admin.ts b/packages/backend/src/app/middleware/require-platform-admin.ts new file mode 100644 index 00000000..01e9f929 --- /dev/null +++ b/packages/backend/src/app/middleware/require-platform-admin.ts @@ -0,0 +1,25 @@ +import type { MiddlewareHandler } from "hono"; +import { eq } from "drizzle-orm"; +import type { AppHonoEnv } from "@/app/types"; +import { users } from "@/db"; +import { getDb } from "@/platform/db/client"; +import { isPlatformAdminRole } from "@/platform/auth/admin-access"; + +export const requirePlatformAdmin: MiddlewareHandler = async ( + c, + next +) => { + const session = c.get("session"); + const db = getDb(c.env); + const user = await db + .select({ role: users.role }) + .from(users) + .where(eq(users.id, session.user.id)) + .get(); + + if (!isPlatformAdminRole(user?.role)) { + return c.json({ error: "forbidden" }, 403); + } + + await next(); +}; diff --git a/packages/backend/src/db/auth.schema.ts b/packages/backend/src/db/auth.schema.ts index 4ad191b3..a94c3979 100644 --- a/packages/backend/src/db/auth.schema.ts +++ b/packages/backend/src/db/auth.schema.ts @@ -11,7 +11,6 @@ export const users = sqliteTable("users", { id: text("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull().unique(), - normalizedEmail: text("normalized_email").unique(), emailVerified: integer("email_verified", { mode: "boolean" }) .default(false) .notNull(), @@ -26,6 +25,11 @@ export const users = sqliteTable("users", { twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).default( false ), + role: text("role"), + banned: integer("banned", { mode: "boolean" }).default(false), + banReason: text("ban_reason"), + banExpires: integer("ban_expires", { mode: "timestamp_ms" }), + normalizedEmail: text("normalized_email").unique(), timezone: text("timezone"), }); @@ -55,6 +59,7 @@ export const sessions = sqliteTable( latitude: text("latitude"), longitude: text("longitude"), activeOrganizationId: text("active_organization_id"), + impersonatedBy: text("impersonated_by"), }, table => [index("sessions_userId_idx").on(table.userId)] ); diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index 6ccbecd3..281a82de 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -5,4 +5,5 @@ export * from "drizzle-orm"; export * from "./auth.schema"; // Export individual tables for drizzle-kit export * from "./email.schema"; export * from "./integration.schema"; +export * from "./operational-event.schema"; export * from "./schema"; diff --git a/packages/backend/src/db/operational-event.schema.ts b/packages/backend/src/db/operational-event.schema.ts new file mode 100644 index 00000000..0b72d2ba --- /dev/null +++ b/packages/backend/src/db/operational-event.schema.ts @@ -0,0 +1,85 @@ +import { desc, relations, sql } from "drizzle-orm"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { emailAddresses, emails } from "./email.schema"; +import { + integrationDispatches, + organizationIntegrations, +} from "./integration.schema"; +import { organizations } from "./auth.schema"; + +export const operationalEvents = sqliteTable( + "operational_events", + { + id: text("id").primaryKey(), + severity: text("severity").notNull(), + type: text("type").notNull(), + organizationId: text("organization_id").references(() => organizations.id, { + onDelete: "set null", + }), + addressId: text("address_id").references(() => emailAddresses.id, { + onDelete: "set null", + }), + emailId: text("email_id").references(() => emails.id, { + onDelete: "set null", + }), + integrationId: text("integration_id").references( + () => organizationIntegrations.id, + { onDelete: "set null" } + ), + dispatchId: text("dispatch_id").references(() => integrationDispatches.id, { + onDelete: "set null", + }), + message: text("message").notNull(), + metadataJson: text("metadata_json"), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + table => [ + index("operational_events_created_idx").on(desc(table.createdAt)), + index("operational_events_severity_created_idx").on( + table.severity, + desc(table.createdAt) + ), + index("operational_events_type_created_idx").on( + table.type, + desc(table.createdAt) + ), + index("operational_events_org_created_idx").on( + table.organizationId, + desc(table.createdAt) + ), + index("operational_events_org_severity_type_created_idx").on( + table.organizationId, + table.severity, + table.type, + desc(table.createdAt) + ), + ] +); + +export const operationalEventsRelations = relations( + operationalEvents, + ({ one }) => ({ + organization: one(organizations, { + fields: [operationalEvents.organizationId], + references: [organizations.id], + }), + address: one(emailAddresses, { + fields: [operationalEvents.addressId], + references: [emailAddresses.id], + }), + email: one(emails, { + fields: [operationalEvents.emailId], + references: [emails.id], + }), + integration: one(organizationIntegrations, { + fields: [operationalEvents.integrationId], + references: [organizationIntegrations.id], + }), + dispatch: one(integrationDispatches, { + fields: [operationalEvents.dispatchId], + references: [integrationDispatches.id], + }), + }) +); diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index 9e5e4c9b..8c0cf366 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -1,11 +1,13 @@ import * as authSchema from "./auth.schema"; // This will be generated in a later step import * as emailSchema from "./email.schema"; import * as integrationSchema from "./integration.schema"; +import * as operationalEventSchema from "./operational-event.schema"; // Combine all schemas here for migrations export const schema = { ...authSchema, ...emailSchema, ...integrationSchema, + ...operationalEventSchema, // ... your other application schemas } as const; diff --git a/packages/backend/src/env.d.ts b/packages/backend/src/env.d.ts index a1d01b74..ce18cff6 100644 --- a/packages/backend/src/env.d.ts +++ b/packages/backend/src/env.d.ts @@ -31,6 +31,10 @@ declare global { MAX_INTEGRATIONS_PER_ORGANIZATION?: string; MAX_RECEIVED_EMAILS_PER_ADDRESS?: string; MAX_RECEIVED_EMAILS_PER_ORGANIZATION?: string; + OPERATIONAL_EVENT_MAX_METADATA_BYTES?: string; + OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX?: string; + OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS?: string; + OPERATIONAL_EVENT_RETENTION_DAYS?: string; RESEND_FROM_EMAIL?: string; } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 811474ae..6fa63c5f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -3,9 +3,11 @@ import type { AppHonoEnv } from "@/app/types"; import { registerCorsMiddleware } from "@/app/middleware/cors"; import { registerAuthInitializationMiddleware } from "@/app/middleware/init-auth"; import { requireAuth } from "@/app/middleware/require-auth"; +import { requirePlatformAdmin } from "@/app/middleware/require-platform-admin"; import { requireOrganizationScope } from "@/app/middleware/require-organization-scope"; import { registerErrorHandling } from "@/app/middleware/error-handler"; import { createAuth } from "@/platform/auth/create-auth"; +import { createAdminRouter } from "@/modules/admin/router"; import { createAuthHttpRouter } from "@/modules/auth-http/router"; import { createDomainsRouter } from "@/modules/domains/router"; import { createOrganizationsRouter } from "@/modules/organizations/router"; @@ -16,6 +18,7 @@ import { createE2EAuthTestRouter } from "@/modules/e2e-auth/router"; import { createExtensionRouter } from "@/modules/extension/router"; import { InboundAbuseCounterDurableObject } from "@/modules/inbound-email/abuse-counter"; import { FixedWindowRateLimiterDurableObject } from "@/shared/rate-limiter"; +import { pruneOperationalEvents } from "@/modules/admin/operational-events"; import { handleIncomingEmail } from "@/modules/inbound-email/handler"; import { handleIntegrationDispatchQueueBatch } from "@/modules/integrations/queue"; @@ -41,6 +44,8 @@ export const createApp = (options: AppFactoryOptions = {}) => { app.route("/api", createAuthHttpRouter()); app.use("/api/domains", requireAuth); + app.use("/api/admin/*", requireAuth); + app.use("/api/admin/*", requirePlatformAdmin); app.use("/api/organizations/stats/*", requireAuth); app.use("/api/extension/bootstrap", requireAuth); app.use("/api/extension/invitations/*", requireAuth); @@ -56,6 +61,7 @@ export const createApp = (options: AppFactoryOptions = {}) => { app.use("/api/integrations/*", requireOrganizationScope); app.route("/api", createExtensionRouter()); + app.route("/api", createAdminRouter()); app.route("/api", createDomainsRouter()); app.route("/api", createOrganizationsRouter()); app.route("/api", createEmailAddressesRouter()); @@ -89,6 +95,19 @@ export const createWorkerHandler = (options: WorkerHandlerOptions = {}) => { email: options.emailHandler ?? handleIncomingEmail, queue: (batch: MessageBatch, env: CloudflareBindings) => queueHandler({ batch, env }), + scheduled: ( + _controller: ScheduledController, + env: CloudflareBindings, + ctx: ExecutionContext + ) => { + ctx.waitUntil( + pruneOperationalEvents(env).catch(error => { + console.error("[admin] Failed to prune operational events", { + error, + }); + }) + ); + }, }; }; diff --git a/packages/backend/src/modules/admin/operational-events.ts b/packages/backend/src/modules/admin/operational-events.ts new file mode 100644 index 00000000..b290ee65 --- /dev/null +++ b/packages/backend/src/modules/admin/operational-events.ts @@ -0,0 +1,315 @@ +import type { + AdminOperationalEventSeverity, + AdminOperationalEventType, +} from "@spinupmail/contracts"; +import { sql } from "drizzle-orm"; +import { operationalEvents } from "@/db"; +import { getDb } from "@/platform/db/client"; +import { consumeFixedWindowRateLimit } from "@/shared/rate-limiter"; +import { parsePositiveInteger } from "@/shared/env"; +import { hashForRateLimitKey } from "@/shared/utils/crypto"; + +type OperationalEventInput = { + env: CloudflareBindings; + severity: AdminOperationalEventSeverity; + type: AdminOperationalEventType; + organizationId?: string | null; + addressId?: string | null; + emailId?: string | null; + integrationId?: string | null; + dispatchId?: string | null; + message: string; + metadata?: Record | null; +}; + +const SENSITIVE_KEY_PATTERN = + /(?:^|[._\-\s])(?:token|secret|password|authorization|cookie|api[-_]?key|raw|headers|body|html|text|envelope)(?=$|[._\-\s])/i; +const DEFAULT_OPERATIONAL_EVENT_RETENTION_DAYS = 30; +const DEFAULT_OPERATIONAL_EVENT_MAX_METADATA_BYTES = 4 * 1024; +const DEFAULT_NOISY_EVENT_RATE_LIMIT_WINDOW_SECONDS = 5 * 60; +const DEFAULT_NOISY_EVENT_RATE_LIMIT_MAX = 1; +const MAX_OPERATIONAL_EVENT_MESSAGE_LENGTH = 500; +const MAX_OPERATIONAL_EVENT_STRING_LENGTH = 512; +const MAX_OPERATIONAL_EVENT_ARRAY_ITEMS = 20; +const MAX_OPERATIONAL_EVENT_OBJECT_KEYS = 50; +const MAX_OPERATIONAL_EVENT_METADATA_BYTES = 64 * 1024; +const NOISY_EVENT_TYPES = new Set([ + "inbound_rejected", + "inbound_duplicate", + "inbound_limit_reached", + "inbound_abuse_block", +]); + +const normalizeMetadataKey = (key: string) => + key.replace(/([a-z0-9])([A-Z])/g, "$1_$2"); + +const clampInteger = ({ + value, + fallback, + min, + max, +}: { + value: number | undefined; + fallback: number; + min: number; + max: number; +}) => { + if (!value) return fallback; + return Math.max(min, Math.min(value, max)); +}; + +const getOperationalEventRetentionDays = ( + env: Pick +) => + clampInteger({ + value: parsePositiveInteger(env.OPERATIONAL_EVENT_RETENTION_DAYS), + fallback: DEFAULT_OPERATIONAL_EVENT_RETENTION_DAYS, + min: 1, + max: 365, + }); + +const getOperationalEventMaxMetadataBytes = ( + env: Pick +) => + clampInteger({ + value: parsePositiveInteger(env.OPERATIONAL_EVENT_MAX_METADATA_BYTES), + fallback: DEFAULT_OPERATIONAL_EVENT_MAX_METADATA_BYTES, + min: 512, + max: MAX_OPERATIONAL_EVENT_METADATA_BYTES, + }); + +const getNoisyEventRateLimitWindowSeconds = ( + env: Pick< + CloudflareBindings, + "OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS" + > +) => + clampInteger({ + value: parsePositiveInteger( + env.OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS + ), + fallback: DEFAULT_NOISY_EVENT_RATE_LIMIT_WINDOW_SECONDS, + min: 60, + max: 24 * 60 * 60, + }); + +const getNoisyEventRateLimitMax = ( + env: Pick +) => + clampInteger({ + value: parsePositiveInteger(env.OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX), + fallback: DEFAULT_NOISY_EVENT_RATE_LIMIT_MAX, + min: 1, + max: 100, + }); + +const truncateString = (value: string, maxLength: number) => { + if (value.length <= maxLength) return value; + const suffix = "...[truncated]"; + return `${value.slice(0, Math.max(0, maxLength - suffix.length))}${suffix}`; +}; + +const sanitizeMetadataValue = (value: unknown, depth = 0): unknown => { + if (depth > 4) return "[truncated]"; + if (value === null) return null; + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return truncateString(value, MAX_OPERATIONAL_EVENT_STRING_LENGTH); + } + if (value instanceof Date) return value.toISOString(); + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) { + const sanitized = value + .slice(0, MAX_OPERATIONAL_EVENT_ARRAY_ITEMS) + .map(item => sanitizeMetadataValue(item, depth + 1)); + if (value.length > MAX_OPERATIONAL_EVENT_ARRAY_ITEMS) { + sanitized.push( + `[${value.length - MAX_OPERATIONAL_EVENT_ARRAY_ITEMS} items truncated]` + ); + } + return sanitized; + } + if (typeof value !== "object") return String(value); + + const output: Record = {}; + const entries = Object.entries(value); + for (const [key, entry] of entries.slice( + 0, + MAX_OPERATIONAL_EVENT_OBJECT_KEYS + )) { + if (SENSITIVE_KEY_PATTERN.test(normalizeMetadataKey(key))) { + output[key] = "[redacted]"; + continue; + } + output[key] = sanitizeMetadataValue(entry, depth + 1); + } + if (entries.length > MAX_OPERATIONAL_EVENT_OBJECT_KEYS) { + output._truncated = `${entries.length - MAX_OPERATIONAL_EVENT_OBJECT_KEYS} keys truncated`; + } + return output; +}; + +const sanitizeMetadata = (metadata?: Record | null) => { + if (!metadata) return null; + return sanitizeMetadataValue(metadata) as Record; +}; + +const serializeMetadata = ( + metadata: Record | null, + maxBytes: number +) => { + if (!metadata) return null; + const serialized = JSON.stringify(metadata); + if (serialized.length <= maxBytes) return serialized; + + const fallback = { + truncated: true, + originalLength: serialized.length, + preview: truncateString(serialized, Math.max(0, maxBytes - 96)), + }; + let fallbackSerialized = JSON.stringify(fallback); + while (fallbackSerialized.length > maxBytes && fallback.preview.length > 0) { + fallback.preview = fallback.preview.slice(0, -64); + fallbackSerialized = JSON.stringify(fallback); + } + return fallbackSerialized.length <= maxBytes + ? fallbackSerialized + : JSON.stringify({ truncated: true }); +}; + +const getMetadataReason = (metadata: Record | null) => + typeof metadata?.reason === "string" ? metadata.reason : null; + +const buildNoisyEventRateLimitKey = ({ + severity, + type, + organizationId, + addressId, + integrationId, + dispatchId, + message, + metadata, +}: Omit & { + metadata: Record | null; +}) => + [ + "operational-event", + type, + severity, + organizationId ?? "none", + addressId ?? "none", + integrationId ?? "none", + dispatchId ?? "none", + getMetadataReason(metadata) ?? "none", + message, + ].join(":"); + +const shouldRecordOperationalEvent = async ( + input: Omit & { + metadata: Record | null; + } +) => { + if (!NOISY_EVENT_TYPES.has(input.type)) return true; + if (!input.env.FIXED_WINDOW_RATE_LIMITERS) return false; + + try { + const rateLimit = await consumeFixedWindowRateLimit({ + namespace: input.env.FIXED_WINDOW_RATE_LIMITERS, + key: await hashForRateLimitKey(buildNoisyEventRateLimitKey(input)), + windowSeconds: getNoisyEventRateLimitWindowSeconds(input.env), + maxAttempts: getNoisyEventRateLimitMax(input.env), + }); + return rateLimit.allowed; + } catch { + return false; + } +}; + +export const recordOperationalEvent = async ({ + env, + severity, + type, + organizationId, + addressId, + emailId, + integrationId, + dispatchId, + message, + metadata, +}: OperationalEventInput) => { + const sanitizedMetadata = sanitizeMetadata(metadata); + const messageForStorage = truncateString( + message, + MAX_OPERATIONAL_EVENT_MESSAGE_LENGTH + ); + + if ( + !(await shouldRecordOperationalEvent({ + env, + severity, + type, + organizationId, + addressId, + emailId, + integrationId, + dispatchId, + message: messageForStorage, + metadata: sanitizedMetadata, + })) + ) { + return; + } + + await getDb(env) + .insert(operationalEvents) + .values({ + id: crypto.randomUUID(), + severity, + type, + organizationId: organizationId ?? null, + addressId: addressId ?? null, + emailId: emailId ?? null, + integrationId: integrationId ?? null, + dispatchId: dispatchId ?? null, + message: messageForStorage, + metadataJson: serializeMetadata( + sanitizedMetadata, + getOperationalEventMaxMetadataBytes(env) + ), + createdAt: new Date(), + }); +}; + +export const pruneOperationalEvents = async (env: CloudflareBindings) => { + const retentionMs = + getOperationalEventRetentionDays(env) * 24 * 60 * 60 * 1000; + const cutoffMs = Date.now() - retentionMs; + + await getDb(env).run( + sql` + delete from operational_events + where id in ( + select id from operational_events + where created_at < ${cutoffMs} + order by created_at asc + limit 1000 + ) + ` + ); +}; + +export const recordOperationalEventSafely = async ( + input: OperationalEventInput +) => { + try { + await recordOperationalEvent(input); + } catch (error) { + console.error("[admin] Failed to record operational event", { + type: input.type, + severity: input.severity, + error, + }); + } +}; diff --git a/packages/backend/src/modules/admin/repo.ts b/packages/backend/src/modules/admin/repo.ts new file mode 100644 index 00000000..d2bcc197 --- /dev/null +++ b/packages/backend/src/modules/admin/repo.ts @@ -0,0 +1,656 @@ +import { and, asc, count, desc, eq, gte, inArray, lt, sql } from "drizzle-orm"; +import { + emailAddresses, + emailAttachments, + emails, + integrationDispatches, + accounts, + apikeys, + invitations, + members, + operationalEvents, + organizationIntegrations, + organizations, + sessions, + users, +} from "@/db"; +import type { AppDb } from "@/platform/db/client"; +import type { + AdminOperationalEventSeverity, + AdminOperationalEventType, +} from "@spinupmail/contracts"; + +type DateRange = { + from: Date; + to: Date; +}; + +const ADMIN_AUDIT_EVENT_TYPES = [ + "admin_user_action", + "admin_session_action", + "admin_impersonation_started", +] as const satisfies readonly AdminOperationalEventType[]; + +export const findAdminUserDetail = async (db: AppDb, userId: string) => { + const [user, accountRows, membershipRows, apiKeyRows, recentEventRows] = + await Promise.all([ + db + .select({ + id: users.id, + name: users.name, + email: users.email, + emailVerified: users.emailVerified, + role: users.role, + banned: users.banned, + banReason: users.banReason, + banExpires: users.banExpires, + twoFactorEnabled: users.twoFactorEnabled, + timezone: users.timezone, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.id, userId)) + .get(), + db + .select({ + providerId: accounts.providerId, + createdAt: accounts.createdAt, + }) + .from(accounts) + .where(eq(accounts.userId, userId)) + .orderBy(desc(accounts.createdAt)), + db + .select({ + organizationId: members.organizationId, + organizationName: organizations.name, + organizationSlug: organizations.slug, + role: members.role, + createdAt: members.createdAt, + }) + .from(members) + .leftJoin(organizations, eq(organizations.id, members.organizationId)) + .where(eq(members.userId, userId)) + .orderBy(desc(members.createdAt)), + db + .select({ + id: apikeys.id, + name: apikeys.name, + start: apikeys.start, + prefix: apikeys.prefix, + enabled: apikeys.enabled, + requestCount: apikeys.requestCount, + remaining: apikeys.remaining, + rateLimitEnabled: apikeys.rateLimitEnabled, + rateLimitMax: apikeys.rateLimitMax, + rateLimitTimeWindow: apikeys.rateLimitTimeWindow, + lastRequest: apikeys.lastRequest, + expiresAt: apikeys.expiresAt, + createdAt: apikeys.createdAt, + metadata: apikeys.metadata, + }) + .from(apikeys) + .where(eq(apikeys.referenceId, userId)) + .orderBy(desc(apikeys.createdAt)), + db + .select({ + id: operationalEvents.id, + severity: operationalEvents.severity, + type: operationalEvents.type, + organizationId: operationalEvents.organizationId, + addressId: operationalEvents.addressId, + emailId: operationalEvents.emailId, + integrationId: operationalEvents.integrationId, + dispatchId: operationalEvents.dispatchId, + organizationName: organizations.name, + message: operationalEvents.message, + metadataJson: operationalEvents.metadataJson, + createdAt: operationalEvents.createdAt, + }) + .from(operationalEvents) + .leftJoin( + organizations, + eq(organizations.id, operationalEvents.organizationId) + ) + .where( + and( + inArray(operationalEvents.type, ADMIN_AUDIT_EVENT_TYPES), + sql`json_extract(${operationalEvents.metadataJson}, '$.targetId') = ${userId}` + ) + ) + .orderBy(desc(operationalEvents.createdAt)) + .limit(10), + ]); + + return { + user, + accounts: accountRows, + memberships: membershipRows, + apiKeys: apiKeyRows, + recentEvents: recentEventRows, + }; +}; + +export const findAdminOrganizationDetail = async ( + db: AppDb, + organizationId: string +) => { + const [ + organization, + memberRows, + invitationRows, + integrationRows, + apiKeyRows, + recentEventRows, + ] = await Promise.all([ + db + .select({ + id: organizations.id, + name: organizations.name, + slug: organizations.slug, + createdAt: organizations.createdAt, + metadata: organizations.metadata, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .get(), + db + .select({ + id: members.id, + userId: members.userId, + name: users.name, + email: users.email, + role: members.role, + createdAt: members.createdAt, + }) + .from(members) + .leftJoin(users, eq(users.id, members.userId)) + .where(eq(members.organizationId, organizationId)) + .orderBy(desc(members.createdAt)), + db + .select({ + id: invitations.id, + email: invitations.email, + role: invitations.role, + status: invitations.status, + expiresAt: invitations.expiresAt, + createdAt: invitations.createdAt, + }) + .from(invitations) + .where(eq(invitations.organizationId, organizationId)) + .orderBy(desc(invitations.createdAt)), + db + .select({ + id: organizationIntegrations.id, + provider: organizationIntegrations.provider, + name: organizationIntegrations.name, + status: organizationIntegrations.status, + lastValidatedAt: organizationIntegrations.lastValidatedAt, + createdAt: organizationIntegrations.createdAt, + updatedAt: organizationIntegrations.updatedAt, + }) + .from(organizationIntegrations) + .where(eq(organizationIntegrations.organizationId, organizationId)) + .orderBy(desc(organizationIntegrations.createdAt)), + db + .select({ + id: apikeys.id, + name: apikeys.name, + start: apikeys.start, + prefix: apikeys.prefix, + enabled: apikeys.enabled, + requestCount: apikeys.requestCount, + remaining: apikeys.remaining, + lastRequest: apikeys.lastRequest, + expiresAt: apikeys.expiresAt, + createdAt: apikeys.createdAt, + metadata: apikeys.metadata, + }) + .from(apikeys) + .where(eq(apikeys.referenceId, organizationId)) + .orderBy(desc(apikeys.createdAt)), + db + .select({ + id: operationalEvents.id, + severity: operationalEvents.severity, + type: operationalEvents.type, + organizationId: operationalEvents.organizationId, + addressId: operationalEvents.addressId, + emailId: operationalEvents.emailId, + integrationId: operationalEvents.integrationId, + dispatchId: operationalEvents.dispatchId, + organizationName: organizations.name, + message: operationalEvents.message, + metadataJson: operationalEvents.metadataJson, + createdAt: operationalEvents.createdAt, + }) + .from(operationalEvents) + .leftJoin( + organizations, + eq(organizations.id, operationalEvents.organizationId) + ) + .where(eq(operationalEvents.organizationId, organizationId)) + .orderBy(desc(operationalEvents.createdAt)) + .limit(10), + ]); + + return { + organization, + members: memberRows, + invitations: invitationRows, + integrations: integrationRows, + apiKeys: apiKeyRows, + recentEvents: recentEventRows, + }; +}; + +export const findAdminApiKeysPage = async ( + db: AppDb, + { page, pageSize }: Pagination +) => { + const offset = (page - 1) * pageSize; + const [items, totalRows] = await Promise.all([ + db + .select({ + id: apikeys.id, + name: apikeys.name, + start: apikeys.start, + prefix: apikeys.prefix, + referenceId: apikeys.referenceId, + enabled: apikeys.enabled, + requestCount: apikeys.requestCount, + remaining: apikeys.remaining, + rateLimitEnabled: apikeys.rateLimitEnabled, + rateLimitMax: apikeys.rateLimitMax, + rateLimitTimeWindow: apikeys.rateLimitTimeWindow, + lastRequest: apikeys.lastRequest, + expiresAt: apikeys.expiresAt, + createdAt: apikeys.createdAt, + metadata: apikeys.metadata, + userName: users.name, + userEmail: users.email, + organizationName: organizations.name, + organizationSlug: organizations.slug, + }) + .from(apikeys) + .leftJoin(users, eq(users.id, apikeys.referenceId)) + .leftJoin(organizations, eq(organizations.id, apikeys.referenceId)) + .orderBy(desc(apikeys.createdAt)) + .limit(pageSize) + .offset(offset), + db.select({ count: count() }).from(apikeys), + ]); + + return { + items, + totalItems: getFirstCount(totalRows), + }; +}; + +type Pagination = { + page: number; + pageSize: number; +}; + +type AnomalyFilters = { + severity?: AdminOperationalEventSeverity; + type?: AdminOperationalEventType; + organizationId?: string; + from?: Date; + to?: Date; +}; + +const getFirstCount = (rows: Array<{ count: number }>) => + Number(rows[0]?.count ?? 0) || 0; + +const countAddressRows = (db: AppDb, range: DateRange) => + db + .select({ count: sql`count(*)` }) + .from(emailAddresses) + .where( + and( + gte(emailAddresses.createdAt, range.from), + lt(emailAddresses.createdAt, range.to) + ) + ); + +const countEmailRows = (db: AppDb, range: DateRange, isSample: boolean) => + db + .select({ count: sql`count(*)` }) + .from(emails) + .where( + and( + eq(emails.isSample, isSample), + gte(emails.receivedAt, range.from), + lt(emails.receivedAt, range.to) + ) + ); + +const countActiveUsersSince = (db: AppDb, since: Date, now: Date) => + db + .select({ count: sql`count(distinct ${sessions.userId})` }) + .from(sessions) + .where(and(gte(sessions.updatedAt, since), gte(sessions.expiresAt, now))); + +export const findAdminOverviewStats = async ({ + db, + currentRange, + previousRange, + active24hSince, + active7dSince, + anomalySince, + now, +}: { + db: AppDb; + currentRange: DateRange; + previousRange: DateRange; + active24hSince: Date; + active7dSince: Date; + anomalySince: Date; + now: Date; +}) => { + const [ + generatedCurrentRows, + generatedPreviousRows, + receivedCurrentRows, + receivedPreviousRows, + sampleCurrentRows, + samplePreviousRows, + organizationRows, + userRows, + active24hRows, + active7dRows, + attachmentRows, + activeIntegrationRows, + retryDispatchRows, + failedDispatchRows, + anomalyRows, + errorAnomalyRows, + warningAnomalyRows, + ] = await Promise.all([ + countAddressRows(db, currentRange), + countAddressRows(db, previousRange), + countEmailRows(db, currentRange, false), + countEmailRows(db, previousRange, false), + countEmailRows(db, currentRange, true), + countEmailRows(db, previousRange, true), + db.select({ count: sql`count(*)` }).from(organizations), + db.select({ count: sql`count(*)` }).from(users), + countActiveUsersSince(db, active24hSince, now), + countActiveUsersSince(db, active7dSince, now), + db + .select({ + count: sql`count(*)`, + sizeTotal: sql`coalesce(sum(${emailAttachments.size}), 0)`, + }) + .from(emailAttachments), + db + .select({ count: sql`count(*)` }) + .from(organizationIntegrations) + .where(eq(organizationIntegrations.status, "active")), + db + .select({ count: sql`count(*)` }) + .from(integrationDispatches) + .where(eq(integrationDispatches.status, "retry_scheduled")), + db + .select({ count: sql`count(*)` }) + .from(integrationDispatches) + .where( + inArray(integrationDispatches.status, [ + "failed_permanent", + "failed_dlq", + ]) + ), + db + .select({ count: sql`count(*)` }) + .from(operationalEvents) + .where( + and( + gte(operationalEvents.createdAt, anomalySince), + inArray(operationalEvents.severity, ["warning", "error"]) + ) + ), + db + .select({ count: sql`count(*)` }) + .from(operationalEvents) + .where( + and( + eq(operationalEvents.severity, "error"), + gte(operationalEvents.createdAt, anomalySince) + ) + ), + db + .select({ count: sql`count(*)` }) + .from(operationalEvents) + .where( + and( + eq(operationalEvents.severity, "warning"), + gte(operationalEvents.createdAt, anomalySince) + ) + ), + ]); + + return { + generatedAddresses: { + current: getFirstCount(generatedCurrentRows), + previous: getFirstCount(generatedPreviousRows), + }, + receivedEmails: { + current: getFirstCount(receivedCurrentRows), + previous: getFirstCount(receivedPreviousRows), + }, + sampleEmails: { + current: getFirstCount(sampleCurrentRows), + previous: getFirstCount(samplePreviousRows), + }, + organizations: getFirstCount(organizationRows), + users: getFirstCount(userRows), + activeUsers24h: getFirstCount(active24hRows), + activeUsers7d: getFirstCount(active7dRows), + attachments: { + count: Number(attachmentRows[0]?.count ?? 0) || 0, + sizeTotal: Number(attachmentRows[0]?.sizeTotal ?? 0) || 0, + }, + integrations: { + active: getFirstCount(activeIntegrationRows), + retryScheduled: getFirstCount(retryDispatchRows), + failed: getFirstCount(failedDispatchRows), + }, + anomalies: { + last24h: getFirstCount(anomalyRows), + errorsLast24h: getFirstCount(errorAnomalyRows), + warningsLast24h: getFirstCount(warningAnomalyRows), + }, + }; +}; + +export const findAdminActivityRows = async ( + db: AppDb, + fromInclusive: Date, + toExclusive: Date +) => { + const addressMinuteExpr = sql`cast(${emailAddresses.createdAt} / 60000 as integer) * 60000`; + const emailMinuteExpr = sql`cast(${emails.receivedAt} / 60000 as integer) * 60000`; + + const [generatedAddressRows, receivedEmailRows] = await Promise.all([ + db + .select({ + minuteStartMs: addressMinuteExpr, + count: sql`count(*)`, + }) + .from(emailAddresses) + .where( + and( + gte(emailAddresses.createdAt, fromInclusive), + lt(emailAddresses.createdAt, toExclusive) + ) + ) + .groupBy(addressMinuteExpr) + .orderBy(asc(addressMinuteExpr)), + db + .select({ + minuteStartMs: emailMinuteExpr, + count: sql`count(*)`, + }) + .from(emails) + .where( + and( + eq(emails.isSample, false), + gte(emails.receivedAt, fromInclusive), + lt(emails.receivedAt, toExclusive) + ) + ) + .groupBy(emailMinuteExpr) + .orderBy(asc(emailMinuteExpr)), + ]); + + return { generatedAddressRows, receivedEmailRows }; +}; + +export const findAdminOrganizationsPage = async ( + db: AppDb, + { page, pageSize }: Pagination +) => { + const offset = (page - 1) * pageSize; + const [items, totalRows] = await Promise.all([ + db + .select({ + id: organizations.id, + name: organizations.name, + slug: organizations.slug, + createdAt: organizations.createdAt, + }) + .from(organizations) + .orderBy(desc(organizations.createdAt)) + .limit(pageSize) + .offset(offset), + db.select({ count: count() }).from(organizations), + ]); + + return { + items, + totalItems: getFirstCount(totalRows), + }; +}; + +export const findAdminOrganizationRollups = async ( + db: AppDb, + organizationIds: string[] +) => { + if (organizationIds.length === 0) { + return { + memberRows: [], + addressRows: [], + emailRows: [], + integrationRows: [], + }; + } + + const [memberRows, addressRows, emailRows, integrationRows] = + await Promise.all([ + db + .select({ + organizationId: members.organizationId, + count: sql`count(*)`, + }) + .from(members) + .where(inArray(members.organizationId, organizationIds)) + .groupBy(members.organizationId), + db + .select({ + organizationId: emailAddresses.organizationId, + count: sql`count(*)`, + lastReceivedAt: sql`max(${emailAddresses.lastReceivedAt})`, + }) + .from(emailAddresses) + .where(inArray(emailAddresses.organizationId, organizationIds)) + .groupBy(emailAddresses.organizationId), + db + .select({ + organizationId: emailAddresses.organizationId, + receivedCount: sql`sum(case when ${emails.isSample} = 0 then 1 else 0 end)`, + sampleCount: sql`sum(case when ${emails.isSample} = 1 then 1 else 0 end)`, + }) + .from(emails) + .innerJoin(emailAddresses, eq(emails.addressId, emailAddresses.id)) + .where(inArray(emailAddresses.organizationId, organizationIds)) + .groupBy(emailAddresses.organizationId), + db + .select({ + organizationId: organizationIntegrations.organizationId, + count: sql`count(*)`, + activeCount: sql`sum(case when ${organizationIntegrations.status} = 'active' then 1 else 0 end)`, + }) + .from(organizationIntegrations) + .where( + inArray(organizationIntegrations.organizationId, organizationIds) + ) + .groupBy(organizationIntegrations.organizationId), + ]); + + return { memberRows, addressRows, emailRows, integrationRows }; +}; + +const buildAnomalyWhere = (filters: AnomalyFilters) => { + const conditions = []; + if (filters.severity) { + conditions.push(eq(operationalEvents.severity, filters.severity)); + } + if (filters.type) { + conditions.push(eq(operationalEvents.type, filters.type)); + } + if (filters.organizationId) { + conditions.push( + eq(operationalEvents.organizationId, filters.organizationId) + ); + } + if (filters.from) { + conditions.push(gte(operationalEvents.createdAt, filters.from)); + } + if (filters.to) { + conditions.push(lt(operationalEvents.createdAt, filters.to)); + } + return conditions.length > 0 ? and(...conditions) : undefined; +}; + +export const findAdminOperationalEventsPage = async ( + db: AppDb, + pagination: Pagination, + filters: AnomalyFilters +) => { + const offset = (pagination.page - 1) * pagination.pageSize; + const where = buildAnomalyWhere(filters); + + const [items, totalRows] = await Promise.all([ + db + .select({ + id: operationalEvents.id, + severity: operationalEvents.severity, + type: operationalEvents.type, + organizationId: operationalEvents.organizationId, + addressId: operationalEvents.addressId, + emailId: operationalEvents.emailId, + integrationId: operationalEvents.integrationId, + dispatchId: operationalEvents.dispatchId, + organizationName: organizations.name, + message: operationalEvents.message, + metadataJson: operationalEvents.metadataJson, + createdAt: operationalEvents.createdAt, + }) + .from(operationalEvents) + .leftJoin( + organizations, + eq(organizations.id, operationalEvents.organizationId) + ) + .where(where) + .orderBy(desc(operationalEvents.createdAt)) + .limit(pagination.pageSize) + .offset(offset), + db.select({ count: count() }).from(operationalEvents).where(where), + ]); + + return { + items, + totalItems: getFirstCount(totalRows), + }; +}; diff --git a/packages/backend/src/modules/admin/router.ts b/packages/backend/src/modules/admin/router.ts new file mode 100644 index 00000000..f6ab4367 --- /dev/null +++ b/packages/backend/src/modules/admin/router.ts @@ -0,0 +1,229 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { + adminRecordAuditEventRequestSchema, + adminUserActionRequestSchema, +} from "@spinupmail/contracts"; +import type { AppHonoEnv } from "@/app/types"; +import { + adminActivityQuerySchema, + adminAnomaliesQuerySchema, + adminPaginationQuerySchema, +} from "./schemas"; +import { + getAdminActivity, + getAdminApiKeys, + getAdminActionErrorResponse, + getAdminOrganizationDetail, + getAdminOperationalEvents, + getAdminOrganizations, + getAdminOverview, + getAdminUserDetail, + performAdminUserAction, + recordAdminAuditEvent, +} from "./service"; + +const idParamSchema = z.object({ + id: z.string().min(1), +}); + +export const createAdminRouter = () => { + const router = new Hono(); + + router.get("/admin/overview", async c => { + const result = await getAdminOverview(c.env); + return c.json(result, 200, { + "Cache-Control": "private, max-age=30", + }); + }); + + router.get( + "/admin/activity", + zValidator("query", adminActivityQuerySchema, (result, c) => { + if (!result.success) return c.json({ error: "invalid admin query" }, 400); + return undefined; + }), + async c => { + const query = c.req.valid("query"); + const result = await getAdminActivity({ + env: c.env, + daysRaw: query.days, + timezoneRaw: query.timezone, + }); + return c.json(result.body, result.status, { + "Cache-Control": "private, max-age=30", + }); + } + ); + + router.get( + "/admin/organizations", + zValidator("query", adminPaginationQuerySchema, (result, c) => { + if (!result.success) return c.json({ error: "invalid admin query" }, 400); + return undefined; + }), + async c => { + const query = c.req.valid("query"); + const result = await getAdminOrganizations({ + env: c.env, + pageRaw: query.page, + pageSizeRaw: query.pageSize, + }); + return c.json(result, 200, { + "Cache-Control": "private, max-age=30", + }); + } + ); + + router.get( + "/admin/users/:id", + zValidator("param", idParamSchema, (result, c) => { + if (!result.success) return c.json({ error: "invalid admin user" }, 400); + return undefined; + }), + async c => { + const { id } = c.req.valid("param"); + const result = await getAdminUserDetail({ env: c.env, userId: id }); + return c.json(result.body, result.status, { + "Cache-Control": "private, max-age=15", + }); + } + ); + + router.get( + "/admin/organizations/:id", + zValidator("param", idParamSchema, (result, c) => { + if (!result.success) + return c.json({ error: "invalid admin organization" }, 400); + return undefined; + }), + async c => { + const { id } = c.req.valid("param"); + const result = await getAdminOrganizationDetail({ + env: c.env, + organizationId: id, + }); + return c.json(result.body, result.status, { + "Cache-Control": "private, max-age=15", + }); + } + ); + + router.get( + "/admin/api-keys", + zValidator("query", adminPaginationQuerySchema, (result, c) => { + if (!result.success) return c.json({ error: "invalid admin query" }, 400); + return undefined; + }), + async c => { + const query = c.req.valid("query"); + const result = await getAdminApiKeys({ + env: c.env, + pageRaw: query.page, + pageSizeRaw: query.pageSize, + }); + return c.json(result, 200, { + "Cache-Control": "private, max-age=15", + }); + } + ); + + router.get( + "/admin/anomalies", + zValidator("query", adminAnomaliesQuerySchema, (result, c) => { + if (!result.success) return c.json({ error: "invalid admin query" }, 400); + return undefined; + }), + async c => { + const query = c.req.valid("query"); + const result = await getAdminOperationalEvents({ + env: c.env, + pageRaw: query.page, + pageSizeRaw: query.pageSize, + severity: query.severity, + type: query.type, + organizationId: query.organizationId, + fromRaw: query.from, + toRaw: query.to, + }); + return c.json(result, 200, { + "Cache-Control": "private, max-age=30", + }); + } + ); + + router.post( + "/admin/audit-events", + zValidator("json", adminRecordAuditEventRequestSchema, (result, c) => { + if (!result.success) + return c.json({ error: "invalid admin audit event" }, 400); + return undefined; + }), + async c => { + const session = c.get("session"); + const input = c.req.valid("json"); + const result = await recordAdminAuditEvent({ + env: c.env, + actorUserId: session.user.id, + actorEmail: + typeof session.user.email === "string" ? session.user.email : null, + input, + }); + return c.json(result, 201); + } + ); + + router.post( + "/admin/user-actions", + zValidator("json", adminUserActionRequestSchema, (result, c) => { + if (!result.success) + return c.json({ error: "invalid admin user action" }, 400); + return undefined; + }), + async c => { + const auth = c.get("auth"); + const session = c.get("session"); + const input = c.req.valid("json"); + + try { + const result = await performAdminUserAction({ + env: c.env, + runImpersonation: + input.action === "impersonate" + ? () => { + const url = new URL(c.req.url); + url.pathname = "/api/auth/admin/impersonate-user"; + url.search = ""; + const headers = new Headers(c.req.raw.headers); + headers.set("Content-Type", "application/json"); + return auth.handler( + new Request(url, { + method: "POST", + headers, + body: JSON.stringify({ userId: input.userId }), + }) + ); + } + : undefined, + actorUserId: session.user.id, + actorEmail: + typeof session.user.email === "string" ? session.user.email : null, + actorRole: session.user.role, + input, + }); + if (result instanceof Response) return result; + return c.json(result, 200); + } catch (error) { + const response = getAdminActionErrorResponse(error); + if (response) { + if ("response" in response) return response.response; + return c.json(response.body, response.status); + } + throw error; + } + } + ); + + return router; +}; diff --git a/packages/backend/src/modules/admin/schemas.ts b/packages/backend/src/modules/admin/schemas.ts new file mode 100644 index 00000000..c2ec036a --- /dev/null +++ b/packages/backend/src/modules/admin/schemas.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { + adminOperationalEventSeveritySchema, + adminOperationalEventTypeSchema, +} from "@spinupmail/contracts"; + +export const adminActivityQuerySchema = z.object({ + days: z.coerce.number().int().min(1).max(30).optional(), + timezone: z.string().min(1).max(128).optional(), +}); + +export const adminPaginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).optional(), + pageSize: z.coerce.number().int().min(1).max(100).optional(), +}); + +export const adminAnomaliesQuerySchema = adminPaginationQuerySchema.extend({ + severity: adminOperationalEventSeveritySchema.optional(), + type: adminOperationalEventTypeSchema.optional(), + organizationId: z.string().min(1).optional(), + from: z.iso.datetime({ local: true }).optional(), + to: z.iso.datetime({ local: true }).optional(), +}); diff --git a/packages/backend/src/modules/admin/service.ts b/packages/backend/src/modules/admin/service.ts new file mode 100644 index 00000000..3675fd24 --- /dev/null +++ b/packages/backend/src/modules/admin/service.ts @@ -0,0 +1,775 @@ +import type { + AdminActivityResponse, + AdminApiKeysResponse, + AdminOperationalEvent, + AdminOperationalEventSeverity, + AdminOperationalEventType, + AdminOrganizationDetailResponse, + AdminOrganizationItem, + AdminOrganizationsResponse, + AdminOverviewResponse, + AdminRecordAuditEventRequest, + AdminUserActionRequest, + AdminUserDetailResponse, + PlatformRole, +} from "@spinupmail/contracts"; +import { eq } from "drizzle-orm"; +import { sessions, users } from "@/db"; +import { getDb } from "@/platform/db/client"; +import { + buildTimeZonedDailyCounts, + getRecentDayKeys, + resolveRequestedTimeZone, +} from "@/modules/organizations/service"; +import { + findAdminActivityRows, + findAdminApiKeysPage, + findAdminOperationalEventsPage, + findAdminOrganizationDetail, + findAdminOrganizationRollups, + findAdminOrganizationsPage, + findAdminOverviewStats, + findAdminUserDetail, +} from "./repo"; +import { recordOperationalEvent } from "./operational-events"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const ACTIVITY_QUERY_BUFFER_MS = 5 * 60 * 1000; +const ACTIVITY_WINDOW_SAFETY_DAYS = 2; +const OVERVIEW_WINDOW_DAYS = 30; +const ACTIVE_USER_24H_MS = DAY_MS; +const ACTIVE_USER_7D_MS = 7 * DAY_MS; +const ANOMALY_WINDOW_MS = DAY_MS; + +const clampPagination = ({ + pageRaw, + pageSizeRaw, +}: { + pageRaw?: number; + pageSizeRaw?: number; +}) => ({ + page: pageRaw && Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : 1, + pageSize: + pageSizeRaw && Number.isInteger(pageSizeRaw) && pageSizeRaw > 0 + ? Math.min(pageSizeRaw, 100) + : 20, +}); + +const toIsoString = (value: unknown): string | null => { + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + if (typeof value === "number" && Number.isFinite(value)) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + if (typeof value === "string" && value.trim()) { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); + } + return null; +}; + +const getTotalPages = (totalItems: number, pageSize: number) => + totalItems === 0 ? 0 : Math.ceil(totalItems / pageSize); + +const getSystemStatus = ({ + errorsLast24h, + warningsLast24h, + failedIntegrations, + retryScheduled, +}: { + errorsLast24h: number; + warningsLast24h: number; + failedIntegrations: number; + retryScheduled: number; +}): AdminOverviewResponse["system"]["status"] => { + if (errorsLast24h > 0 || failedIntegrations > 0) return "critical"; + if (warningsLast24h > 0 || retryScheduled > 0) return "warning"; + return "healthy"; +}; + +const parseMetadata = ( + value: string | null +): Record | null => { + if (!value) return null; + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +}; + +const parseRecord = (value: unknown): Record | null => { + if (!value) return null; + if (typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + if (typeof value !== "string") return null; + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +}; + +const mapOperationalEvent = (item: { + id: string; + severity: string; + type: string; + organizationId: string | null; + addressId: string | null; + emailId: string | null; + integrationId: string | null; + dispatchId: string | null; + organizationName: string | null; + message: string; + metadataJson: string | null; + createdAt: unknown; +}): AdminOperationalEvent => ({ + id: item.id, + severity: item.severity as AdminOperationalEventSeverity, + type: item.type as AdminOperationalEventType, + organizationId: item.organizationId ?? null, + addressId: item.addressId ?? null, + emailId: item.emailId ?? null, + integrationId: item.integrationId ?? null, + dispatchId: item.dispatchId ?? null, + organizationName: item.organizationName ?? null, + message: item.message, + metadata: parseMetadata(item.metadataJson), + createdAt: toIsoString(item.createdAt), +}); + +export const getAdminOverview = async ( + env: CloudflareBindings +): Promise => { + const now = new Date(); + const currentRange = { + from: new Date(now.getTime() - OVERVIEW_WINDOW_DAYS * DAY_MS), + to: now, + }; + const previousRange = { + from: new Date(now.getTime() - OVERVIEW_WINDOW_DAYS * 2 * DAY_MS), + to: currentRange.from, + }; + const stats = await findAdminOverviewStats({ + db: getDb(env), + currentRange, + previousRange, + active24hSince: new Date(now.getTime() - ACTIVE_USER_24H_MS), + active7dSince: new Date(now.getTime() - ACTIVE_USER_7D_MS), + anomalySince: new Date(now.getTime() - ANOMALY_WINDOW_MS), + now, + }); + + return { + ...stats, + system: { + status: getSystemStatus({ + errorsLast24h: stats.anomalies.errorsLast24h, + warningsLast24h: stats.anomalies.warningsLast24h, + failedIntegrations: stats.integrations.failed, + retryScheduled: stats.integrations.retryScheduled, + }), + checkedAt: now.toISOString(), + }, + }; +}; + +export const getAdminActivity = async ({ + env, + daysRaw, + timezoneRaw, +}: { + env: CloudflareBindings; + daysRaw?: number; + timezoneRaw?: string; +}): Promise< + | { status: 200; body: AdminActivityResponse } + | { status: 400; body: { error: string } } +> => { + const days = daysRaw ?? 14; + const timezoneResult = resolveRequestedTimeZone(timezoneRaw ?? null); + if (!timezoneResult.ok) { + return { status: 400, body: { error: timezoneResult.error } }; + } + + const now = new Date(); + const dayKeys = getRecentDayKeys({ + days, + now, + timeZone: timezoneResult.timezone, + }); + const fromInclusive = new Date( + now.getTime() - (days + ACTIVITY_WINDOW_SAFETY_DAYS) * DAY_MS + ); + const toExclusive = new Date(now.getTime() + ACTIVITY_QUERY_BUFFER_MS); + const { generatedAddressRows, receivedEmailRows } = + await findAdminActivityRows(getDb(env), fromInclusive, toExclusive); + const generatedDaily = buildTimeZonedDailyCounts({ + dayKeys, + minuteRows: generatedAddressRows, + timeZone: timezoneResult.timezone, + }); + const receivedDaily = buildTimeZonedDailyCounts({ + dayKeys, + minuteRows: receivedEmailRows, + timeZone: timezoneResult.timezone, + }); + const generatedByDay = new Map( + generatedDaily.map(item => [item.date, item.count]) + ); + const receivedByDay = new Map( + receivedDaily.map(item => [item.date, item.count]) + ); + + return { + status: 200, + body: { + timezone: timezoneResult.timezone, + daily: dayKeys.map(date => ({ + date, + generatedAddresses: generatedByDay.get(date) ?? 0, + receivedEmails: receivedByDay.get(date) ?? 0, + })), + }, + }; +}; + +export const getAdminOrganizations = async ({ + env, + pageRaw, + pageSizeRaw, +}: { + env: CloudflareBindings; + pageRaw?: number; + pageSizeRaw?: number; +}): Promise => { + const pagination = clampPagination({ pageRaw, pageSizeRaw }); + const page = await findAdminOrganizationsPage(getDb(env), pagination); + const organizationIds = page.items.map(item => item.id); + const rollups = await findAdminOrganizationRollups( + getDb(env), + organizationIds + ); + const memberCountByOrgId = new Map( + rollups.memberRows.map(row => [row.organizationId, Number(row.count) || 0]) + ); + const addressRollupByOrgId = new Map( + rollups.addressRows + .filter(row => row.organizationId) + .map(row => [ + String(row.organizationId), + { + count: Number(row.count) || 0, + lastReceivedAt: row.lastReceivedAt, + }, + ]) + ); + const emailRollupByOrgId = new Map( + rollups.emailRows + .filter(row => row.organizationId) + .map(row => [ + String(row.organizationId), + { + receivedCount: Number(row.receivedCount) || 0, + sampleCount: Number(row.sampleCount) || 0, + }, + ]) + ); + const integrationRollupByOrgId = new Map( + rollups.integrationRows.map(row => [ + row.organizationId, + { + count: Number(row.count) || 0, + activeCount: Number(row.activeCount) || 0, + }, + ]) + ); + + const items: AdminOrganizationItem[] = page.items.map(item => { + const addressRollup = addressRollupByOrgId.get(item.id); + const emailRollup = emailRollupByOrgId.get(item.id); + const integrationRollup = integrationRollupByOrgId.get(item.id); + + return { + id: item.id, + name: item.name, + slug: item.slug, + createdAt: toIsoString(item.createdAt), + memberCount: memberCountByOrgId.get(item.id) ?? 0, + addressCount: addressRollup?.count ?? 0, + receivedEmailCount: emailRollup?.receivedCount ?? 0, + sampleEmailCount: emailRollup?.sampleCount ?? 0, + integrationCount: integrationRollup?.count ?? 0, + activeIntegrationCount: integrationRollup?.activeCount ?? 0, + lastReceivedAt: toIsoString(addressRollup?.lastReceivedAt), + }; + }); + + return { + items, + page: pagination.page, + pageSize: pagination.pageSize, + totalItems: page.totalItems, + totalPages: getTotalPages(page.totalItems, pagination.pageSize), + }; +}; + +export const getAdminOperationalEvents = async ({ + env, + pageRaw, + pageSizeRaw, + severity, + type, + organizationId, + fromRaw, + toRaw, +}: { + env: CloudflareBindings; + pageRaw?: number; + pageSizeRaw?: number; + severity?: AdminOperationalEventSeverity; + type?: AdminOperationalEventType; + organizationId?: string; + fromRaw?: string; + toRaw?: string; +}) => { + const pagination = clampPagination({ pageRaw, pageSizeRaw }); + const from = fromRaw ? new Date(fromRaw) : undefined; + const to = toRaw ? new Date(toRaw) : undefined; + const page = await findAdminOperationalEventsPage(getDb(env), pagination, { + severity, + type, + organizationId, + from, + to, + }); + const items: AdminOperationalEvent[] = page.items.map(mapOperationalEvent); + + return { + items, + page: pagination.page, + pageSize: pagination.pageSize, + totalItems: page.totalItems, + totalPages: getTotalPages(page.totalItems, pagination.pageSize), + }; +}; + +export const getAdminUserDetail = async ({ + env, + userId, +}: { + env: CloudflareBindings; + userId: string; +}): Promise< + | { status: 200; body: AdminUserDetailResponse } + | { status: 404; body: { error: string } } +> => { + const detail = await findAdminUserDetail(getDb(env), userId); + if (!detail.user) return { status: 404, body: { error: "user not found" } }; + + return { + status: 200, + body: { + user: { + id: detail.user.id, + name: detail.user.name ?? null, + email: detail.user.email, + emailVerified: detail.user.emailVerified === true, + role: detail.user.role ?? null, + banned: detail.user.banned ?? null, + banReason: detail.user.banReason ?? null, + banExpires: toIsoString(detail.user.banExpires), + twoFactorEnabled: detail.user.twoFactorEnabled ?? null, + timezone: detail.user.timezone ?? null, + createdAt: toIsoString(detail.user.createdAt), + updatedAt: toIsoString(detail.user.updatedAt), + }, + accounts: detail.accounts.map(account => ({ + providerId: account.providerId, + createdAt: toIsoString(account.createdAt), + })), + memberships: detail.memberships.map(membership => ({ + organizationId: membership.organizationId, + organizationName: membership.organizationName ?? null, + organizationSlug: membership.organizationSlug ?? null, + role: membership.role, + createdAt: toIsoString(membership.createdAt), + })), + apiKeys: detail.apiKeys.map(key => ({ + id: key.id, + name: key.name ?? null, + start: key.start ?? null, + prefix: key.prefix ?? null, + enabled: key.enabled ?? null, + requestCount: Number(key.requestCount ?? 0) || 0, + remaining: key.remaining ?? null, + rateLimitEnabled: key.rateLimitEnabled ?? null, + rateLimitMax: key.rateLimitMax ?? null, + rateLimitTimeWindow: key.rateLimitTimeWindow ?? null, + lastRequest: toIsoString(key.lastRequest), + expiresAt: toIsoString(key.expiresAt), + createdAt: toIsoString(key.createdAt), + metadata: parseRecord(key.metadata), + })), + recentEvents: detail.recentEvents.map(mapOperationalEvent), + }, + }; +}; + +export const getAdminOrganizationDetail = async ({ + env, + organizationId, +}: { + env: CloudflareBindings; + organizationId: string; +}): Promise< + | { status: 200; body: AdminOrganizationDetailResponse } + | { status: 404; body: { error: string } } +> => { + const detail = await findAdminOrganizationDetail(getDb(env), organizationId); + if (!detail.organization) { + return { status: 404, body: { error: "organization not found" } }; + } + + const rollups = await findAdminOrganizationRollups(getDb(env), [ + organizationId, + ]); + const addressRollup = rollups.addressRows[0]; + const emailRollup = rollups.emailRows[0]; + const integrationRollup = rollups.integrationRows[0]; + const memberRollup = rollups.memberRows[0]; + + return { + status: 200, + body: { + organization: { + id: detail.organization.id, + name: detail.organization.name, + slug: detail.organization.slug, + createdAt: toIsoString(detail.organization.createdAt), + metadata: parseRecord(detail.organization.metadata), + memberCount: Number(memberRollup?.count ?? 0) || 0, + addressCount: Number(addressRollup?.count ?? 0) || 0, + receivedEmailCount: Number(emailRollup?.receivedCount ?? 0) || 0, + sampleEmailCount: Number(emailRollup?.sampleCount ?? 0) || 0, + integrationCount: Number(integrationRollup?.count ?? 0) || 0, + activeIntegrationCount: + Number(integrationRollup?.activeCount ?? 0) || 0, + lastReceivedAt: toIsoString(addressRollup?.lastReceivedAt), + }, + members: detail.members.map(member => ({ + id: member.id, + userId: member.userId, + name: member.name ?? null, + email: member.email ?? null, + role: member.role, + createdAt: toIsoString(member.createdAt), + })), + invitations: detail.invitations.map(invitation => ({ + id: invitation.id, + email: invitation.email, + role: invitation.role ?? null, + status: invitation.status, + expiresAt: toIsoString(invitation.expiresAt), + createdAt: toIsoString(invitation.createdAt), + })), + integrations: detail.integrations.map(integration => ({ + id: integration.id, + provider: integration.provider, + name: integration.name, + status: integration.status, + lastValidatedAt: toIsoString(integration.lastValidatedAt), + createdAt: toIsoString(integration.createdAt), + updatedAt: toIsoString(integration.updatedAt), + })), + apiKeys: detail.apiKeys.map(key => ({ + id: key.id, + name: key.name ?? null, + start: key.start ?? null, + prefix: key.prefix ?? null, + enabled: key.enabled ?? null, + requestCount: Number(key.requestCount ?? 0) || 0, + remaining: key.remaining ?? null, + lastRequest: toIsoString(key.lastRequest), + expiresAt: toIsoString(key.expiresAt), + createdAt: toIsoString(key.createdAt), + metadata: parseRecord(key.metadata), + })), + recentEvents: detail.recentEvents.map(mapOperationalEvent), + }, + }; +}; + +export const getAdminApiKeys = async ({ + env, + pageRaw, + pageSizeRaw, +}: { + env: CloudflareBindings; + pageRaw?: number; + pageSizeRaw?: number; +}): Promise => { + const pagination = clampPagination({ pageRaw, pageSizeRaw }); + const page = await findAdminApiKeysPage(getDb(env), pagination); + + return { + items: page.items.map(item => { + const ownerType = item.userEmail + ? "user" + : item.organizationName + ? "organization" + : "unknown"; + const ownerLabel = + item.userEmail ?? + item.organizationName ?? + item.organizationSlug ?? + null; + + return { + id: item.id, + name: item.name ?? null, + start: item.start ?? null, + prefix: item.prefix ?? null, + referenceId: item.referenceId, + ownerType, + ownerLabel, + enabled: item.enabled ?? null, + requestCount: Number(item.requestCount ?? 0) || 0, + remaining: item.remaining ?? null, + rateLimitEnabled: item.rateLimitEnabled ?? null, + rateLimitMax: item.rateLimitMax ?? null, + rateLimitTimeWindow: item.rateLimitTimeWindow ?? null, + lastRequest: toIsoString(item.lastRequest), + expiresAt: toIsoString(item.expiresAt), + createdAt: toIsoString(item.createdAt), + metadata: parseRecord(item.metadata), + }; + }), + page: pagination.page, + pageSize: pagination.pageSize, + totalItems: page.totalItems, + totalPages: getTotalPages(page.totalItems, pagination.pageSize), + }; +}; + +export const recordAdminAuditEvent = async ({ + env, + actorUserId, + actorEmail, + input, +}: { + env: CloudflareBindings; + actorUserId: string; + actorEmail?: string | null; + input: AdminRecordAuditEventRequest; +}) => { + await recordOperationalEvent({ + env, + severity: "info", + type: + input.targetType === "session" + ? "admin_session_action" + : input.action === "impersonate-user" + ? "admin_impersonation_started" + : "admin_user_action", + organizationId: input.organizationId ?? null, + message: input.message, + metadata: { + ...(input.metadata ?? {}), + actorUserId, + actorEmail: actorEmail ?? null, + action: input.action, + targetType: input.targetType, + targetId: input.targetId ?? null, + reason: input.reason ?? null, + }, + }); + + return { ok: true }; +}; + +const roleIncludes = (role: unknown, expected: PlatformRole) => { + if (Array.isArray(role)) { + return role.some(value => String(value).trim() === expected); + } + if (typeof role !== "string") return false; + return role.split(",").some(part => part.trim() === expected); +}; + +const requireAdminActionPermission = ({ + actorRole, +}: { + actorRole: unknown; +}) => { + if (roleIncludes(actorRole, "admin")) return; + + throw new AdminActionError(403, "forbidden"); +}; + +class AdminActionError extends Error { + constructor( + readonly status: 400 | 403 | 404 | 500, + message: string + ) { + super(message); + } +} + +class AdminActionResponse extends Error { + constructor(readonly response: Response) { + super("admin action response"); + } +} + +const getAdminActionMessage = ( + input: AdminUserActionRequest, + targetEmail: string | null +) => { + const label = targetEmail ?? "user"; + if (input.action === "set-role") return `Set ${label} role to ${input.role}.`; + if (input.action === "ban") return `Banned ${label}.`; + if (input.action === "unban") return `Unbanned ${label}.`; + if (input.action === "impersonate") + return `Started impersonation for ${label}.`; + if (input.action === "revoke-session") + return `Revoked one session for ${label}.`; + return `Revoked sessions for ${label}.`; +}; + +const getAdminActionAuditType = ( + input: AdminUserActionRequest +): AdminRecordAuditEventRequest["targetType"] => + input.action === "revoke-session" || input.action === "revoke-sessions" + ? "session" + : "user"; + +const getAdminActionEventType = ( + input: AdminUserActionRequest +): AdminOperationalEventType => + input.action === "impersonate" + ? "admin_impersonation_started" + : input.action === "revoke-session" || input.action === "revoke-sessions" + ? "admin_session_action" + : "admin_user_action"; + +export const performAdminUserAction = async ({ + env, + runImpersonation, + actorUserId, + actorEmail, + actorRole, + input, +}: { + env: CloudflareBindings; + runImpersonation?: () => Promise; + actorUserId: string; + actorEmail?: string | null; + actorRole: unknown; + input: AdminUserActionRequest; +}) => { + requireAdminActionPermission({ actorRole }); + + const db = getDb(env); + const targetUser = await db + .select({ id: users.id, email: users.email }) + .from(users) + .where(eq(users.id, input.userId)) + .get(); + + if (!targetUser) throw new AdminActionError(404, "user not found"); + + let actionResponse: Response | null = null; + + if (input.action === "set-role") { + await db + .update(users) + .set({ role: input.role, updatedAt: new Date() }) + .where(eq(users.id, input.userId)); + } else if (input.action === "ban") { + if (input.userId === actorUserId) { + throw new AdminActionError(400, "cannot ban yourself"); + } + await db + .update(users) + .set({ + banned: true, + banReason: input.reason?.trim() || "Administrative action", + updatedAt: new Date(), + }) + .where(eq(users.id, input.userId)); + await db.delete(sessions).where(eq(sessions.userId, input.userId)); + } else if (input.action === "unban") { + await db + .update(users) + .set({ + banned: false, + banReason: null, + banExpires: null, + updatedAt: new Date(), + }) + .where(eq(users.id, input.userId)); + } else if (input.action === "revoke-sessions") { + await db.delete(sessions).where(eq(sessions.userId, input.userId)); + } else if (input.action === "revoke-session") { + const session = await db + .select({ userId: sessions.userId }) + .from(sessions) + .where(eq(sessions.token, input.sessionToken)) + .get(); + if (session?.userId && session.userId !== input.userId) { + throw new AdminActionError(400, "session does not belong to user"); + } + await db.delete(sessions).where(eq(sessions.token, input.sessionToken)); + } else { + const impersonationResponse = await runImpersonation?.(); + if (!impersonationResponse) { + throw new AdminActionError(500, "unable to impersonate user"); + } + if (!impersonationResponse.ok) { + throw new AdminActionResponse(impersonationResponse); + } + actionResponse = impersonationResponse; + } + + await recordOperationalEvent({ + env, + severity: "info", + type: getAdminActionEventType(input), + message: getAdminActionMessage(input, targetUser.email), + metadata: { + ...(input.action === "set-role" ? { role: input.role } : {}), + actorUserId, + actorEmail: actorEmail ?? null, + action: input.action, + targetType: getAdminActionAuditType(input), + targetId: input.userId, + reason: input.reason?.trim() || null, + }, + }); + + return actionResponse ?? { ok: true }; +}; + +export const getAdminActionErrorResponse = (error: unknown) => { + if (error instanceof AdminActionResponse) { + return { response: error.response }; + } + if (error instanceof AdminActionError) { + return { + status: error.status, + body: { error: error.message }, + }; + } + return null; +}; diff --git a/packages/backend/src/modules/inbound-email/handler.ts b/packages/backend/src/modules/inbound-email/handler.ts index d53acd7f..de609bb1 100644 --- a/packages/backend/src/modules/inbound-email/handler.ts +++ b/packages/backend/src/modules/inbound-email/handler.ts @@ -2,6 +2,7 @@ import type { ExecutionContext, ForwardableEmailMessage, } from "@cloudflare/workers-types"; +import { recordOperationalEventSafely } from "@/modules/admin/operational-events"; import { dispatchEmailReceivedEvent } from "@/modules/integrations/service"; import { getDb } from "@/platform/db/client"; import { @@ -146,12 +147,23 @@ export const handleIncomingEmail = async ( rawTruncated, ...extra, }); + const trackOperationalEvent = ( + input: Omit[0], "env"> + ) => { + ctx.waitUntil(recordOperationalEventSafely({ env, ...input })); + }; try { const recipient = normalizeAddress(message.to); const atIndex = recipient.lastIndexOf("@"); if (atIndex === -1) { + trackOperationalEvent({ + severity: "warning", + type: "inbound_rejected", + message: "Inbound email rejected because the recipient was invalid", + metadata: { reason: "invalid_recipient" }, + }); message.setReject("Invalid recipient address"); return; } @@ -160,12 +172,26 @@ export const handleIncomingEmail = async ( const addressRow = await findAddressByRecipient(db, recipient); if (!addressRow) { + trackOperationalEvent({ + severity: "info", + type: "inbound_rejected", + message: "Inbound email rejected because the address is not registered", + metadata: { reason: "address_not_registered" }, + }); message.setReject("Address not registered"); return; } const availability = validateAddressAvailability(addressRow); if (!availability.allowed) { + trackOperationalEvent({ + severity: "info", + type: "inbound_rejected", + organizationId: addressRow.organizationId, + addressId: addressRow.id, + message: "Inbound email rejected because the address is unavailable", + metadata: { reason: availability.reason }, + }); message.setReject(availability.reason); return; } @@ -186,6 +212,14 @@ export const handleIncomingEmail = async ( "[email] Duplicate inbound delivery skipped", logFields() ); + trackOperationalEvent({ + severity: "info", + type: "inbound_duplicate", + organizationId, + addressId: addressRow.id, + message: "Duplicate inbound email delivery skipped", + metadata: { messageId }, + }); return; } } @@ -206,6 +240,18 @@ export const handleIncomingEmail = async ( allowedFromDomains: senderPolicy.allowedFromDomains, }) ); + trackOperationalEvent({ + severity: "info", + type: "inbound_rejected", + organizationId, + addressId: addressRow.id, + message: "Inbound email dropped by sender-domain policy", + metadata: { + reason: "disallowed_sender_domain", + senderDomain: senderPolicy.senderDomain ?? "unknown", + allowedFromDomains: senderPolicy.allowedFromDomains, + }, + }); return; } @@ -216,6 +262,17 @@ export const handleIncomingEmail = async ( senderRaw, }); if (!abuseCheck.allowed) { + trackOperationalEvent({ + severity: "warning", + type: "inbound_abuse_block", + organizationId, + addressId: addressRow.id, + message: "Inbound email blocked by abuse policy", + metadata: { + reason: abuseCheck.reason, + senderDomain: abuseCheck.senderDomain, + }, + }); return; } const addressMeta = parseAddressMeta(addressRow.meta); @@ -246,6 +303,18 @@ export const handleIncomingEmail = async ( reason: rawSizeResult.reason, }) ); + trackOperationalEvent({ + severity: "error", + type: "inbound_rejected", + organizationId, + addressId: addressRow.id, + emailId, + message: "Inbound email rejected because rawSize was invalid", + metadata: { + reason: rawSizeResult.reason, + rawSizeType: rawSizeResult.receivedType, + }, + }); message.setReject("Temporary processing error"); return; } @@ -292,6 +361,17 @@ export const handleIncomingEmail = async ( "[email] Failed to parse inbound email", logFields({ error }) ); + trackOperationalEvent({ + severity: "error", + type: "inbound_parse_failed", + organizationId, + addressId: addressRow.id, + emailId, + message: "Inbound email parsing failed", + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }); message.setReject("Temporary processing error"); return; } @@ -350,6 +430,18 @@ export const handleIncomingEmail = async ( error, }) ); + trackOperationalEvent({ + severity: "error", + type: "inbound_storage_failed", + organizationId, + addressId: addressRow.id, + emailId, + message: "Failed to clean stored files after inbox limit cleanup", + metadata: { + reason: "limit_cleanup_failed", + error: error instanceof Error ? error.message : String(error), + }, + }); message.setReject("Temporary processing error"); return; } @@ -387,13 +479,49 @@ export const handleIncomingEmail = async ( ); if (reservationFailureReason === "organization_limit") { + trackOperationalEvent({ + severity: "warning", + type: "inbound_limit_reached", + organizationId, + addressId: addressRow.id, + emailId, + message: + "Inbound email rejected because organization inbox limit was reached", + metadata: { + reason: reservationFailureReason, + organizationHardLimit, + }, + }); message.setReject("Organization inbox limit reached"); return; } if (reservationFailureReason === "address_limit") { + trackOperationalEvent({ + severity: "warning", + type: "inbound_limit_reached", + organizationId, + addressId: addressRow.id, + emailId, + message: + "Inbound email dropped because address inbox limit was reached", + metadata: { + reason: reservationFailureReason, + maxReceivedEmailCount, + maxReceivedEmailAction, + }, + }); return; } + trackOperationalEvent({ + severity: "error", + type: "system_error", + organizationId, + addressId: addressRow.id, + emailId, + message: "Inbound email reservation failed unexpectedly", + metadata: { reason: reservationFailureReason ?? "conflict" }, + }); message.setReject("Temporary processing error"); return; } @@ -443,6 +571,17 @@ export const handleIncomingEmail = async ( "[email] Inbound email insert failed", logFields({ error }) ); + trackOperationalEvent({ + severity: "error", + type: "system_error", + organizationId, + addressId: addressRow.id, + emailId, + message: "Inbound email database insert failed", + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }); throw error; } @@ -497,7 +636,20 @@ export const handleIncomingEmail = async ( addressId: addressRow.id, userId: addressRow.userId, }); - })() + })().catch(error => + recordOperationalEventSafely({ + env, + severity: "error", + type: "inbound_storage_failed", + organizationId, + addressId: addressRow.id, + emailId, + message: "Inbound email storage persistence failed", + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }) + ) ); ctx.waitUntil(updateAddressLastReceivedAt(db, addressRow.id, receivedAt)); @@ -515,6 +667,18 @@ export const handleIncomingEmail = async ( organizationId, error, }); + return recordOperationalEventSafely({ + env, + severity: "error", + type: "integration_dispatch_failed", + organizationId, + addressId: addressRow.id, + emailId, + message: "Integration dispatch failed after inbound email receipt", + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }); }) ); @@ -547,6 +711,17 @@ export const handleIncomingEmail = async ( } } logInboundError("[email] Unhandled processing error", logFields({ error })); + trackOperationalEvent({ + severity: "error", + type: "system_error", + organizationId: reservedOrganizationId, + addressId: reservedAddressId ?? addressId, + emailId, + message: "Unhandled inbound email processing error", + metadata: { + error: error instanceof Error ? error.message : String(error), + }, + }); message.setReject("Temporary processing error"); } }; diff --git a/packages/backend/src/platform/auth/admin-access.ts b/packages/backend/src/platform/auth/admin-access.ts new file mode 100644 index 00000000..46359205 --- /dev/null +++ b/packages/backend/src/platform/auth/admin-access.ts @@ -0,0 +1,20 @@ +import { createAccessControl } from "better-auth/plugins/access"; +import { defaultStatements } from "better-auth/plugins/admin/access"; +export { isPlatformAdminRole } from "@spinupmail/contracts"; + +export const adminAccessControl = createAccessControl(defaultStatements); + +export const platformAdminRole = adminAccessControl.newRole({ + user: ["list", "get", "impersonate"], + session: ["list"], +}); + +export const platformUserRole = adminAccessControl.newRole({ + user: [], + session: [], +}); + +export const platformAdminRoles = { + admin: platformAdminRole, + user: platformUserRole, +}; diff --git a/packages/backend/src/platform/auth/create-auth.ts b/packages/backend/src/platform/auth/create-auth.ts index 6049243f..fff803e6 100644 --- a/packages/backend/src/platform/auth/create-auth.ts +++ b/packages/backend/src/platform/auth/create-auth.ts @@ -8,12 +8,17 @@ import { betterAuth } from "better-auth"; import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; import { withCloudflare } from "better-auth-cloudflare"; import { apiKey } from "@better-auth/api-key"; -import { captcha, testUtils } from "better-auth/plugins"; +import { captcha, openAPI, testUtils } from "better-auth/plugins"; +import { admin } from "better-auth/plugins/admin"; import { organization } from "better-auth/plugins/organization"; import { twoFactor } from "better-auth/plugins/two-factor"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzle } from "drizzle-orm/d1"; import { schema } from "../../db"; +import { + adminAccessControl, + platformAdminRoles, +} from "@/platform/auth/admin-access"; import { assertAllowedAuthEmailDomain } from "./auth-domain-restriction"; import { createEmailQualificationPlugin } from "./email-qualification-plugin"; import { @@ -152,6 +157,15 @@ function createAuth( emailAndPassword: { enabled: true, requireEmailVerification: true, + customSyntheticUser: ({ coreFields, additionalFields, id }) => ({ + ...coreFields, + role: "user", + banned: false, + banReason: null, + banExpires: null, + ...additionalFields, + id, + }), sendResetPassword, revokeSessionsOnPasswordReset: true, password: { @@ -208,6 +222,7 @@ function createAuth( normalizedEmail: { type: "string", required: false, + unique: true, input: false, returned: false, }, @@ -253,6 +268,13 @@ function createAuth( twoFactor({ issuer: "Spinupmail", }), + admin({ + ac: adminAccessControl, + roles: platformAdminRoles, + defaultRole: "user", + adminRoles: ["admin"], + }), + openAPI(), ], rateLimit: { // Playwright e2e flows seed auth state directly and can fan out diff --git a/packages/backend/tests/unit/admin-operational-events.test.ts b/packages/backend/tests/unit/admin-operational-events.test.ts new file mode 100644 index 00000000..e1bd55da --- /dev/null +++ b/packages/backend/tests/unit/admin-operational-events.test.ts @@ -0,0 +1,174 @@ +const mocks = vi.hoisted(() => ({ + getDb: vi.fn(), +})); + +vi.mock("@/platform/db/client", () => ({ + getDb: mocks.getDb, +})); + +import { + pruneOperationalEvents, + recordOperationalEvent, + recordOperationalEventSafely, +} from "@/modules/admin/operational-events"; + +describe("admin operational events", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("redacts sensitive metadata before writing operational events", async () => { + const values = vi.fn().mockResolvedValue(undefined); + const insert = vi.fn(() => ({ values })); + mocks.getDb.mockReturnValue({ insert }); + + await recordOperationalEvent({ + env: {} as CloudflareBindings, + severity: "error", + type: "inbound_storage_failed", + organizationId: "org-1", + addressId: "address-1", + emailId: "email-1", + message: "Storage persistence failed", + metadata: { + provider: "r2", + token: "secret-token", + nested: { + authorization: "Bearer value", + retryCount: 2, + }, + rawHeaders: { + subject: "private", + }, + context: { + id: "ctx-1", + }, + }, + }); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + severity: "error", + type: "inbound_storage_failed", + organizationId: "org-1", + addressId: "address-1", + emailId: "email-1", + message: "Storage persistence failed", + metadataJson: JSON.stringify({ + provider: "r2", + token: "[redacted]", + nested: { + authorization: "[redacted]", + retryCount: 2, + }, + rawHeaders: "[redacted]", + context: { + id: "ctx-1", + }, + }), + }) + ); + }); + + it("does not throw when safe event recording fails", async () => { + const error = new Error("d1 unavailable"); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const values = vi.fn().mockRejectedValue(error); + const insert = vi.fn(() => ({ values })); + mocks.getDb.mockReturnValue({ insert }); + + await expect( + recordOperationalEventSafely({ + env: {} as CloudflareBindings, + severity: "warning", + type: "system_error", + message: "Rejected", + }) + ).resolves.toBeUndefined(); + + expect(consoleError).toHaveBeenCalledWith( + "[admin] Failed to record operational event", + { + type: "system_error", + severity: "warning", + error, + } + ); + }); + + it("caps message and metadata size before writing operational events", async () => { + const values = vi.fn().mockResolvedValue(undefined); + const insert = vi.fn(() => ({ values })); + mocks.getDb.mockReturnValue({ insert }); + + await recordOperationalEvent({ + env: { + OPERATIONAL_EVENT_MAX_METADATA_BYTES: "1024", + } as CloudflareBindings, + severity: "error", + type: "system_error", + message: "x".repeat(2_000), + metadata: { + reason: "large_payload", + ...Object.fromEntries( + Array.from({ length: 60 }, (_, index) => [ + `detail${index}`, + "y".repeat(2_000), + ]) + ), + }, + }); + + const written = values.mock.calls[0]?.[0] as { + message: string; + metadataJson: string; + }; + expect(written.message).toHaveLength(500); + expect(written.message).toContain("[truncated]"); + expect(written.metadataJson.length).toBeLessThanOrEqual(1024); + expect(JSON.parse(written.metadataJson)).toMatchObject({ + truncated: true, + }); + }); + + it("suppresses noisy operational events when the event limiter is exhausted", async () => { + const values = vi.fn().mockResolvedValue(undefined); + const insert = vi.fn(() => ({ values })); + const consume = vi.fn().mockResolvedValue({ allowed: false }); + mocks.getDb.mockReturnValue({ insert }); + + await recordOperationalEvent({ + env: { + FIXED_WINDOW_RATE_LIMITERS: { + idFromName: vi.fn(value => value), + get: vi.fn(() => ({ consume })), + }, + } as unknown as CloudflareBindings, + severity: "info", + type: "inbound_rejected", + message: "Inbound email rejected because the address is not registered", + metadata: { reason: "address_not_registered" }, + }); + + expect(consume).toHaveBeenCalledWith(300, 1); + expect(insert).not.toHaveBeenCalled(); + expect(values).not.toHaveBeenCalled(); + }); + + it("prunes operational events older than the retention window", async () => { + const run = vi.fn().mockResolvedValue({ meta: { changes: 1 } }); + mocks.getDb.mockReturnValue({ run }); + + await pruneOperationalEvents({ + OPERATIONAL_EVENT_RETENTION_DAYS: "7", + } as CloudflareBindings); + + expect(run).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/backend/tests/unit/admin-service.test.ts b/packages/backend/tests/unit/admin-service.test.ts new file mode 100644 index 00000000..52add856 --- /dev/null +++ b/packages/backend/tests/unit/admin-service.test.ts @@ -0,0 +1,331 @@ +const mocks = vi.hoisted(() => ({ + getDb: vi.fn(), + findAdminActivityRows: vi.fn(), + findAdminOperationalEventsPage: vi.fn(), + findAdminOrganizationRollups: vi.fn(), + findAdminOrganizationsPage: vi.fn(), + findAdminOverviewStats: vi.fn(), +})); + +vi.mock("@/platform/db/client", () => ({ + getDb: mocks.getDb, +})); + +vi.mock("@/modules/admin/repo", () => ({ + findAdminActivityRows: mocks.findAdminActivityRows, + findAdminOperationalEventsPage: mocks.findAdminOperationalEventsPage, + findAdminOrganizationRollups: mocks.findAdminOrganizationRollups, + findAdminOrganizationsPage: mocks.findAdminOrganizationsPage, + findAdminOverviewStats: mocks.findAdminOverviewStats, +})); + +import { + getAdminActivity, + getAdminOperationalEvents, + getAdminOrganizations, + getAdminOverview, + performAdminUserAction, +} from "@/modules/admin/service"; + +const createAdminActionDb = () => { + const targetUser = { + id: "target-user", + email: "target@example.com", + }; + const get = vi.fn().mockResolvedValue(targetUser); + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ get })), + })), + })); + const updateWhere = vi.fn(); + const set = vi.fn(() => ({ where: updateWhere })); + const update = vi.fn(() => ({ set })); + const deleteWhere = vi.fn(); + const deleteFrom = vi.fn(() => ({ where: deleteWhere })); + const values = vi.fn(); + const insert = vi.fn(() => ({ values })); + + return { + db: { + select, + update, + delete: deleteFrom, + insert, + }, + set, + updateWhere, + insertValues: values, + }; +}; + +describe("admin service", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-27T12:00:00.000Z")); + mocks.getDb.mockReturnValue({}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("builds overview windows and keeps sample email counts separate", async () => { + mocks.findAdminOverviewStats.mockResolvedValue({ + generatedAddresses: { current: 30, previous: 12 }, + receivedEmails: { current: 20, previous: 8 }, + sampleEmails: { current: 5, previous: 2 }, + organizations: 3, + users: 9, + activeUsers24h: 4, + activeUsers7d: 6, + attachments: { count: 7, sizeTotal: 4096 }, + integrations: { active: 2, retryScheduled: 1, failed: 0 }, + anomalies: { last24h: 2, errorsLast24h: 0, warningsLast24h: 1 }, + }); + + const result = await getAdminOverview({} as CloudflareBindings); + + expect(mocks.findAdminOverviewStats).toHaveBeenCalledWith({ + db: {}, + currentRange: { + from: new Date("2026-03-28T12:00:00.000Z"), + to: new Date("2026-04-27T12:00:00.000Z"), + }, + previousRange: { + from: new Date("2026-02-26T12:00:00.000Z"), + to: new Date("2026-03-28T12:00:00.000Z"), + }, + active24hSince: new Date("2026-04-26T12:00:00.000Z"), + active7dSince: new Date("2026-04-20T12:00:00.000Z"), + anomalySince: new Date("2026-04-26T12:00:00.000Z"), + now: new Date("2026-04-27T12:00:00.000Z"), + }); + expect(result.receivedEmails.current).toBe(20); + expect(result.sampleEmails.current).toBe(5); + expect(result.system).toEqual({ + status: "warning", + checkedAt: "2026-04-27T12:00:00.000Z", + }); + }); + + it("builds timezone-aware generated-address and received-email activity", async () => { + mocks.findAdminActivityRows.mockResolvedValue({ + generatedAddressRows: [ + { + minuteStartMs: Date.parse("2026-04-26T21:30:00.000Z"), + count: 3, + }, + ], + receivedEmailRows: [ + { + minuteStartMs: Date.parse("2026-04-27T08:30:00.000Z"), + count: 4, + }, + ], + }); + + const result = await getAdminActivity({ + env: {} as CloudflareBindings, + daysRaw: 2, + timezoneRaw: "Europe/Istanbul", + }); + + expect(result).toEqual({ + status: 200, + body: { + timezone: "Europe/Istanbul", + daily: [ + { + date: "2026-04-26", + generatedAddresses: 0, + receivedEmails: 0, + }, + { + date: "2026-04-27", + generatedAddresses: 3, + receivedEmails: 4, + }, + ], + }, + }); + }); + + it("combines organization page rows with per-organization rollups", async () => { + mocks.findAdminOrganizationsPage.mockResolvedValue({ + items: [ + { + id: "org-1", + name: "Acme", + slug: "acme", + createdAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ], + totalItems: 1, + }); + mocks.findAdminOrganizationRollups.mockResolvedValue({ + memberRows: [{ organizationId: "org-1", count: 2 }], + addressRows: [ + { + organizationId: "org-1", + count: 5, + lastReceivedAt: new Date("2026-04-26T10:00:00.000Z"), + }, + ], + emailRows: [ + { + organizationId: "org-1", + receivedCount: 8, + sampleCount: 3, + }, + ], + integrationRows: [ + { + organizationId: "org-1", + count: 4, + activeCount: 2, + }, + ], + }); + + const result = await getAdminOrganizations({ + env: {} as CloudflareBindings, + pageRaw: 1, + pageSizeRaw: 10, + }); + + expect(mocks.findAdminOrganizationRollups).toHaveBeenCalledWith({}, [ + "org-1", + ]); + expect(result.items).toEqual([ + { + id: "org-1", + name: "Acme", + slug: "acme", + createdAt: "2026-04-01T00:00:00.000Z", + memberCount: 2, + addressCount: 5, + receivedEmailCount: 8, + sampleEmailCount: 3, + integrationCount: 4, + activeIntegrationCount: 2, + lastReceivedAt: "2026-04-26T10:00:00.000Z", + }, + ]); + }); + + it("returns filtered operational events with parsed redacted metadata", async () => { + mocks.findAdminOperationalEventsPage.mockResolvedValue({ + items: [ + { + id: "event-1", + severity: "error", + type: "integration_dispatch_failed", + organizationId: "org-1", + addressId: null, + emailId: "email-1", + integrationId: "integration-1", + dispatchId: "dispatch-1", + organizationName: "Acme", + message: "Integration dispatch failed", + metadataJson: JSON.stringify({ + provider: "telegram", + token: "[redacted]", + }), + createdAt: new Date("2026-04-27T11:00:00.000Z"), + }, + ], + totalItems: 1, + }); + + const result = await getAdminOperationalEvents({ + env: {} as CloudflareBindings, + pageRaw: 1, + pageSizeRaw: 20, + severity: "error", + type: "integration_dispatch_failed", + organizationId: "org-1", + fromRaw: "2026-04-27T00:00:00.000Z", + toRaw: "2026-04-28T00:00:00.000Z", + }); + + expect(mocks.findAdminOperationalEventsPage).toHaveBeenCalledWith( + {}, + { page: 1, pageSize: 20 }, + { + severity: "error", + type: "integration_dispatch_failed", + organizationId: "org-1", + from: new Date("2026-04-27T00:00:00.000Z"), + to: new Date("2026-04-28T00:00:00.000Z"), + } + ); + expect(result.items).toEqual([ + { + id: "event-1", + severity: "error", + type: "integration_dispatch_failed", + organizationId: "org-1", + addressId: null, + emailId: "email-1", + integrationId: "integration-1", + dispatchId: "dispatch-1", + organizationName: "Acme", + message: "Integration dispatch failed", + metadata: { + provider: "telegram", + token: "[redacted]", + }, + createdAt: "2026-04-27T11:00:00.000Z", + }, + ]); + }); + + it("allows admins to set user roles and records the audit event", async () => { + const db = createAdminActionDb(); + mocks.getDb.mockReturnValue(db.db); + + await expect( + performAdminUserAction({ + env: {} as CloudflareBindings, + actorUserId: "actor-user", + actorEmail: "actor@example.com", + actorRole: "admin", + input: { + action: "set-role", + userId: "target-user", + role: "admin", + }, + }) + ).resolves.toEqual({ ok: true }); + + expect(db.set).toHaveBeenCalledWith({ + role: "admin", + updatedAt: new Date("2026-04-27T12:00:00.000Z"), + }); + expect(db.insertValues).toHaveBeenCalled(); + }); + + it("rejects admin actions from non-admin users", async () => { + const db = createAdminActionDb(); + mocks.getDb.mockReturnValue(db.db); + + await expect( + performAdminUserAction({ + env: {} as CloudflareBindings, + actorUserId: "actor-user", + actorEmail: "actor@example.com", + actorRole: "user", + input: { + action: "ban", + userId: "target-user", + reason: "Policy violation", + }, + }) + ).rejects.toThrow("forbidden"); + + expect(db.updateWhere).not.toHaveBeenCalled(); + expect(db.insertValues).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/tests/unit/auth-middleware.test.ts b/packages/backend/tests/unit/auth-middleware.test.ts index 49ae2689..b32d8fc3 100644 --- a/packages/backend/tests/unit/auth-middleware.test.ts +++ b/packages/backend/tests/unit/auth-middleware.test.ts @@ -1,6 +1,15 @@ +const mocks = vi.hoisted(() => ({ + getDb: vi.fn(), +})); + +vi.mock("@/platform/db/client", () => ({ + getDb: mocks.getDb, +})); + import { Hono } from "hono"; import type { AppHonoEnv } from "@/app/types"; import { requireAuth } from "@/app/middleware/require-auth"; +import { requirePlatformAdmin } from "@/app/middleware/require-platform-admin"; import { requireOrganizationScope } from "@/app/middleware/require-organization-scope"; const buildAuthApp = (getSession: ReturnType) => { @@ -53,6 +62,37 @@ const buildOrganizationScopeApp = ( return app; }; +const buildPlatformAdminApp = ( + getSession: ReturnType, + role: string | null | undefined +) => { + const app = new Hono(); + const get = vi.fn().mockResolvedValue(role === undefined ? null : { role }); + const where = vi.fn(() => ({ get })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + + mocks.getDb.mockReturnValue({ select }); + + app.use("*", async (c, next) => { + c.set("auth", { + api: { + getSession, + }, + } as never); + await next(); + }); + + app.use("*", requireAuth); + app.use("*", requirePlatformAdmin); + + app.get("/admin", c => { + return c.json({ ok: true }); + }); + + return app; +}; + describe("auth middleware", () => { it("rejects malformed session payloads as unauthorized", async () => { const app = buildAuthApp( @@ -221,3 +261,47 @@ describe("organization scope middleware", () => { }); }); }); + +describe("platform admin middleware", () => { + it("rejects authenticated non-admin users", async () => { + const app = buildPlatformAdminApp( + vi.fn().mockResolvedValue({ + session: { + id: "session-1", + userId: "user-1", + }, + user: { + id: "user-1", + emailVerified: true, + }, + }), + "user" + ); + + const response = await app.request("/admin"); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "forbidden" }); + }); + + it("allows verified admin users", async () => { + const app = buildPlatformAdminApp( + vi.fn().mockResolvedValue({ + session: { + id: "session-1", + userId: "user-1", + }, + user: { + id: "user-1", + emailVerified: true, + }, + }), + "admin" + ); + + const response = await app.request("/admin"); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/backend/tests/unit/create-auth.test.ts b/packages/backend/tests/unit/create-auth.test.ts index ccdf1e25..6847dcd6 100644 --- a/packages/backend/tests/unit/create-auth.test.ts +++ b/packages/backend/tests/unit/create-auth.test.ts @@ -194,13 +194,22 @@ describe("createAuth", () => { normalizedEmail?: { type?: string; required?: boolean; + unique?: boolean; input?: boolean; returned?: boolean; }; }; }; + emailAndPassword?: { + customSyntheticUser?: (input: { + coreFields: Record; + additionalFields: Record; + id: string; + }) => Record; + }; plugins?: Array<{ id?: string; + options?: unknown; init?: () => { options?: { databaseHooks?: { @@ -247,9 +256,31 @@ describe("createAuth", () => { expect(auth.user?.additionalFields?.normalizedEmail).toEqual({ type: "string", required: false, + unique: true, input: false, returned: false, }); + expect( + auth.emailAndPassword?.customSyntheticUser?.({ + coreFields: { + email: "jane@example.com", + name: "Jane", + }, + additionalFields: { + timezone: "UTC", + }, + id: "user-1", + }) + ).toEqual({ + email: "jane@example.com", + name: "Jane", + role: "user", + banned: false, + banReason: null, + banExpires: null, + timezone: "UTC", + id: "user-1", + }); const createdUser = await qualificationPlugin ?.init?.() @@ -275,6 +306,61 @@ describe("createAuth", () => { ).toBe(true); }); + it("configures the Better Auth admin plugin with safe platform permissions", async () => { + const { createAuth } = await import("@/platform/auth/create-auth"); + const { platformAdminRole, platformUserRole } = + await import("@/platform/auth/admin-access"); + + const auth = createAuth() as { + plugins?: Array<{ + id?: string; + options?: { + roles?: { + admin?: typeof platformAdminRole; + user?: typeof platformUserRole; + }; + defaultRole?: string; + adminRoles?: string[]; + }; + }>; + }; + const adminPlugin = auth.plugins?.find(plugin => plugin.id === "admin"); + + expect(adminPlugin?.options).toMatchObject({ + defaultRole: "user", + adminRoles: ["admin"], + }); + expect(adminPlugin?.options?.roles).toEqual({ + admin: platformAdminRole, + user: platformUserRole, + }); + expect( + platformAdminRole.authorize({ + user: ["list", "get", "impersonate"], + session: ["list"], + }).success + ).toBe(true); + expect(platformAdminRole.authorize({ user: ["set-role"] }).success).toBe( + false + ); + expect(platformAdminRole.authorize({ user: ["delete"] }).success).toBe( + false + ); + expect( + platformAdminRole.authorize({ user: ["create", "set-password"] }).success + ).toBe(false); + expect(platformAdminRole.authorize({ user: ["impersonate"] }).success).toBe( + true + ); + expect(platformAdminRole.authorize({ session: ["revoke"] }).success).toBe( + false + ); + expect(platformAdminRole.authorize({ session: ["delete"] }).success).toBe( + false + ); + expect(platformUserRole.authorize({ user: ["list"] }).success).toBe(false); + }); + it("enables test utils and keeps captcha when E2E helpers are turned on", async () => { const { createAuth } = await import("@/platform/auth/create-auth"); diff --git a/packages/backend/worker-configuration.d.ts b/packages/backend/worker-configuration.d.ts index 6197291a..4702b43b 100644 --- a/packages/backend/worker-configuration.d.ts +++ b/packages/backend/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface CloudflareBindings` (hash: 8dd1c4f62ab73811190ab02fcc81dd88) +// Generated by Wrangler by running `wrangler types --env-interface CloudflareBindings` (hash: e6324e251b80d5ebfbc8dc9b4cee64b7) // Runtime types generated with workerd@1.20260415.1 2025-03-01 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -21,6 +21,10 @@ declare namespace Cloudflare { MAX_RECEIVED_EMAILS_PER_ORGANIZATION: "1000"; MAX_RECEIVED_EMAILS_PER_ADDRESS: "100"; RESEND_FROM_EMAIL: "Spinupmail "; + OPERATIONAL_EVENT_RETENTION_DAYS: "30"; + OPERATIONAL_EVENT_MAX_METADATA_BYTES: "4096"; + OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS: "300"; + OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX: "1"; EMAIL_MAX_BYTES: "10485760"; EMAIL_BODY_MAX_BYTES: "524288"; EMAIL_ATTACHMENT_MAX_BYTES: "10485760"; diff --git a/packages/backend/wrangler.e2e.toml b/packages/backend/wrangler.e2e.toml index de322e23..549a68f0 100644 --- a/packages/backend/wrangler.e2e.toml +++ b/packages/backend/wrangler.e2e.toml @@ -9,6 +9,9 @@ enabled = true [placement] mode = "smart" +[triggers] +crons = ["17 * * * *"] + [[migrations]] tag = "v1" new_sqlite_classes = ["InboundAbuseCounterDurableObject"] @@ -58,6 +61,10 @@ EMAIL_DOMAINS = "spinupmail.com,spinupmail.dev" MAX_ADDRESSES_PER_ORGANIZATION = "100" MAX_INTEGRATIONS_PER_ORGANIZATION = "3" MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY = "100" +OPERATIONAL_EVENT_RETENTION_DAYS = "30" +OPERATIONAL_EVENT_MAX_METADATA_BYTES = "4096" +OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS = "300" +OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX = "1" RESEND_FROM_EMAIL = "Spinupmail " EMAIL_MAX_BYTES = "10485760" EMAIL_BODY_MAX_BYTES = "524288" diff --git a/packages/backend/wrangler.toml b/packages/backend/wrangler.toml index edef19d3..7e3e5413 100644 --- a/packages/backend/wrangler.toml +++ b/packages/backend/wrangler.toml @@ -12,6 +12,9 @@ enabled = true [placement] mode = "smart" +[triggers] +crons = ["17 * * * *"] + [[migrations]] tag = "v1" new_sqlite_classes = ["InboundAbuseCounterDurableObject"] @@ -64,6 +67,10 @@ MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY = "100" MAX_RECEIVED_EMAILS_PER_ORGANIZATION = "1000" MAX_RECEIVED_EMAILS_PER_ADDRESS = "100" RESEND_FROM_EMAIL = "Spinupmail " +OPERATIONAL_EVENT_RETENTION_DAYS = "30" +OPERATIONAL_EVENT_MAX_METADATA_BYTES = "4096" +OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS = "300" +OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX = "1" EMAIL_MAX_BYTES = "10485760" EMAIL_BODY_MAX_BYTES = "524288" EMAIL_ATTACHMENT_MAX_BYTES = "10485760" diff --git a/packages/backend/wrangler.toml.example b/packages/backend/wrangler.toml.example index a72a332c..f1e0cd70 100644 --- a/packages/backend/wrangler.toml.example +++ b/packages/backend/wrangler.toml.example @@ -13,14 +13,25 @@ enabled = true [placement] mode = "smart" +[triggers] +crons = ["17 * * * *"] + [[migrations]] tag = "v1" new_sqlite_classes = ["InboundAbuseCounterDurableObject"] +[[migrations]] +tag = "v2" +new_sqlite_classes = ["FixedWindowRateLimiterDurableObject"] + [[durable_objects.bindings]] name = "ABUSE_COUNTERS" class_name = "InboundAbuseCounterDurableObject" +[[durable_objects.bindings]] +name = "FIXED_WINDOW_RATE_LIMITERS" +class_name = "FixedWindowRateLimiterDurableObject" + [[d1_databases]] binding = "SUM_DB" database_name = "SUM_DB" @@ -62,6 +73,10 @@ MAX_INTEGRATIONS_PER_ORGANIZATION = "3" MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY = "100" MAX_RECEIVED_EMAILS_PER_ORGANIZATION = "1000" MAX_RECEIVED_EMAILS_PER_ADDRESS = "100" +OPERATIONAL_EVENT_RETENTION_DAYS = "30" +OPERATIONAL_EVENT_MAX_METADATA_BYTES = "4096" +OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS = "300" +OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX = "1" API_KEY_RATE_LIMIT_WINDOW = "60" API_KEY_RATE_LIMIT_MAX = "120" AUTH_RATE_LIMIT_WINDOW = "60" diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f30a2791..4a92bdea 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -53,6 +53,308 @@ export const organizationStatsResponseSchema = z.object({ items: z.array(organizationStatsItemSchema), }); +export const adminOperationalEventSeveritySchema = z.enum([ + "info", + "warning", + "error", +]); + +export const adminOperationalEventTypeSchema = z.enum([ + "admin_user_action", + "admin_session_action", + "admin_impersonation_started", + "inbound_rejected", + "inbound_duplicate", + "inbound_limit_reached", + "inbound_abuse_block", + "inbound_parse_failed", + "inbound_storage_failed", + "integration_dispatch_failed", + "system_error", +]); + +export const platformRoleSchema = z.enum(["user", "admin"]); + +const PLATFORM_ADMIN_ROLES = new Set(["admin"]); + +export const isPlatformAdminRole = (role: unknown) => { + if (Array.isArray(role)) { + return role.some(value => PLATFORM_ADMIN_ROLES.has(String(value).trim())); + } + if (typeof role !== "string") return false; + return role + .split(",") + .map(part => part.trim()) + .some(part => PLATFORM_ADMIN_ROLES.has(part)); +}; + +const isoDateSchema = z.iso.date(); +const isoDateTimeSchema = z.string().datetime(); + +export const adminMetricSchema = z.object({ + current: z.number().int().nonnegative(), + previous: z.number().int().nonnegative(), +}); + +export const adminOverviewResponseSchema = z.object({ + generatedAddresses: adminMetricSchema, + receivedEmails: adminMetricSchema, + sampleEmails: adminMetricSchema, + organizations: z.number().int().nonnegative(), + users: z.number().int().nonnegative(), + activeUsers24h: z.number().int().nonnegative(), + activeUsers7d: z.number().int().nonnegative(), + attachments: z.object({ + count: z.number().int().nonnegative(), + sizeTotal: z.number().int().nonnegative(), + }), + integrations: z.object({ + active: z.number().int().nonnegative(), + retryScheduled: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + }), + anomalies: z.object({ + last24h: z.number().int().nonnegative(), + errorsLast24h: z.number().int().nonnegative(), + warningsLast24h: z.number().int().nonnegative(), + }), + system: z.object({ + status: z.enum(["healthy", "warning", "critical"]), + checkedAt: z.string().datetime(), + }), +}); + +export const adminActivityDaySchema = z.object({ + date: isoDateSchema, + generatedAddresses: z.number().int().nonnegative(), + receivedEmails: z.number().int().nonnegative(), +}); + +export const adminActivityResponseSchema = z.object({ + timezone: z.string().min(1), + daily: z.array(adminActivityDaySchema), +}); + +export const adminOrganizationItemSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + slug: z.string().min(1), + createdAt: isoDateTimeSchema.nullable(), + memberCount: z.number().int().nonnegative(), + addressCount: z.number().int().nonnegative(), + receivedEmailCount: z.number().int().nonnegative(), + sampleEmailCount: z.number().int().nonnegative(), + integrationCount: z.number().int().nonnegative(), + activeIntegrationCount: z.number().int().nonnegative(), + lastReceivedAt: isoDateTimeSchema.nullable(), +}); + +export const adminOrganizationsResponseSchema = z.object({ + items: z.array(adminOrganizationItemSchema), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + totalItems: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), +}); + +export const adminOperationalEventSchema = z.object({ + id: z.string().min(1), + severity: adminOperationalEventSeveritySchema, + type: adminOperationalEventTypeSchema, + organizationId: z.string().nullable(), + addressId: z.string().nullable(), + emailId: z.string().nullable(), + integrationId: z.string().nullable(), + dispatchId: z.string().nullable(), + organizationName: z.string().nullable(), + message: z.string().min(1), + metadata: z.record(z.string(), z.unknown()).nullable(), + createdAt: isoDateTimeSchema.nullable(), +}); + +export const adminOperationalEventsResponseSchema = z.object({ + items: z.array(adminOperationalEventSchema), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + totalItems: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), +}); + +export const adminUserDetailSchema = z.object({ + user: z.object({ + id: z.string().min(1), + name: z.string().nullable(), + email: z.string().min(1), + emailVerified: z.boolean(), + role: z.string().nullable(), + banned: z.boolean().nullable(), + banReason: z.string().nullable(), + banExpires: isoDateTimeSchema.nullable(), + twoFactorEnabled: z.boolean().nullable(), + timezone: z.string().nullable(), + createdAt: isoDateTimeSchema.nullable(), + updatedAt: isoDateTimeSchema.nullable(), + }), + accounts: z.array( + z.object({ + providerId: z.string().min(1), + createdAt: isoDateTimeSchema.nullable(), + }) + ), + memberships: z.array( + z.object({ + organizationId: z.string().min(1), + organizationName: z.string().nullable(), + organizationSlug: z.string().nullable(), + role: z.string().min(1), + createdAt: isoDateTimeSchema.nullable(), + }) + ), + apiKeys: z.array( + z.object({ + id: z.string().min(1), + name: z.string().nullable(), + start: z.string().nullable(), + prefix: z.string().nullable(), + enabled: z.boolean().nullable(), + requestCount: z.number().int().nonnegative(), + remaining: z.number().int().nullable(), + rateLimitEnabled: z.boolean().nullable(), + rateLimitMax: z.number().int().nullable(), + rateLimitTimeWindow: z.number().int().nullable(), + lastRequest: isoDateTimeSchema.nullable(), + expiresAt: isoDateTimeSchema.nullable(), + createdAt: isoDateTimeSchema.nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), + }) + ), + recentEvents: z.array(adminOperationalEventSchema), +}); + +export const adminUserDetailResponseSchema = adminUserDetailSchema; + +export const adminOrganizationDetailSchema = z.object({ + organization: adminOrganizationItemSchema.extend({ + metadata: z.record(z.string(), z.unknown()).nullable(), + }), + members: z.array( + z.object({ + id: z.string().min(1), + userId: z.string().min(1), + name: z.string().nullable(), + email: z.string().nullable(), + role: z.string().min(1), + createdAt: isoDateTimeSchema.nullable(), + }) + ), + invitations: z.array( + z.object({ + id: z.string().min(1), + email: z.string().min(1), + role: z.string().nullable(), + status: z.string().min(1), + expiresAt: isoDateTimeSchema.nullable(), + createdAt: isoDateTimeSchema.nullable(), + }) + ), + integrations: z.array( + z.object({ + id: z.string().min(1), + provider: z.string().min(1), + name: z.string().min(1), + status: z.string().min(1), + lastValidatedAt: isoDateTimeSchema.nullable(), + createdAt: isoDateTimeSchema.nullable(), + updatedAt: isoDateTimeSchema.nullable(), + }) + ), + apiKeys: z.array( + z.object({ + id: z.string().min(1), + name: z.string().nullable(), + start: z.string().nullable(), + prefix: z.string().nullable(), + enabled: z.boolean().nullable(), + requestCount: z.number().int().nonnegative(), + remaining: z.number().int().nullable(), + lastRequest: isoDateTimeSchema.nullable(), + expiresAt: isoDateTimeSchema.nullable(), + createdAt: isoDateTimeSchema.nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), + }) + ), + recentEvents: z.array(adminOperationalEventSchema), +}); + +export const adminOrganizationDetailResponseSchema = + adminOrganizationDetailSchema; + +export const adminApiKeyItemSchema = z.object({ + id: z.string().min(1), + name: z.string().nullable(), + start: z.string().nullable(), + prefix: z.string().nullable(), + referenceId: z.string().min(1), + ownerType: z.enum(["user", "organization", "unknown"]), + ownerLabel: z.string().nullable(), + enabled: z.boolean().nullable(), + requestCount: z.number().int().nonnegative(), + remaining: z.number().int().nullable(), + rateLimitEnabled: z.boolean().nullable(), + rateLimitMax: z.number().int().nullable(), + rateLimitTimeWindow: z.number().int().nullable(), + lastRequest: isoDateTimeSchema.nullable(), + expiresAt: isoDateTimeSchema.nullable(), + createdAt: isoDateTimeSchema.nullable(), + metadata: z.record(z.string(), z.unknown()).nullable(), +}); + +export const adminApiKeysResponseSchema = z.object({ + items: z.array(adminApiKeyItemSchema), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), + totalItems: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), +}); + +export const adminRecordAuditEventRequestSchema = z.object({ + action: z.string().min(1).max(128), + targetType: z.enum(["user", "session", "organization", "api_key", "system"]), + targetId: z.string().min(1).max(256).optional(), + organizationId: z.string().min(1).optional(), + message: z.string().min(1).max(512), + reason: z.string().trim().min(1).max(512).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const adminUserActionBaseSchema = z.object({ + userId: z.string().min(1).max(256), + reason: z.string().trim().min(1).max(512).optional(), +}); + +export const adminUserActionRequestSchema = z.discriminatedUnion("action", [ + adminUserActionBaseSchema.extend({ + action: z.literal("set-role"), + role: platformRoleSchema, + }), + adminUserActionBaseSchema.extend({ + action: z.literal("ban"), + }), + adminUserActionBaseSchema.extend({ + action: z.literal("unban"), + }), + adminUserActionBaseSchema.extend({ + action: z.literal("impersonate"), + }), + adminUserActionBaseSchema.extend({ + action: z.literal("revoke-sessions"), + }), + adminUserActionBaseSchema.extend({ + action: z.literal("revoke-session"), + sessionToken: z.string().min(1).max(512), + }), +]); + export const integrationProviderSchema = z.enum(["telegram"]); export const integrationStatusSchema = z.enum(["active", "archived"]); export const integrationEventTypeSchema = z.enum(["email.received"]); @@ -507,6 +809,37 @@ export type OrganizationStatsItem = z.infer; export type OrganizationStatsResponse = z.infer< typeof organizationStatsResponseSchema >; +export type AdminOperationalEventSeverity = z.infer< + typeof adminOperationalEventSeveritySchema +>; +export type AdminOperationalEventType = z.infer< + typeof adminOperationalEventTypeSchema +>; +export type PlatformRole = z.infer; +export type AdminOverviewResponse = z.infer; +export type AdminActivityResponse = z.infer; +export type AdminOrganizationItem = z.infer; +export type AdminOrganizationsResponse = z.infer< + typeof adminOrganizationsResponseSchema +>; +export type AdminOperationalEvent = z.infer; +export type AdminOperationalEventsResponse = z.infer< + typeof adminOperationalEventsResponseSchema +>; +export type AdminUserDetailResponse = z.infer< + typeof adminUserDetailResponseSchema +>; +export type AdminOrganizationDetailResponse = z.infer< + typeof adminOrganizationDetailResponseSchema +>; +export type AdminApiKeyItem = z.infer; +export type AdminApiKeysResponse = z.infer; +export type AdminRecordAuditEventRequest = z.infer< + typeof adminRecordAuditEventRequestSchema +>; +export type AdminUserActionRequest = z.infer< + typeof adminUserActionRequestSchema +>; export type IntegrationProvider = z.infer; export type IntegrationStatus = z.infer; export type IntegrationEventType = z.infer; diff --git a/packages/frontend/src/components/app-sidebar.tsx b/packages/frontend/src/components/app-sidebar.tsx index 250c017e..21f11405 100644 --- a/packages/frontend/src/components/app-sidebar.tsx +++ b/packages/frontend/src/components/app-sidebar.tsx @@ -7,6 +7,7 @@ import { LogoutIcon, UserMultiple02Icon, DashboardSquare01Icon, + Key01Icon, } from "@/lib/hugeicons"; import { HugeiconsIcon } from "@hugeicons/react"; import { AppLogo } from "@/components/app-logo"; @@ -39,10 +40,12 @@ import { } from "@/components/ui/chevrons-up-down"; import { MemberAvatar } from "@/features/organization/components/members/member-avatar"; import type { AuthUser } from "@/lib/auth"; +import { isPlatformAdminRole } from "@spinupmail/contracts"; type AppSidebarProps = React.ComponentProps & { user: AuthUser | null; onSignOut: () => Promise | void; + onRefreshSession?: () => Promise; }; type NavItem = { @@ -81,10 +84,17 @@ const navItems: NavItem[] = [ }, ]; -export const AppSidebar = ({ user, onSignOut, ...props }: AppSidebarProps) => { +export const AppSidebar = ({ + user, + onSignOut, + onRefreshSession, + ...props +}: AppSidebarProps) => { const { isMobile, state } = useSidebar(); const location = useLocation(); const navigate = useNavigate(); + const [hasRefreshedSession, setHasRefreshedSession] = + React.useState(!onRefreshSession); const navigateIfNeeded = React.useCallback( (to: string) => { if (location.pathname === to) return; @@ -93,6 +103,35 @@ export const AppSidebar = ({ user, onSignOut, ...props }: AppSidebarProps) => { [location.pathname, navigate] ); const userAvatarSeed = user?.id ?? user?.email ?? user?.name ?? "guest"; + + React.useEffect(() => { + if (!onRefreshSession) return; + let isMounted = true; + + void onRefreshSession().finally(() => { + if (isMounted) setHasRefreshedSession(true); + }); + + return () => { + isMounted = false; + }; + }, [onRefreshSession]); + + const visibleNavItems = React.useMemo( + () => + hasRefreshedSession && + isPlatformAdminRole((user as { role?: unknown } | null)?.role) + ? [ + ...navItems, + { + title: "Admin", + to: "/admin", + icon: Key01Icon, + }, + ] + : navItems, + [hasRefreshedSession, user] + ); const [isUserDropdownOpen, setIsUserDropdownOpen] = React.useState(false); const userChevronsRef = React.useRef(null); @@ -139,7 +178,7 @@ export const AppSidebar = ({ user, onSignOut, ...props }: AppSidebarProps) => { - {navItems.map(item => { + {visibleNavItems.map(item => { const isActive = item.end ? location.pathname === item.to : location.pathname.startsWith(item.to); diff --git a/packages/frontend/src/features/auth/hooks/route-loaders.ts b/packages/frontend/src/features/auth/hooks/route-loaders.ts index 7924e91b..9843a0d3 100644 --- a/packages/frontend/src/features/auth/hooks/route-loaders.ts +++ b/packages/frontend/src/features/auth/hooks/route-loaders.ts @@ -1,4 +1,5 @@ import { redirect, type LoaderFunctionArgs } from "react-router"; +import { isPlatformAdminRole } from "@spinupmail/contracts"; import { getLastActiveOrganizationId, setLastActiveOrganizationId, @@ -76,6 +77,24 @@ export const requireAuthLoader = async ({ request }: LoaderFunctionArgs) => { return null; }; +export const requirePlatformAdminLoader = async ({ + request, +}: LoaderFunctionArgs) => { + const session = await getSessionOrRedirect(request); + const freshSession = await authClient.getSession({ + query: { + disableCookieCache: true, + }, + }); + const user = freshSession.data?.user ?? session.user; + + if (!isPlatformAdminRole((user as { role?: unknown }).role)) { + throw redirect("/"); + } + + return null; +}; + export const requireActiveOrganizationLoader = async ({ request, }: LoaderFunctionArgs) => { diff --git a/packages/frontend/src/features/auth/hooks/tests/route-loaders.test.ts b/packages/frontend/src/features/auth/hooks/tests/route-loaders.test.ts index e81a6ba7..cbe24865 100644 --- a/packages/frontend/src/features/auth/hooks/tests/route-loaders.test.ts +++ b/packages/frontend/src/features/auth/hooks/tests/route-loaders.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { redirectIfAuthenticatedLoader, requireActiveOrganizationLoader, + requirePlatformAdminLoader, requireNoActiveOrganizationLoader, } from "../route-loaders"; @@ -130,4 +131,55 @@ describe("route loaders", () => { ) ).resolves.toBeNull(); }); + + it("redirects non-admin users away from the admin route", async () => { + mocks.getSession + .mockResolvedValueOnce({ + error: null, + data: { + user: { id: "user-1", role: "user" }, + session: {}, + }, + }) + .mockResolvedValueOnce({ + error: null, + data: { + user: { id: "user-1", role: "user" }, + session: {}, + }, + }); + + await expect( + requirePlatformAdminLoader(loaderArgs("https://app/admin")) + ).rejects.toMatchObject({ + status: 302, + }); + }); + + it("allows admin users through the admin route loader", async () => { + mocks.getSession + .mockResolvedValueOnce({ + error: null, + data: { + user: { id: "user-1", role: "user" }, + session: {}, + }, + }) + .mockResolvedValueOnce({ + error: null, + data: { + user: { id: "user-1", role: "admin" }, + session: {}, + }, + }); + + await expect( + requirePlatformAdminLoader(loaderArgs("https://app/admin")) + ).resolves.toBeNull(); + expect(mocks.getSession).toHaveBeenLastCalledWith({ + query: { + disableCookieCache: true, + }, + }); + }); }); diff --git a/packages/frontend/src/features/settings/components/tests/change-password-panel.test.tsx b/packages/frontend/src/features/settings/components/tests/change-password-panel.test.tsx index 63289108..c262ef2f 100644 --- a/packages/frontend/src/features/settings/components/tests/change-password-panel.test.tsx +++ b/packages/frontend/src/features/settings/components/tests/change-password-panel.test.tsx @@ -44,6 +44,7 @@ const buildMockUser = () => ({ createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), twoFactorEnabled: false, + banned: false, }); const renderChangePasswordPanel = ( diff --git a/packages/frontend/src/features/settings/components/tests/two-factor-panel.test.tsx b/packages/frontend/src/features/settings/components/tests/two-factor-panel.test.tsx index 0c5dc1bd..386076ab 100644 --- a/packages/frontend/src/features/settings/components/tests/two-factor-panel.test.tsx +++ b/packages/frontend/src/features/settings/components/tests/two-factor-panel.test.tsx @@ -56,6 +56,7 @@ const buildMockUser = (twoFactorEnabled: boolean) => ({ createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), twoFactorEnabled, + banned: false, }); const buildAuthState = ({ diff --git a/packages/frontend/src/features/settings/components/tests/user-profile-panel.test.tsx b/packages/frontend/src/features/settings/components/tests/user-profile-panel.test.tsx index 865771e1..74d96b9e 100644 --- a/packages/frontend/src/features/settings/components/tests/user-profile-panel.test.tsx +++ b/packages/frontend/src/features/settings/components/tests/user-profile-panel.test.tsx @@ -60,6 +60,7 @@ const buildMockUser = (name: string) => ({ createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), twoFactorEnabled: false, + banned: false, }); const renderUserProfilePanel = () => diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index 189ca0bd..ea0b68b0 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -1,4 +1,15 @@ import type { + AdminActivityResponse, + AdminApiKeysResponse, + AdminOperationalEventSeverity, + AdminOperationalEventType, + AdminOperationalEventsResponse, + AdminOrganizationDetailResponse, + AdminOrganizationsResponse, + AdminOverviewResponse, + AdminRecordAuditEventRequest, + AdminUserActionRequest, + AdminUserDetailResponse, AddressIntegration, CreateIntegrationRequest, DeleteIntegrationResponse, @@ -128,6 +139,16 @@ const buildQueryString = (query: URLSearchParams) => query.size > 0 ? `?${query.toString()}` : ""; export type { + AdminActivityResponse, + AdminApiKeysResponse, + AdminOperationalEventSeverity, + AdminOperationalEventType, + AdminOperationalEventsResponse, + AdminOrganizationDetailResponse, + AdminOrganizationsResponse, + AdminOverviewResponse, + AdminRecordAuditEventRequest, + AdminUserDetailResponse, AddressIntegration, DeleteIntegrationResponse, IntegrationDispatch, @@ -141,6 +162,111 @@ export type { TelegramIntegrationPublicConfig, }; +export const getAdminOverview = async (options?: { signal?: AbortSignal }) => + apiFetch("/api/admin/overview", { + signal: options?.signal, + }); + +export const getAdminActivity = async (options?: { + days?: number; + timezone?: string; + signal?: AbortSignal; +}) => { + const query = new URLSearchParams(); + if (options?.days) query.set("days", String(options.days)); + if (options?.timezone) query.set("timezone", options.timezone); + return apiFetch( + `/api/admin/activity${buildQueryString(query)}`, + { signal: options?.signal } + ); +}; + +export const listAdminOrganizations = async (options?: { + page?: number; + pageSize?: number; + signal?: AbortSignal; +}) => { + const query = new URLSearchParams(); + if (options?.page) query.set("page", String(options.page)); + if (options?.pageSize) query.set("pageSize", String(options.pageSize)); + return apiFetch( + `/api/admin/organizations${buildQueryString(query)}`, + { signal: options?.signal } + ); +}; + +export const getAdminUserDetail = async ( + userId: string, + options?: { signal?: AbortSignal } +) => + apiFetch( + `/api/admin/users/${encodeURIComponent(userId)}`, + { signal: options?.signal } + ); + +export const getAdminOrganizationDetail = async ( + organizationId: string, + options?: { signal?: AbortSignal } +) => + apiFetch( + `/api/admin/organizations/${encodeURIComponent(organizationId)}`, + { signal: options?.signal } + ); + +export const listAdminApiKeys = async (options?: { + page?: number; + pageSize?: number; + signal?: AbortSignal; +}) => { + const query = new URLSearchParams(); + if (options?.page) query.set("page", String(options.page)); + if (options?.pageSize) query.set("pageSize", String(options.pageSize)); + return apiFetch( + `/api/admin/api-keys${buildQueryString(query)}`, + { signal: options?.signal } + ); +}; + +export const listAdminAnomalies = async (options?: { + page?: number; + pageSize?: number; + severity?: AdminOperationalEventSeverity; + type?: AdminOperationalEventType; + organizationId?: string; + from?: string; + to?: string; + signal?: AbortSignal; +}) => { + const query = new URLSearchParams(); + if (options?.page) query.set("page", String(options.page)); + if (options?.pageSize) query.set("pageSize", String(options.pageSize)); + if (options?.severity) query.set("severity", options.severity); + if (options?.type) query.set("type", options.type); + if (options?.organizationId) { + query.set("organizationId", options.organizationId); + } + if (options?.from) query.set("from", options.from); + if (options?.to) query.set("to", options.to); + return apiFetch( + `/api/admin/anomalies${buildQueryString(query)}`, + { signal: options?.signal } + ); +}; + +export const recordAdminAuditEvent = async ( + body: AdminRecordAuditEventRequest +) => + apiFetch<{ ok: true }>("/api/admin/audit-events", { + method: "POST", + body: JSON.stringify(body), + }); + +export const performAdminUserAction = async (body: AdminUserActionRequest) => + apiFetch<{ ok: true }>("/api/admin/user-actions", { + method: "POST", + body: JSON.stringify(body), + }); + export type EmailAddress = { id: string; address: string; diff --git a/packages/frontend/src/lib/auth.ts b/packages/frontend/src/lib/auth.ts index 349defd3..eea67eeb 100644 --- a/packages/frontend/src/lib/auth.ts +++ b/packages/frontend/src/lib/auth.ts @@ -1,4 +1,5 @@ import { apiKeyClient } from "@better-auth/api-key/client"; +import { adminClient } from "better-auth/client/plugins"; import { organizationClient, twoFactorClient, @@ -9,7 +10,12 @@ const baseURL = import.meta.env.VITE_AUTH_BASE_URL; export const authClient = createAuthClient({ baseURL, - plugins: [apiKeyClient(), organizationClient(), twoFactorClient()], + plugins: [ + apiKeyClient(), + organizationClient(), + twoFactorClient(), + adminClient(), + ], }); export type AuthSession = typeof authClient.$Infer.Session; diff --git a/packages/frontend/src/lib/query-keys.ts b/packages/frontend/src/lib/query-keys.ts index 8edee5c7..b32dc87e 100644 --- a/packages/frontend/src/lib/query-keys.ts +++ b/packages/frontend/src/lib/query-keys.ts @@ -1,4 +1,47 @@ +import type { + AdminOperationalEventSeverity, + AdminOperationalEventType, +} from "@spinupmail/contracts"; + +export type AdminAnomaliesQueryKeyOptions = { + page: number; + pageSize: number; + severity: AdminOperationalEventSeverity | "all"; + type: AdminOperationalEventType | "all"; + organizationId: string; + from: string; + to: string; +}; + export const queryKeys = { + adminOverview: ["app", "admin", "overview"] as const, + adminActivity: (timezone: string) => + ["app", "admin", "activity", timezone] as const, + adminOrganizations: (page: number, pageSize: number) => + ["app", "admin", "organizations", page, pageSize] as const, + adminUserDetail: (userId: string | null) => + ["app", "admin", "users", "detail", userId] as const, + adminOrganizationDetail: (organizationId: string | null) => + ["app", "admin", "organizations", "detail", organizationId] as const, + adminApiKeys: (page: number, pageSize: number) => + ["app", "admin", "api-keys", page, pageSize] as const, + adminAnomalies: (options: AdminAnomaliesQueryKeyOptions) => + [ + "app", + "admin", + "anomalies", + options.page, + options.pageSize, + options.severity, + options.type, + options.organizationId, + options.from, + options.to, + ] as const, + adminUsers: (page: number, pageSize: number, search: string) => + ["app", "admin", "users", page, pageSize, search] as const, + adminUserSessions: (userId: string | null) => + ["app", "admin", "users", userId, "sessions"] as const, organizationStats: ["app", "organization-stats"] as const, emailActivity: (organizationId: string | null, timezone: string) => [ diff --git a/packages/frontend/src/lib/tests/api.test.ts b/packages/frontend/src/lib/tests/api.test.ts index a79cfd26..1627c209 100644 --- a/packages/frontend/src/lib/tests/api.test.ts +++ b/packages/frontend/src/lib/tests/api.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createOrganization, downloadEmailAttachment, + listAdminAnomalies, listAllEmailAddresses, listDomains, } from "../api"; @@ -159,6 +160,43 @@ describe("api client helpers", () => { expect(init.body).toBe(JSON.stringify({ name: "Acme" })); }); + it("builds admin anomaly filter query parameters", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + items: [], + page: 1, + pageSize: 10, + totalItems: 0, + totalPages: 0, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + + await listAdminAnomalies({ + page: 1, + pageSize: 10, + severity: "error", + type: "system_error", + organizationId: "org-1", + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-27T23:59:59.999Z", + }); + + const [input] = fetchMock.mock.calls[0] as [RequestInfo, RequestInit]; + const url = toUrl(input); + expect(url.pathname).toBe("/api/admin/anomalies"); + expect(url.searchParams.get("severity")).toBe("error"); + expect(url.searchParams.get("type")).toBe("system_error"); + expect(url.searchParams.get("organizationId")).toBe("org-1"); + expect(url.searchParams.get("from")).toBe("2026-04-01T00:00:00.000Z"); + expect(url.searchParams.get("to")).toBe("2026-04-27T23:59:59.999Z"); + }); + it("stops pagination when backend reports unsafe page count", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( diff --git a/packages/frontend/src/lib/tests/query-keys.test.ts b/packages/frontend/src/lib/tests/query-keys.test.ts index 77f95688..6a710430 100644 --- a/packages/frontend/src/lib/tests/query-keys.test.ts +++ b/packages/frontend/src/lib/tests/query-keys.test.ts @@ -48,6 +48,28 @@ describe("queryKeys", () => { "email-detail", "email-1", ]); + expect( + queryKeys.adminAnomalies({ + page: 2, + pageSize: 10, + severity: "error", + type: "system_error", + organizationId: "org-1", + from: "2026-04-01", + to: "2026-04-27", + }) + ).toEqual([ + "app", + "admin", + "anomalies", + 2, + 10, + "error", + "system_error", + "org-1", + "2026-04-01", + "2026-04-27", + ]); }); it("changes key when parameters change", () => { diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index bbb54ef7..ae5d2ac4 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -15,11 +15,13 @@ import { Spinner } from "@/components/ui/spinner"; import { requireActiveOrganizationLoader, requireNoActiveOrganizationLoader, + requirePlatformAdminLoader, redirectIfAuthenticatedLoader, } from "@/features/auth/hooks/route-loaders"; import { AuthProvider } from "@/features/auth/hooks/use-auth"; import { TimezoneProvider } from "@/features/timezone/hooks/use-timezone"; import { AddressManagementPage } from "@/pages/address-management-page"; +import { AdminPage } from "@/pages/admin-page"; import { HomePage } from "@/pages/home-page"; import { InboxPage } from "@/pages/inbox-page"; import { NotFoundPage } from "@/pages/not-found-page"; @@ -129,6 +131,20 @@ const routes: RouteObject[] = [ errorElement: , handle: { title: "Organization Onboarding" }, }, + { + path: "/admin", + loader: requirePlatformAdminLoader, + hydrateFallbackElement: hydrationFallbackElement, + element: , + errorElement: , + children: [ + { + index: true, + element: , + handle: { title: "Admin" }, + }, + ], + }, { path: "/", loader: requireActiveOrganizationLoader, diff --git a/packages/frontend/src/pages/admin-page.tsx b/packages/frontend/src/pages/admin-page.tsx new file mode 100644 index 00000000..ec07eb18 --- /dev/null +++ b/packages/frontend/src/pages/admin-page.tsx @@ -0,0 +1,1828 @@ +import * as React from "react"; +import NumberFlow from "@number-flow/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + isPlatformAdminRole, + type AdminOperationalEventType, +} from "@spinupmail/contracts"; +import { Bar, BarChart, XAxis } from "recharts"; +import { toast } from "sonner"; +import { + AlertTriangle, + Ban, + CheckCircle2, + Database, + Eye, + KeyRound, + Mail, + Mailbox, + PlugZap, + RefreshCcw, + Users, +} from "lucide-react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Alert02Icon, + ChartAnalysisIcon, + DashboardSquare01Icon, + FolderIcon, + Key01Icon, + LeftToRightListDashIcon, + UserMultiple02Icon, +} from "@/lib/hugeicons"; +import { HashTabsPage } from "@/components/layout/hash-tabs-page"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAuth } from "@/features/auth/hooks/use-auth"; +import { useTimezone } from "@/features/timezone/hooks/use-timezone"; +import { formatDashboardDayLabel } from "@/features/timezone/lib/date-format"; +import { authClient } from "@/lib/auth"; +import { + getAdminOrganizationDetail, + getAdminUserDetail, + getAdminActivity, + getAdminOverview, + listAdminApiKeys, + listAdminAnomalies, + listAdminOrganizations, + performAdminUserAction, + type AdminApiKeysResponse, + type AdminOperationalEventsResponse, + type AdminOrganizationDetailResponse, + type AdminOrganizationsResponse, + type AdminUserDetailResponse, +} from "@/lib/api"; +import { queryKeys } from "@/lib/query-keys"; + +const PAGE_SIZE = 10; +const PLATFORM_ROLE_OPTIONS = ["user", "admin"] as const; +const ANOMALY_SEVERITIES = ["all", "info", "warning", "error"] as const; +const ANOMALY_TYPES = [ + "all", + "admin_user_action", + "admin_session_action", + "admin_impersonation_started", + "inbound_rejected", + "inbound_duplicate", + "inbound_limit_reached", + "inbound_abuse_block", + "inbound_parse_failed", + "inbound_storage_failed", + "integration_dispatch_failed", + "system_error", +] as const satisfies readonly (AdminOperationalEventType | "all")[]; + +const chartConfig = { + generatedAddresses: { + label: "Addresses", + color: "var(--chart-1)", + }, + receivedEmails: { + label: "Emails", + color: "var(--chart-2)", + }, +} satisfies ChartConfig; + +type AdminUser = { + id: string; + name: string; + email: string; + role?: string | string[] | null; + banned?: boolean | null; + banReason?: string | null; + banExpires?: Date | string | null; + emailVerified?: boolean | null; + createdAt?: Date | string | null; +}; + +type AdminSession = { + id: string; + token: string; + userId: string; + expiresAt: Date | string; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; + ipAddress?: string | null; + userAgent?: string | null; +}; + +type AdminUsersResponse = { + users: AdminUser[]; + total: number; +}; + +type PendingUserAction = + | { + type: "set-role"; + user: AdminUser; + role: (typeof PLATFORM_ROLE_OPTIONS)[number]; + } + | { type: "ban"; user: AdminUser } + | { type: "unban"; user: AdminUser } + | { type: "impersonate"; user: AdminUser } + | { type: "revoke-sessions"; user: AdminUser } + | null; + +const readAuthError = (error: unknown, fallback: string) => { + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message; + } + return fallback; +}; + +const listAdminUsers = async ({ + page, + pageSize, + search, +}: { + page: number; + pageSize: number; + search: string; +}): Promise => { + const result = await authClient.admin.listUsers({ + query: { + limit: pageSize, + offset: (page - 1) * pageSize, + sortBy: "createdAt", + sortDirection: "desc", + ...(search.trim() + ? { + searchValue: search.trim(), + searchField: "email" as const, + searchOperator: "contains" as const, + } + : {}), + }, + }); + + if (result.error) { + throw new Error(readAuthError(result.error, "Unable to load users")); + } + + return { + users: (result.data?.users ?? []) as AdminUser[], + total: result.data?.total ?? 0, + }; +}; + +const listAdminUserSessions = async ( + userId: string +): Promise => { + const result = await authClient.admin.listUserSessions({ userId }); + + if (result.error) { + throw new Error(readAuthError(result.error, "Unable to load sessions")); + } + + return (result.data?.sessions ?? []) as AdminSession[]; +}; + +const formatNumber = (value: number) => value.toLocaleString(); + +const formatDate = (value: string | Date | null | undefined) => { + if (!value) return "Never"; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +}; + +const getRoleLabel = (role: AdminUser["role"]) => + Array.isArray(role) ? role.join(", ") : (role ?? "user"); + +const isAdminUser = (user: AdminUser) => isPlatformAdminRole(user.role); + +const parseRoleParts = (role: unknown) => + Array.isArray(role) + ? role.map(value => String(value).trim()) + : typeof role === "string" + ? role.split(",").map(value => value.trim()) + : []; + +const hasPlatformRole = (role: unknown, expectedRole: string) => + parseRoleParts(role).includes(expectedRole); + +const getPrimaryPlatformRole = ( + role: AdminUser["role"] +): (typeof PLATFORM_ROLE_OPTIONS)[number] => { + const parts = parseRoleParts(role); + return PLATFORM_ROLE_OPTIONS.find(option => parts.includes(option)) ?? "user"; +}; + +const formatPercent = (value: number) => + new Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 0, + }).format(value); + +const formatSignedNumber = (value: number) => + `${value > 0 ? "+" : ""}${formatNumber(value)}`; + +const getTrendDetail = (current: number, previous: number) => { + const delta = current - previous; + if (previous === 0) { + return delta > 0 + ? `${formatSignedNumber(delta)} vs previous 30d` + : "No change"; + } + return `${formatPercent(delta / previous)} vs previous 30d`; +}; + +const CompactMetric = ({ + icon: Icon, + label, + value, + detail, + loading, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: React.ReactNode; + detail?: React.ReactNode; + loading?: boolean; +}) => ( +
+ +
+
{label}
+ {loading ? ( +
+ + +
+ ) : ( +
+
{value}
+ {detail ? ( +
+ {detail} +
+ ) : null} +
+ )} +
+
+); + +const InsightRow = ({ + icon: Icon, + label, + value, + detail, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + value: React.ReactNode; + detail?: React.ReactNode; +}) => ( +
+
+ +
+
{label}
+ {detail ? ( +
{detail}
+ ) : null} +
+
+
{value}
+
+); + +const AdminOverviewPanel = () => { + const { effectiveTimeZone } = useTimezone(); + const overviewQuery = useQuery({ + queryKey: queryKeys.adminOverview, + queryFn: ({ signal }) => getAdminOverview({ signal }), + staleTime: 30_000, + }); + const activityQuery = useQuery({ + queryKey: queryKeys.adminActivity(effectiveTimeZone), + queryFn: ({ signal }) => + getAdminActivity({ days: 14, timezone: effectiveTimeZone, signal }), + staleTime: 30_000, + }); + const overview = overviewQuery.data; + const daily = activityQuery.data?.daily ?? []; + const isLoading = overviewQuery.isLoading; + const overviewUnavailable = + overviewQuery.isError || (!isLoading && !overview); + const generatedDelta = overview + ? overview.generatedAddresses.current - overview.generatedAddresses.previous + : null; + const integrationQueueCount = overview + ? overview.integrations.retryScheduled + overview.integrations.failed + : null; + + return ( +
+
+
+
+ {isLoading ? ( + <> + +
+ + +
+ + ) : overviewUnavailable ? ( + <> +
+ +
+
+ Platform status +
+
Unknown
+
+
+
+ + +
+ + ) : ( + <> +
+ {overview!.system.status === "healthy" ? ( + + ) : ( + + )} +
+
+ Platform status +
+
+ {overview!.system.status} +
+
+
+
+ + +
+ + )} +
+ +
+ + ) : ( + "Unknown" + ) + } + detail={ + generatedDelta === null + ? "Unavailable" + : `${formatSignedNumber(generatedDelta)} in 30d` + } + /> + + ) : ( + "Unknown" + ) + } + detail={ + overview + ? getTrendDetail( + overview.receivedEmails.current, + overview.receivedEmails.previous + ) + : "Unavailable" + } + /> + : "Unknown" + } + detail={ + overview + ? `${formatNumber(overview.activeUsers24h)} active in 24h` + : "Unavailable" + } + /> + + ) : ( + "Unknown" + ) + } + /> + + ) : ( + "Unknown" + ) + } + /> +
+
+ + + + + +
+ + + Activity + +
+
+ + {activityQuery.isLoading ? ( +
+ {Array.from({ length: 14 }).map((_, index) => ( + + ))} +
+ ) : ( + + + String(value).slice(-2)} + tick={{ fontSize: 10 }} + /> + + } + /> + + + + + )} +
+
+
+
+ ); +}; + +const AdminUsersPanel = () => { + const queryClient = useQueryClient(); + const { user: currentUser, refreshSession } = useAuth(); + const [page, setPage] = React.useState(1); + const [search, setSearch] = React.useState(""); + const [selectedUserId, setSelectedUserId] = React.useState( + null + ); + const [selectedSessionUser, setSelectedSessionUser] = + React.useState(null); + const [pendingAction, setPendingAction] = + React.useState(null); + const [actionReason, setActionReason] = React.useState(""); + const usersQuery = useQuery({ + queryKey: queryKeys.adminUsers(page, PAGE_SIZE, search), + queryFn: () => listAdminUsers({ page, pageSize: PAGE_SIZE, search }), + staleTime: 30_000, + }); + const sessionsQuery = useQuery({ + queryKey: queryKeys.adminUserSessions(selectedSessionUser?.id ?? null), + queryFn: () => listAdminUserSessions(selectedSessionUser?.id ?? ""), + enabled: Boolean(selectedSessionUser?.id), + staleTime: 15_000, + }); + const actionMutation = useMutation({ + mutationFn: async (action: NonNullable) => { + const reason = actionReason.trim() || undefined; + if (action.type === "set-role") { + await performAdminUserAction({ + action: "set-role", + userId: action.user.id, + role: action.role, + ...(reason ? { reason } : {}), + }); + return; + } + if (action.type === "ban") { + await performAdminUserAction({ + action: "ban", + userId: action.user.id, + reason: reason || "Administrative action", + }); + return; + } + if (action.type === "unban") { + await performAdminUserAction({ + action: "unban", + userId: action.user.id, + ...(reason ? { reason } : {}), + }); + return; + } + if (action.type === "impersonate") { + await performAdminUserAction({ + action: "impersonate", + userId: action.user.id, + ...(reason ? { reason } : {}), + }); + return; + } + await performAdminUserAction({ + action: "revoke-sessions", + userId: action.user.id, + ...(reason ? { reason } : {}), + }); + }, + onSuccess: async (_data, action) => { + const actedUserId = action.user.id; + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["app", "admin", "users"] }), + queryClient.invalidateQueries({ + queryKey: queryKeys.adminUserSessions(actedUserId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.adminUserDetail(actedUserId), + }), + ]); + setPendingAction(null); + setActionReason(""); + if (action.type === "impersonate") { + await refreshSession(); + } + }, + }); + const revokeSessionMutation = useMutation({ + mutationFn: async ({ + sessionToken, + userId, + }: { + sessionToken: string; + userId: string; + }) => { + await performAdminUserAction({ + action: "revoke-session", + userId, + sessionToken, + }); + }, + onSuccess: async (_data, action) => { + const userId = action.userId; + await queryClient.invalidateQueries({ + queryKey: queryKeys.adminUserSessions(userId), + }); + }, + }); + const users = usersQuery.data?.users ?? []; + const total = usersQuery.data?.total ?? 0; + const totalPages = total === 0 ? 0 : Math.ceil(total / PAGE_SIZE); + const canImpersonate = hasPlatformRole( + (currentUser as { role?: unknown } | null)?.role, + "admin" + ); + + return ( +
+
+ { + setPage(1); + setSearch(event.target.value); + }} + /> + +
+ + + {users.map(user => ( + + +
+ {user.name || "Unnamed"} + + {user.email} + +
+
+ + + {getRoleLabel(user.role)} + + + + {user.banned ? ( + Banned + ) : ( + Active + )} + + {formatDate(user.createdAt)} + +
+ + + + + {canImpersonate ? ( + + ) : null} +
+
+
+ ))} +
+ + setPage(value => Math.max(1, value - 1))} + onNext={() => setPage(value => value + 1)} + /> + + { + if (!open && !actionMutation.isPending) { + setPendingAction(null); + setActionReason(""); + } + }} + > + + + {getActionTitle(pendingAction)} + + {getActionDescription(pendingAction)} + + + {actionMutation.error ? ( +

+ {(actionMutation.error as Error).message} +

+ ) : null} + {pendingAction ? ( + setActionReason(event.target.value)} + /> + ) : null} + + + Cancel + + { + event.preventDefault(); + if (pendingAction) actionMutation.mutate(pendingAction); + }} + > + {actionMutation.isPending ? + +
+
+ + { + if (!open) setSelectedSessionUser(null); + }} + > + + + Sessions + + {selectedSessionUser?.email ?? "Selected user"} + + +
+ + + + Created + Expires + IP + Action + + + + {sessionsQuery.isLoading ? ( + + ) : ( + (sessionsQuery.data ?? []).map(session => ( + + {formatDate(session.createdAt)} + {formatDate(session.expiresAt)} + {session.ipAddress ?? "Unknown"} + + + + + )) + )} + +
+
+ + + Close + +
+
+ + { + if (!open) setSelectedUserId(null); + }} + /> +
+ ); +}; + +const getActionTitle = (action: PendingUserAction) => { + if (!action) return "Confirm action"; + if (action.type === "set-role") return `Set role to ${action.role}`; + if (action.type === "ban") return "Ban user"; + if (action.type === "unban") return "Unban user"; + if (action.type === "impersonate") return "Impersonate user"; + return "Revoke sessions"; +}; + +const getActionDescription = (action: PendingUserAction) => { + if (!action) return ""; + if (action.type === "set-role") { + return `${action.user.email} will receive the ${action.role} role.`; + } + if (action.type === "ban") { + return `${action.user.email} will be blocked from signing in.`; + } + if (action.type === "unban") { + return `${action.user.email} will be allowed to sign in again.`; + } + if (action.type === "impersonate") { + return `You will start a temporary session as ${action.user.email}. This is audited.`; + } + return `All active sessions for ${action.user.email} will be revoked.`; +}; + +const AdminOrganizationsPanel = () => { + const [page, setPage] = React.useState(1); + const [selectedOrganizationId, setSelectedOrganizationId] = React.useState< + string | null + >(null); + const organizationsQuery = useQuery({ + queryKey: queryKeys.adminOrganizations(page, PAGE_SIZE), + queryFn: ({ signal }) => + listAdminOrganizations({ page, pageSize: PAGE_SIZE, signal }), + staleTime: 30_000, + }); + const organizations = organizationsQuery.data?.items ?? []; + + return ( +
+ + {organizations.map(org => ( + + +
+ {org.name} + + {org.slug} + +
+
+ {formatNumber(org.memberCount)} + {formatNumber(org.addressCount)} + {formatNumber(org.receivedEmailCount)} + + {formatNumber(org.activeIntegrationCount)} /{" "} + {formatNumber(org.integrationCount)} + + {formatDate(org.lastReceivedAt)} + + + +
+ ))} +
+ setPage(value => Math.max(1, value - 1))} + onNext={() => setPage(value => value + 1)} + /> + { + if (!open) setSelectedOrganizationId(null); + }} + /> +
+ ); +}; + +const AdminAnomaliesPanel = () => { + const [page, setPage] = React.useState(1); + const [selectedEvent, setSelectedEvent] = React.useState< + AdminOperationalEventsResponse["items"][number] | null + >(null); + const [severity, setSeverity] = + React.useState<(typeof ANOMALY_SEVERITIES)[number]>("all"); + const [type, setType] = React.useState<(typeof ANOMALY_TYPES)[number]>("all"); + const [organizationId, setOrganizationId] = React.useState(""); + const [fromDate, setFromDate] = React.useState(""); + const [toDate, setToDate] = React.useState(""); + const anomaliesQuery = useQuery({ + queryKey: queryKeys.adminAnomalies({ + page, + pageSize: PAGE_SIZE, + severity, + type, + organizationId, + from: fromDate, + to: toDate, + }), + queryFn: ({ signal }) => + listAdminAnomalies({ + page, + pageSize: PAGE_SIZE, + severity: severity === "all" ? undefined : severity, + type: type === "all" ? undefined : type, + organizationId: organizationId.trim() || undefined, + from: fromDate ? `${fromDate}T00:00:00` : undefined, + to: toDate ? `${toDate}T23:59:59.999` : undefined, + signal, + }), + staleTime: 30_000, + }); + const anomalies = anomaliesQuery.data?.items ?? []; + + return ( +
+
+ { + setPage(1); + setOrganizationId(event.target.value); + }} + /> + + + { + setPage(1); + setFromDate(event.target.value); + }} + /> + { + setPage(1); + setToDate(event.target.value); + }} + /> +
+ + + {anomalies.map(event => ( + + +
+ {event.message} + + {event.type.replaceAll("_", " ")} + +
+
+ + + + + {event.organizationName ?? event.organizationId ?? "System"} + + {formatDate(event.createdAt)} + + + +
+ ))} +
+ setPage(value => Math.max(1, value - 1))} + onNext={() => setPage(value => value + 1)} + /> + { + if (!open) setSelectedEvent(null); + }} + /> +
+ ); +}; + +const SeverityBadge = ({ severity }: { severity: string }) => { + if (severity === "error") return Error; + if (severity === "warning") return Warning; + return Info; +}; + +const DetailRow = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( +
+ {label} + + {value} + +
+); + +const JsonBlock = ({ value }: { value: unknown }) => ( +
+    {JSON.stringify(value ?? {}, null, 2)}
+  
+); + +const UserDetailSheet = ({ + userId, + onOpenChange, +}: { + userId: string | null; + onOpenChange: (open: boolean) => void; +}) => { + const detailQuery = useQuery({ + queryKey: queryKeys.adminUserDetail(userId), + queryFn: ({ signal }) => getAdminUserDetail(userId ?? "", { signal }), + enabled: Boolean(userId), + staleTime: 15_000, + }); + const detail = detailQuery.data; + + return ( + + + + {detail?.user.email ?? "User detail"} + + Account, sessions, organizations, API keys, and recent audit events. + + + {detailQuery.isLoading ? ( +
+ + +
+ ) : detail ? ( +
+
+

+ Account +

+ + + + + + +
+ +
+

+ Organizations +

+ {detail.memberships.length > 0 ? ( + detail.memberships.map(membership => ( + + )) + ) : ( +

No memberships.

+ )} +
+ +
+

+ API Keys +

+ {detail.apiKeys.length > 0 ? ( + detail.apiKeys.map(key => ( + + )) + ) : ( +

No API keys.

+ )} +
+ +
+

+ Recent Audit +

+ {detail.recentEvents.length > 0 ? ( + detail.recentEvents.map(event => ( + + )) + ) : ( +

+ No recent audit events. +

+ )} +
+
+ ) : ( +

User not found.

+ )} +
+
+ ); +}; + +const OrganizationDetailSheet = ({ + organizationId, + onOpenChange, +}: { + organizationId: string | null; + onOpenChange: (open: boolean) => void; +}) => { + const detailQuery = useQuery({ + queryKey: queryKeys.adminOrganizationDetail(organizationId), + queryFn: ({ signal }) => + getAdminOrganizationDetail(organizationId ?? "", { signal }), + enabled: Boolean(organizationId), + staleTime: 15_000, + }); + const detail = detailQuery.data; + + return ( + + + + + {detail?.organization.name ?? "Organization detail"} + + + Members, invitations, integrations, API keys, and recent events. + + + {detailQuery.isLoading ? ( +
+ + +
+ ) : detail ? ( +
+
+

+ Usage +

+ + + + + +
+ +
+

+ Members +

+ {detail.members.map(member => ( + + ))} +
+ +
+

+ Invitations +

+ {detail.invitations.length > 0 ? ( + detail.invitations.map(invitation => ( + + )) + ) : ( +

No invitations.

+ )} +
+ +
+

+ Integrations +

+ {detail.integrations.length > 0 ? ( + detail.integrations.map(integration => ( + + )) + ) : ( +

+ No integrations. +

+ )} +
+
+ ) : ( +

+ Organization not found. +

+ )} +
+
+ ); +}; + +const OperationalEventSheet = ({ + event, + onOpenChange, +}: { + event: AdminOperationalEventsResponse["items"][number] | null; + onOpenChange: (open: boolean) => void; +}) => ( + + + + {event?.message ?? "Event detail"} + {event?.type.replaceAll("_", " ")} + + {event ? ( +
+ } + /> + + + + + +
+

+ Metadata +

+ +
+
+ ) : null} +
+
+); + +const AdminApiKeysPanel = () => { + const [page, setPage] = React.useState(1); + const apiKeysQuery = useQuery({ + queryKey: queryKeys.adminApiKeys(page, PAGE_SIZE), + queryFn: ({ signal }) => + listAdminApiKeys({ page, pageSize: PAGE_SIZE, signal }), + staleTime: 30_000, + }); + const apiKeys = apiKeysQuery.data?.items ?? []; + + return ( +
+ + {apiKeys.map(key => ( + + +
+ {key.name ?? "Unnamed key"} + + {key.prefix ?? ""} + {key.start ?? key.id} + +
+
+ +
+ {key.ownerLabel ?? key.referenceId} + + {key.ownerType} + +
+
+ + + {key.enabled === false ? "Disabled" : "Enabled"} + + + {formatNumber(key.requestCount)} + {formatDate(key.lastRequest)} + {formatDate(key.expiresAt)} +
+ ))} +
+ setPage(value => Math.max(1, value - 1))} + onNext={() => setPage(value => value + 1)} + /> +
+ ); +}; + +const AdminAuditPanel = () => { + const [page, setPage] = React.useState(1); + const [type, setType] = React.useState< + "admin_user_action" | "admin_session_action" | "admin_impersonation_started" + >("admin_user_action"); + const severity = "info" as const; + const [selectedEvent, setSelectedEvent] = React.useState< + AdminOperationalEventsResponse["items"][number] | null + >(null); + const auditQuery = useQuery({ + queryKey: queryKeys.adminAnomalies({ + page, + pageSize: PAGE_SIZE, + severity, + type, + organizationId: "", + from: "", + to: "", + }), + queryFn: ({ signal }) => + listAdminAnomalies({ + page, + pageSize: PAGE_SIZE, + severity, + type, + signal, + }), + staleTime: 15_000, + }); + const events = auditQuery.data?.items ?? []; + + return ( +
+ + + {events.map(event => ( + + {event.message} + + {typeof event.metadata?.actorEmail === "string" + ? event.metadata.actorEmail + : "Unknown"} + + + {typeof event.metadata?.targetId === "string" + ? event.metadata.targetId + : "None"} + + {formatDate(event.createdAt)} + + + + + ))} + + setPage(value => Math.max(1, value - 1))} + onNext={() => setPage(value => value + 1)} + /> + { + if (!open) setSelectedEvent(null); + }} + /> +
+ ); +}; + +const AdminTableShell = ({ + columns, + loading, + children, +}: { + columns: string[]; + loading: boolean; + children: React.ReactNode; +}) => ( +
+ + + + {columns.map(column => ( + {column} + ))} + + + + {loading ? : children} + +
+
+); + +const SkeletonRows = ({ columns }: { columns: number }) => ( + <> + {Array.from({ length: 5 }).map((_, rowIndex) => ( + + {Array.from({ length: columns }).map((__, columnIndex) => ( + + + + ))} + + ))} + +); + +const PaginationFooter = ({ + page, + totalPages, + onPrevious, + onNext, +}: { + page: number; + totalPages: number; + onPrevious: () => void; + onNext: () => void; +}) => ( +
+ + Page {page} + {totalPages > 0 ? ` of ${totalPages}` : ""} + + + +
+); + +const adminSections = [ + { + id: "overview", + label: "Overview", + icon: DashboardSquare01Icon, + content: , + }, + { + id: "users", + label: "Users", + icon: UserMultiple02Icon, + content: , + }, + { + id: "organizations", + label: "Organizations", + icon: FolderIcon, + content: , + }, + { + id: "anomalies", + label: "Anomalies", + icon: Alert02Icon, + content: , + }, + { + id: "api-keys", + label: "API Keys", + icon: Key01Icon, + content: , + }, + { + id: "audit", + label: "Audit", + icon: LeftToRightListDashIcon, + content: , + }, +]; + +export const AdminPage = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/src/pages/protected-layout-page.tsx b/packages/frontend/src/pages/protected-layout-page.tsx index a03eba14..7fc6da68 100644 --- a/packages/frontend/src/pages/protected-layout-page.tsx +++ b/packages/frontend/src/pages/protected-layout-page.tsx @@ -49,7 +49,7 @@ const HeaderSidebarTrigger = () => { export const ProtectedLayoutPage = () => { const navigate = useNavigate(); const matches = useMatches(); - const { user, isLoading, signOut } = useAuth(); + const { user, isLoading, refreshSession, signOut } = useAuth(); const [signOutError, setSignOutError] = React.useState(null); @@ -74,7 +74,11 @@ export const ProtectedLayoutPage = () => { return ( - +
diff --git a/packages/frontend/src/pages/tests/protected-layout-page.test.tsx b/packages/frontend/src/pages/tests/protected-layout-page.test.tsx index 494c0fbc..c896bedb 100644 --- a/packages/frontend/src/pages/tests/protected-layout-page.test.tsx +++ b/packages/frontend/src/pages/tests/protected-layout-page.test.tsx @@ -63,6 +63,7 @@ const buildMockUser = (overrides?: Partial): AuthUser => ({ createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), twoFactorEnabled: false, + banned: false, ...overrides, }); diff --git a/packages/landing/src/components/docs/content/docs-content.ts b/packages/landing/src/components/docs/content/docs-content.ts index 0ab18874..b45b8fd5 100644 --- a/packages/landing/src/components/docs/content/docs-content.ts +++ b/packages/landing/src/components/docs/content/docs-content.ts @@ -515,7 +515,7 @@ const DOC_INDEX: Partial> = { searchText: "limits security organizations members addresses attachments raw email rate limiting integration dispatch telegram verification resend X-Org-Id", codeText: - "MAX_ADDRESSES_PER_ORGANIZATION MAX_INTEGRATIONS_PER_ORGANIZATION MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS INTEGRATION_QUEUE_BASE_DELAY_SECONDS INTEGRATION_QUEUE_MAX_DELAY_SECONDS INTEGRATION_QUEUE_JITTER_SECONDS EMAIL_MAX_BYTES EMAIL_BODY_MAX_BYTES EMAIL_ATTACHMENT_MAX_BYTES EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION EMAIL_ATTACHMENTS_ENABLED API_KEY_RATE_LIMIT_WINDOW API_KEY_RATE_LIMIT_MAX AUTH_RATE_LIMIT_WINDOW AUTH_RATE_LIMIT_MAX AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX EMAIL_STORE_HEADERS_IN_DB EMAIL_STORE_RAW_IN_DB EMAIL_STORE_RAW_IN_R2", + "MAX_ADDRESSES_PER_ORGANIZATION MAX_INTEGRATIONS_PER_ORGANIZATION MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS INTEGRATION_QUEUE_BASE_DELAY_SECONDS INTEGRATION_QUEUE_MAX_DELAY_SECONDS INTEGRATION_QUEUE_JITTER_SECONDS EMAIL_MAX_BYTES EMAIL_BODY_MAX_BYTES EMAIL_ATTACHMENT_MAX_BYTES EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION EMAIL_ATTACHMENTS_ENABLED OPERATIONAL_EVENT_RETENTION_DAYS OPERATIONAL_EVENT_MAX_METADATA_BYTES OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX API_KEY_RATE_LIMIT_WINDOW API_KEY_RATE_LIMIT_MAX AUTH_RATE_LIMIT_WINDOW AUTH_RATE_LIMIT_MAX AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX EMAIL_STORE_HEADERS_IN_DB EMAIL_STORE_RAW_IN_DB EMAIL_STORE_RAW_IN_R2", }, }; diff --git a/packages/landing/src/content/docs/cloudflare-resources.mdx b/packages/landing/src/content/docs/cloudflare-resources.mdx index ec4b9aa0..d6d0d0e3 100644 --- a/packages/landing/src/content/docs/cloudflare-resources.mdx +++ b/packages/landing/src/content/docs/cloudflare-resources.mdx @@ -28,24 +28,24 @@ pnpm exec wrangler d1 create SUM_DB pnpm exec wrangler kv namespace create SUM_KV pnpm exec wrangler r2 bucket create spinupmail-attachments pnpm exec wrangler r2 bucket create spinupmail-attachments-preview -pnpm wrangler queues create spinupmail-integration-dispatches +pnpm exec wrangler queues create spinupmail-integration-dispatches ``` Record the values each command returns. You will need them when you edit `wrangler.toml`. -| Resource | Create command | Save these values | -| --- | --- | --- | -| D1 database | `pnpm exec wrangler d1 create SUM_DB` | `binding`, `database_name`, `database_id` | -| KV namespace | `pnpm exec wrangler kv namespace create SUM_KV` | `binding`, `id` | -| R2 bucket | `pnpm exec wrangler r2 bucket create spinupmail-attachments` | `bucket_name` | -| R2 preview bucket | `pnpm exec wrangler r2 bucket create spinupmail-attachments-preview` | `bucket_name` | -| Integration dispatch queue | `pnpm wrangler queues create spinupmail-integration-dispatches` | `queue_name` | +| Resource | Create command | Save these values | +| -------------------------- | -------------------------------------------------------------------- | ----------------------------------------- | +| D1 database | `pnpm exec wrangler d1 create SUM_DB` | `binding`, `database_name`, `database_id` | +| KV namespace | `pnpm exec wrangler kv namespace create SUM_KV` | `binding`, `id` | +| R2 bucket | `pnpm exec wrangler r2 bucket create spinupmail-attachments` | `bucket_name` | +| R2 preview bucket | `pnpm exec wrangler r2 bucket create spinupmail-attachments-preview` | `bucket_name` | +| Integration dispatch queue | `pnpm exec wrangler queues create spinupmail-integration-dispatches` | `queue_name` | - The Durable Object binding and migration already exist in the example - Wrangler config. Cloudflare creates the namespace when you deploy the Worker, - and individual instances are created on first use. + The Durable Object binding and migration already exist in the example Wrangler + config. Cloudflare creates the namespace when you deploy the Worker, and + individual instances are created on first use. ## Copy the backend Wrangler config @@ -93,38 +93,41 @@ control routing, email limits, and optional product behavior. ### Required variables -| Variable | Purpose | -| --- | --- | -| `EMAIL_DOMAINS` | Comma-separated inbound domains handled by Email Routing, such as `spinupmail.com` or `spinupmail.com,spinupmail.dev` | -| `RESEND_FROM_EMAIL` | Verified sender used for Better Auth verification and password-reset email | +| Variable | Purpose | +| ------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `EMAIL_DOMAINS` | Comma-separated inbound domains handled by Email Routing, such as `spinupmail.com` or `spinupmail.com,spinupmail.dev` | +| `RESEND_FROM_EMAIL` | Verified sender used for Better Auth verification and password-reset email | ### Common optional variables -| Variable | Default or usage | -| --- | --- | -| `AUTH_ALLOWED_EMAIL_DOMAIN` | Restricts sign-up and sign-in to one email domain for internal deployments | -| `FORCED_MAIL_PREFIX` | Forces every created or renamed inbox local part to start with this prefix plus `-`; unset disables the feature | -| `EMAIL_MAX_BYTES` | Max raw email bytes parsed by the Worker | -| `EMAIL_BODY_MAX_BYTES` | Max HTML and text body bytes persisted in D1 | -| `EMAIL_FORWARD_TO` | Optional forward target | -| `EMAIL_ATTACHMENT_MAX_BYTES` | Max size per attachment uploaded to R2 | -| `EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION` | Total attachment quota per organization, default `104857600` | -| `EMAIL_ATTACHMENTS_ENABLED` | Toggle attachment processing, default `true` | -| `MAX_ADDRESSES_PER_ORGANIZATION` | Address cap per org, default `10` | -| `MAX_RECEIVED_EMAILS_PER_ADDRESS` | Default and hard cap for stored emails per inbox, default `100` | -| `MAX_RECEIVED_EMAILS_PER_ORGANIZATION` | Total stored-email cap across one org, default `1000` | -| `MAX_INTEGRATIONS_PER_ORGANIZATION` | Integration cap per org, default `3` | -| `MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY` | Daily integration dispatch cap per org, default `100` | -| `INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS` | Retry window for integration dispatch failures, default `21600` | -| `INTEGRATION_QUEUE_BASE_DELAY_SECONDS` | Initial retry delay for integration dispatches, default `30` | -| `INTEGRATION_QUEUE_MAX_DELAY_SECONDS` | Maximum retry delay for integration dispatches, default `1800` | -| `INTEGRATION_QUEUE_JITTER_SECONDS` | Retry jitter to spread retries, default `10` | -| `API_KEY_RATE_LIMIT_WINDOW` and `API_KEY_RATE_LIMIT_MAX` | Rate limit window and max for `x-api-key` traffic | -| `AUTH_RATE_LIMIT_WINDOW` and `AUTH_RATE_LIMIT_MAX` | Better Auth global rate limiting | -| `AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW` and `AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX` | Change-email rate limiting | -| `EMAIL_STORE_HEADERS_IN_DB` | Persist full headers in D1 | -| `EMAIL_STORE_RAW_IN_DB` | Persist raw MIME in D1 | -| `EMAIL_STORE_RAW_IN_R2` | Persist raw MIME in private R2 for debugging | +| Variable | Default or usage | +| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `AUTH_ALLOWED_EMAIL_DOMAIN` | Restricts sign-up and sign-in to one email domain for internal deployments | +| `FORCED_MAIL_PREFIX` | Forces every created or renamed inbox local part to start with this prefix plus `-`; unset disables the feature | +| `EMAIL_MAX_BYTES` | Max raw email bytes parsed by the Worker | +| `EMAIL_BODY_MAX_BYTES` | Max HTML and text body bytes persisted in D1 | +| `EMAIL_FORWARD_TO` | Optional forward target | +| `EMAIL_ATTACHMENT_MAX_BYTES` | Max size per attachment uploaded to R2 | +| `EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION` | Total attachment quota per organization, default `104857600` | +| `EMAIL_ATTACHMENTS_ENABLED` | Toggle attachment processing, default `true` | +| `MAX_ADDRESSES_PER_ORGANIZATION` | Address cap per org, default `10` | +| `MAX_RECEIVED_EMAILS_PER_ADDRESS` | Default and hard cap for stored emails per inbox, default `100` | +| `MAX_RECEIVED_EMAILS_PER_ORGANIZATION` | Total stored-email cap across one org, default `1000` | +| `OPERATIONAL_EVENT_RETENTION_DAYS` | Operational-event retention window, default `30` | +| `OPERATIONAL_EVENT_MAX_METADATA_BYTES` | Serialized metadata cap per operational event, default `4096` | +| `OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS` and `OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX` | Stored noisy-event cap, default `1` per `300` seconds | +| `MAX_INTEGRATIONS_PER_ORGANIZATION` | Integration cap per org, default `3` | +| `MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY` | Daily integration dispatch cap per org, default `100` | +| `INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS` | Retry window for integration dispatch failures, default `21600` | +| `INTEGRATION_QUEUE_BASE_DELAY_SECONDS` | Initial retry delay for integration dispatches, default `30` | +| `INTEGRATION_QUEUE_MAX_DELAY_SECONDS` | Maximum retry delay for integration dispatches, default `1800` | +| `INTEGRATION_QUEUE_JITTER_SECONDS` | Retry jitter to spread retries, default `10` | +| `API_KEY_RATE_LIMIT_WINDOW` and `API_KEY_RATE_LIMIT_MAX` | Rate limit window and max for `x-api-key` traffic | +| `AUTH_RATE_LIMIT_WINDOW` and `AUTH_RATE_LIMIT_MAX` | Better Auth global rate limiting | +| `AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW` and `AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX` | Change-email rate limiting | +| `EMAIL_STORE_HEADERS_IN_DB` | Persist full headers in D1 | +| `EMAIL_STORE_RAW_IN_DB` | Persist raw MIME in D1 | +| `EMAIL_STORE_RAW_IN_R2` | Persist raw MIME in private R2 for debugging | ## Domain strategy and routing prep diff --git a/packages/landing/src/content/docs/installation.mdx b/packages/landing/src/content/docs/installation.mdx index 955c1d2d..3bc8b179 100644 --- a/packages/landing/src/content/docs/installation.mdx +++ b/packages/landing/src/content/docs/installation.mdx @@ -69,7 +69,7 @@ Spinupmail dispatches integration events asynchronously through a queue. Create the queue used for integration dispatch jobs: ```bash -pnpm wrangler queues create spinupmail-integration-dispatches +pnpm exec wrangler queues create spinupmail-integration-dispatches ``` See [Integrations](/docs/integrations) for provider setup and subscription diff --git a/packages/landing/src/content/docs/limits-security.mdx b/packages/landing/src/content/docs/limits-security.mdx index e7418fa8..45f3f14c 100644 --- a/packages/landing/src/content/docs/limits-security.mdx +++ b/packages/landing/src/content/docs/limits-security.mdx @@ -20,12 +20,12 @@ settings you can override in `wrangler.toml`. These limits are part of the current Spinupmail product behavior. -| Limit | Current value | -| --- | --- | -| Organizations per user | `3`, configurable in backend auth setup | -| Members per organization | `10`, configurable in backend auth setup | -| Addresses per organization | `100` by default, configurable with `MAX_ADDRESSES_PER_ORGANIZATION` | -| Stored emails per address | `100` by default, configurable with `MAX_RECEIVED_EMAILS_PER_ADDRESS` | +| Limit | Current value | +| ------------------------------ | --------------------------------------------------------------------------- | +| Organizations per user | `3`, configurable in backend auth setup | +| Members per organization | `10`, configurable in backend auth setup | +| Addresses per organization | `10` by default, configurable with `MAX_ADDRESSES_PER_ORGANIZATION` | +| Stored emails per address | `100` by default, configurable with `MAX_RECEIVED_EMAILS_PER_ADDRESS` | | Stored emails per organization | `1000` by default, configurable with `MAX_RECEIVED_EMAILS_PER_ORGANIZATION` | If an organization reaches its address cap, new address creation is blocked @@ -43,19 +43,26 @@ currently deployed limits. These settings control how much inbound mail Spinupmail accepts and stores. -| Variable | Default | -| --- | --- | -| `EMAIL_MAX_BYTES` | `524288` bytes for the raw inbound message | -| `EMAIL_BODY_MAX_BYTES` | `524288` bytes persisted per email body in D1 | -| `EMAIL_ATTACHMENT_MAX_BYTES` | `10485760` bytes per attachment | -| `EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION` | `104857600` bytes total per organization | -| `EMAIL_ATTACHMENTS_ENABLED` | `true` | -| `MAX_RECEIVED_EMAILS_PER_ADDRESS` | `100` stored emails per inbox by default | -| `MAX_RECEIVED_EMAILS_PER_ORGANIZATION` | `1000` stored emails across the organization by default | +| Variable | Default | +| --------------------------------------------------- | ------------------------------------------------------- | +| `EMAIL_MAX_BYTES` | `10485760` bytes for the raw inbound message | +| `EMAIL_BODY_MAX_BYTES` | `524288` bytes persisted per email body in D1 | +| `EMAIL_ATTACHMENT_MAX_BYTES` | `10485760` bytes per attachment | +| `EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION` | `104857600` bytes total per organization | +| `EMAIL_ATTACHMENTS_ENABLED` | `true` | +| `MAX_RECEIVED_EMAILS_PER_ADDRESS` | `100` stored emails per inbox by default | +| `MAX_RECEIVED_EMAILS_PER_ORGANIZATION` | `1000` stored emails across the organization by default | +| `OPERATIONAL_EVENT_RETENTION_DAYS` | `30` days of admin operational events | +| `OPERATIONAL_EVENT_MAX_METADATA_BYTES` | `4096` bytes of serialized metadata per event | +| `OPERATIONAL_EVENT_NOISY_RATE_LIMIT_MAX` | `1` stored noisy event per normalized identity/window | +| `OPERATIONAL_EVENT_NOISY_RATE_LIMIT_WINDOW_SECONDS` | `300` seconds | Oversized message bodies are dropped before D1 persistence to avoid write failures. Oversized attachments are skipped, and new attachments are also skipped once an organization would exceed its total attachment quota. +Operational events are pruned by the scheduled Worker, and repeated noisy +inbound events such as rejects, duplicates, limit hits, and abuse blocks are +rate-limited before they write to D1. For inbox retention, Spinupmail enforces both limits together: @@ -75,11 +82,11 @@ For inbox retention, Spinupmail enforces both limits together: The default posture is intentionally conservative. -| Variable | Default | Effect | -| --- | --- | --- | +| Variable | Default | Effect | +| --------------------------- | ------- | ------------------------------ | | `EMAIL_STORE_HEADERS_IN_DB` | `false` | Persist full header JSON in D1 | -| `EMAIL_STORE_RAW_IN_DB` | `false` | Persist raw MIME in D1 | -| `EMAIL_STORE_RAW_IN_R2` | `false` | Persist raw MIME in private R2 | +| `EMAIL_STORE_RAW_IN_DB` | `false` | Persist raw MIME in D1 | +| `EMAIL_STORE_RAW_IN_R2` | `false` | Persist raw MIME in private R2 | Raw MIME storage is disabled by default to reduce storage overhead and avoid @@ -91,14 +98,14 @@ The default posture is intentionally conservative. Spinupmail applies both Better Auth throttling and API key throttling. -| Control | Current default | -| --- | --- | -| `API_KEY_RATE_LIMIT_WINDOW` | `60` seconds | -| `API_KEY_RATE_LIMIT_MAX` | `120` requests | -| `AUTH_RATE_LIMIT_WINDOW` | `60` seconds | -| `AUTH_RATE_LIMIT_MAX` | Optional override, otherwise Better Auth default | -| `AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW` | `3600` seconds | -| `AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX` | `2` | +| Control | Current default | +| ------------------------------------- | ------------------------------------------------ | +| `API_KEY_RATE_LIMIT_WINDOW` | `60` seconds | +| `API_KEY_RATE_LIMIT_MAX` | `120` requests | +| `AUTH_RATE_LIMIT_WINDOW` | `60` seconds | +| `AUTH_RATE_LIMIT_MAX` | Optional override, otherwise Better Auth default | +| `AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW` | `3600` seconds | +| `AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX` | `2` | API key throttling also covers Better Auth runtime checks on `/get-session` and `/organization/get-full-organization`, not just the application endpoints. @@ -108,14 +115,14 @@ API key throttling also covers Better Auth runtime checks on `/get-session` and Integration dispatches are also protected by organization-level caps and queue retry controls. -| Control | Current default | -| --- | --- | -| `MAX_INTEGRATIONS_PER_ORGANIZATION` | `3` | -| `MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY` | `100` | -| `INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS` | `21600` seconds | -| `INTEGRATION_QUEUE_BASE_DELAY_SECONDS` | `30` seconds | -| `INTEGRATION_QUEUE_MAX_DELAY_SECONDS` | `1800` seconds | -| `INTEGRATION_QUEUE_JITTER_SECONDS` | `10` seconds | +| Control | Current default | +| ----------------------------------------------------- | --------------- | +| `MAX_INTEGRATIONS_PER_ORGANIZATION` | `3` | +| `MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY` | `100` | +| `INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS` | `21600` seconds | +| `INTEGRATION_QUEUE_BASE_DELAY_SECONDS` | `30` seconds | +| `INTEGRATION_QUEUE_MAX_DELAY_SECONDS` | `1800` seconds | +| `INTEGRATION_QUEUE_JITTER_SECONDS` | `10` seconds | Dispatches run asynchronously through the integration queue and retry with backoff inside the configured retry window. @@ -124,11 +131,11 @@ backoff inside the configured retry window. Email verification resend is also guarded by backend constants: -| Control | Current value | -| --- | --- | -| Cooldown between resend attempts | `60` seconds | -| IP window | `300` seconds | -| Max resend attempts per IP window | `5` | +| Control | Current value | +| --------------------------------- | ------------- | +| Cooldown between resend attempts | `60` seconds | +| IP window | `300` seconds | +| Max resend attempts per IP window | `5` | ## Retrieval boundaries