From 0d77f3950e1656aabf91460a6b41d336c2915dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 19:38:44 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(sso):=20=E2=9C=A8=20add=2012=20built-i?= =?UTF-8?q?n=20SSO=20providers=20with=20PKCE,=20OIDC=20and=20email=20confi?= =?UTF-8?q?rmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Apple, Atlassian, GitLab, Kick, LinkedIn, Microsoft, PayPal, Reddit, Slack, TikTok, Twitch and Twitter (X) adapters under @vitnode/core/api/adapters/sso/*. Shared changes: - Sign-in state is a server-side, single-use attempt bound to the browser and provider, carrying a PKCE verifier and OIDC nonce. - ID tokens are validated with jose (signature, alg, iss, aud, exp, nonce). - Returning identities sign in by provider account id, without an email. - Identities without a trusted email confirm an address with a one-time code before an account is created or linked. - Apple's form_post callback is turned into a same-URL GET by the Start middleware, without relaxing cookie or CSRF rules. - Connecting a new account from Settings needs a sign-in within the hour. - Migration adds core_sso_attempts, core_sso_pending and PKCE/nonce columns on core_users_sso_operations. Docs: one guide per provider, an email confirmation page, and corrected Discord, Google, Facebook, custom adapter and connected-accounts guides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01S9WHKDMZd6SGLUjdYbi4hT --- apps/api/.env.example | 39 + .../migration.sql | 34 + .../snapshot.json | 20631 ++++++++++++++++ apps/api/src/vitnode.api.config.ts | 19 + apps/web/content/docs/dev/sso/apple.mdx | 188 + apps/web/content/docs/dev/sso/atlassian.mdx | 142 + .../docs/dev/sso/connected-accounts.mdx | 8 +- .../content/docs/dev/sso/custom-adapter.mdx | 52 +- apps/web/content/docs/dev/sso/discord.mdx | 13 +- .../docs/dev/sso/email-confirmation.mdx | 106 + .../dev/sso/email-confirmation/code-step.webp | Bin 0 -> 40988 bytes .../sso/email-confirmation/email-step.webp | Bin 0 -> 29386 bytes apps/web/content/docs/dev/sso/facebook.mdx | 12 +- apps/web/content/docs/dev/sso/gitlab.mdx | 174 + apps/web/content/docs/dev/sso/google.mdx | 12 +- apps/web/content/docs/dev/sso/icons.mdx | 10 +- apps/web/content/docs/dev/sso/index.mdx | 151 +- apps/web/content/docs/dev/sso/kick.mdx | 136 + apps/web/content/docs/dev/sso/linkedin.mdx | 144 + apps/web/content/docs/dev/sso/meta.json | 17 +- apps/web/content/docs/dev/sso/microsoft.mdx | 175 + apps/web/content/docs/dev/sso/paypal.mdx | 164 + apps/web/content/docs/dev/sso/reddit.mdx | 140 + apps/web/content/docs/dev/sso/slack.mdx | 152 + apps/web/content/docs/dev/sso/tiktok.mdx | 161 + apps/web/content/docs/dev/sso/twitch.mdx | 132 + apps/web/content/docs/dev/sso/twitter.mdx | 170 + .../docs/dev/sso/using-connected-accounts.mdx | 6 +- packages/vitnode/package.json | 1 + .../vitnode/src/api/adapters/sso/apple.ts | 234 + .../vitnode/src/api/adapters/sso/atlassian.ts | 128 + .../vitnode/src/api/adapters/sso/gitlab.ts | 205 + .../vitnode/src/api/adapters/sso/icons.ts | 36 + packages/vitnode/src/api/adapters/sso/kick.ts | 137 + .../vitnode/src/api/adapters/sso/linkedin.ts | 130 + .../vitnode/src/api/adapters/sso/microsoft.ts | 237 + .../vitnode/src/api/adapters/sso/paypal.ts | 182 + .../src/api/adapters/sso/profile.test.ts | 14 +- .../src/api/adapters/sso/providers.test.ts | 1121 + .../vitnode/src/api/adapters/sso/reddit.ts | 139 + .../vitnode/src/api/adapters/sso/slack.ts | 155 + .../vitnode/src/api/adapters/sso/tiktok.ts | 134 + .../vitnode/src/api/adapters/sso/twitch.ts | 156 + .../vitnode/src/api/adapters/sso/twitter.ts | 153 + .../vitnode/src/api/lib/sso-protocol.test.ts | 218 + packages/vitnode/src/api/lib/sso-protocol.ts | 238 + packages/vitnode/src/api/models/session.ts | 18 + .../src/api/models/sso-connection-store.ts | 16 +- .../vitnode/src/api/models/sso-connection.ts | 64 +- .../src/api/models/sso-email-completion.ts | 188 + .../vitnode/src/api/models/sso-flow-store.ts | 213 + .../src/api/models/sso-link-token.test.ts | 18 + .../vitnode/src/api/models/sso-link-token.ts | 9 + packages/vitnode/src/api/models/sso.ts | 643 +- .../users/sso/connections/connections.test.ts | 64 + .../modules/users/sso/connections/failure.ts | 2 + .../sso/connections/routes/callback.route.ts | 4 +- .../modules/users/sso/connections/schema.ts | 4 + .../users/sso/routes/callback.route.ts | 53 +- .../modules/users/sso/routes/callback.test.ts | 667 +- .../modules/users/sso/routes/pending.route.ts | 167 + .../src/api/modules/users/sso/sso.module.ts | 12 +- packages/vitnode/src/database/users.ts | 54 + .../vitnode/src/emails/sso-email-code.tsx | 113 + packages/vitnode/src/locales/api/en.json | 10 + packages/vitnode/src/locales/en.json | 40 + packages/vitnode/src/tanstack/auth/actions.ts | 37 + .../src/tanstack/auth/contract.test.ts | 19 + .../vitnode/src/tanstack/auth/contract.ts | 36 +- .../src/tanstack/auth/default-transport.ts | 6 +- .../vitnode/src/tanstack/auth/route-search.ts | 3 + packages/vitnode/src/tanstack/auth/screens.ts | 6 + packages/vitnode/src/tanstack/auth/server.ts | 6 +- .../vitnode/src/tanstack/auth/sso-screen.tsx | 13 +- .../src/tanstack/auth/transport-operations.ts | 7 +- .../src/tanstack/start/create-start.ts | 11 +- .../src/tanstack/start/sso-form-post.test.ts | 100 + .../src/tanstack/start/sso-form-post.ts | 105 + .../vitnode/src/tests/sso-connection-store.ts | 5 +- packages/vitnode/src/tests/sso-flow-store.ts | 104 + .../auth/settings/sso/connection-callback.ts | 3 + .../settings/sso/sso-connections-mutations.ts | 9 +- .../auth/sso/buttons/sso-buttons-content.tsx | 23 +- .../sso/callback/sso-callback-content.tsx | 140 +- .../auth/sso/callback/sso-callback-result.ts | 13 +- .../auth/sso/callback/use-sso-callback.ts | 12 +- .../auth/sso/email/sso-email-completion.tsx | 229 + .../auth/sso/email/sso-email-mutations.ts | 114 + pnpm-lock.yaml | 4 + 89 files changed, 29520 insertions(+), 450 deletions(-) create mode 100644 apps/api/migrations/20261023190631_sso_attempts_and_pending/migration.sql create mode 100644 apps/api/migrations/20261023190631_sso_attempts_and_pending/snapshot.json create mode 100644 apps/web/content/docs/dev/sso/apple.mdx create mode 100644 apps/web/content/docs/dev/sso/atlassian.mdx create mode 100644 apps/web/content/docs/dev/sso/email-confirmation.mdx create mode 100644 apps/web/content/docs/dev/sso/email-confirmation/code-step.webp create mode 100644 apps/web/content/docs/dev/sso/email-confirmation/email-step.webp create mode 100644 apps/web/content/docs/dev/sso/gitlab.mdx create mode 100644 apps/web/content/docs/dev/sso/kick.mdx create mode 100644 apps/web/content/docs/dev/sso/linkedin.mdx create mode 100644 apps/web/content/docs/dev/sso/microsoft.mdx create mode 100644 apps/web/content/docs/dev/sso/paypal.mdx create mode 100644 apps/web/content/docs/dev/sso/reddit.mdx create mode 100644 apps/web/content/docs/dev/sso/slack.mdx create mode 100644 apps/web/content/docs/dev/sso/tiktok.mdx create mode 100644 apps/web/content/docs/dev/sso/twitch.mdx create mode 100644 apps/web/content/docs/dev/sso/twitter.mdx create mode 100644 packages/vitnode/src/api/adapters/sso/apple.ts create mode 100644 packages/vitnode/src/api/adapters/sso/atlassian.ts create mode 100644 packages/vitnode/src/api/adapters/sso/gitlab.ts create mode 100644 packages/vitnode/src/api/adapters/sso/kick.ts create mode 100644 packages/vitnode/src/api/adapters/sso/linkedin.ts create mode 100644 packages/vitnode/src/api/adapters/sso/microsoft.ts create mode 100644 packages/vitnode/src/api/adapters/sso/paypal.ts create mode 100644 packages/vitnode/src/api/adapters/sso/providers.test.ts create mode 100644 packages/vitnode/src/api/adapters/sso/reddit.ts create mode 100644 packages/vitnode/src/api/adapters/sso/slack.ts create mode 100644 packages/vitnode/src/api/adapters/sso/tiktok.ts create mode 100644 packages/vitnode/src/api/adapters/sso/twitch.ts create mode 100644 packages/vitnode/src/api/adapters/sso/twitter.ts create mode 100644 packages/vitnode/src/api/lib/sso-protocol.test.ts create mode 100644 packages/vitnode/src/api/lib/sso-protocol.ts create mode 100644 packages/vitnode/src/api/models/sso-email-completion.ts create mode 100644 packages/vitnode/src/api/models/sso-flow-store.ts create mode 100644 packages/vitnode/src/api/modules/users/sso/routes/pending.route.ts create mode 100644 packages/vitnode/src/emails/sso-email-code.tsx create mode 100644 packages/vitnode/src/tanstack/start/sso-form-post.test.ts create mode 100644 packages/vitnode/src/tanstack/start/sso-form-post.ts create mode 100644 packages/vitnode/src/tests/sso-flow-store.ts create mode 100644 packages/vitnode/src/views/auth/sso/email/sso-email-completion.tsx create mode 100644 packages/vitnode/src/views/auth/sso/email/sso-email-mutations.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index 5ccaed5137..470a91fc2c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -17,6 +17,45 @@ VITNODE_API_URL=http://localhost:8000 # `{ rpId: "example.com", origins: [...] }` instead. Changing the RP ID later # makes every existing passkey unusable. +# === Single sign-on (server-only - never add these to clientEnv) === +# Discord, Google and Facebook are registered in `vitnode.api.config.ts`; the +# others are commented out there until you configure them. +# See https://vitnode.com/docs/dev/sso for each provider. +# DISCORD_CLIENT_ID= +# DISCORD_CLIENT_SECRET= +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# FACEBOOK_CLIENT_ID= +# FACEBOOK_CLIENT_SECRET= +# APPLE_CLIENT_ID=com.example.web +# APPLE_TEAM_ID= +# APPLE_KEY_ID= +# APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" +# ATLASSIAN_CLIENT_ID= +# ATLASSIAN_CLIENT_SECRET= +# GITLAB_CLIENT_ID= +# GITLAB_CLIENT_SECRET= +# KICK_CLIENT_ID= +# KICK_CLIENT_SECRET= +# LINKEDIN_CLIENT_ID= +# LINKEDIN_CLIENT_SECRET= +# MICROSOFT_CLIENT_ID= +# MICROSOFT_CLIENT_SECRET= +# PAYPAL_CLIENT_ID= +# PAYPAL_CLIENT_SECRET= +# REDDIT_CLIENT_ID= +# REDDIT_CLIENT_SECRET= +# REDDIT_USER_AGENT="web:com.example.forum:1.0.0 (by /u/your_username)" +# SLACK_CLIENT_ID= +# SLACK_CLIENT_SECRET= +# SLACK_TEAM_ID= +# TIKTOK_CLIENT_KEY= +# TIKTOK_CLIENT_SECRET= +# TWITCH_CLIENT_ID= +# TWITCH_CLIENT_SECRET= +# TWITTER_CLIENT_ID= +# TWITTER_CLIENT_SECRET= + # === CRON Secret for Internal API Calls === CRON_SECRET=your-secure-cron-secret-key diff --git a/apps/api/migrations/20261023190631_sso_attempts_and_pending/migration.sql b/apps/api/migrations/20261023190631_sso_attempts_and_pending/migration.sql new file mode 100644 index 0000000000..aad28d6162 --- /dev/null +++ b/apps/api/migrations/20261023190631_sso_attempts_and_pending/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE "core_sso_attempts" ( + "id" serial PRIMARY KEY, + "stateHash" varchar(64) NOT NULL UNIQUE, + "bindingHash" varchar(64) NOT NULL, + "providerId" varchar(255) NOT NULL, + "codeVerifier" varchar(128) NOT NULL, + "nonce" varchar(128) NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "expiresAt" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_sso_attempts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_sso_pending" ( + "id" serial PRIMARY KEY, + "tokenHash" varchar(64) NOT NULL UNIQUE, + "providerId" varchar(255) NOT NULL, + "providerAccountId" varchar(255) NOT NULL, + "providerUsername" varchar(255), + "profile" jsonb, + "email" varchar(255), + "codeHash" varchar(64), + "codeExpiresAt" timestamp, + "codeAttempts" integer DEFAULT 0 NOT NULL, + "codeSends" integer DEFAULT 0 NOT NULL, + "codeSentAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "expiresAt" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "core_sso_pending" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "core_users_sso_operations" ADD COLUMN "codeVerifier" varchar(128);--> statement-breakpoint +ALTER TABLE "core_users_sso_operations" ADD COLUMN "nonce" varchar(128);--> statement-breakpoint +CREATE INDEX "core_sso_attempts_expires_at_idx" ON "core_sso_attempts" ("expiresAt");--> statement-breakpoint +CREATE INDEX "core_sso_pending_expires_at_idx" ON "core_sso_pending" ("expiresAt"); \ No newline at end of file diff --git a/apps/api/migrations/20261023190631_sso_attempts_and_pending/snapshot.json b/apps/api/migrations/20261023190631_sso_attempts_and_pending/snapshot.json new file mode 100644 index 0000000000..6d596f139e --- /dev/null +++ b/apps/api/migrations/20261023190631_sso_attempts_and_pending/snapshot.json @@ -0,0 +1,20631 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "1e7f3624-6147-49b3-9b8c-e8a36101edce", + "prevIds": [ + "0c989133-91e8-46c3-86d3-f9b7ac8ec7a3" + ], + "ddl": [ + { + "isRlsEnabled": true, + "name": "core_admin_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_action_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_budget_periods", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_calls", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_cost_adjustments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_reservations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_role_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_translation_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_ai_user_overrides", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_containers", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_documents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_drafts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_field_locks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_submissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_file_refs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_revisions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_schedules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_content_slug_history", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_cron", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_admin_dashboard", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_analysis", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_files_alt_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_languages_words", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_logs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_report_reasons", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderation_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notification_user_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_notifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_page_layouts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkey_challenges", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_passkeys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_queue", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_index", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_search_popularity", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_secrets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sessions_known_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_lock", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_results", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_update_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_sso_pending", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_confirm_emails", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_forgot_password", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_secondary_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_operations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_users_sso_profile_sources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_categories_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_author_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_category_id", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "blog_posts_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_faq", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_related_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_advanced_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_articles_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_categories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_discussions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_gallery", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_related_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_sessions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_events_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_localized_articles_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_pages", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_related_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "example_tags_translations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_comments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "forum_topics", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fallbackModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxInputCharacters", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxOutputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeoutMs", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxRetries", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxSteps", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instructions", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_action_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "unit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "limitAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "spentAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedAmount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unknownCount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerRequestId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "images", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costReason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pricingSnapshot", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reconciledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_calls" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "callId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "previousSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newCostUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "newSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deltaUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "budgetPeriodId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "amount", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "permission", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "granted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dailyLimit", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actionKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceType", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resourceId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "errorCode", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "promptVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "modelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerModelId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "inputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "outputTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheReadTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cacheWriteTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reasoningTokens", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "costSource", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "chargedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "reservedPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "settlement", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "leaseExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "acceptedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finishedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settledAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "systemMonthlyBudgetUsd", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'0'", + "generated": null, + "identity": null, + "name": "defaultMonthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "pointsConversionVersion", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "userRequestsPerMinute", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "userConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "systemConcurrency", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "365", + "generated": null, + "identity": null, + "name": "historyRetentionDays", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "altEnabled", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "10", + "generated": null, + "identity": null, + "name": "altBatchSize", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "altScanCursor", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "altScanStartedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_settings" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceLocale", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "numeric(24,12)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthlyPoints", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unlimited", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "blocked", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "definitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "policies", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_containers" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "varchar(35)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "bytea", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_documents" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_drafts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "acquiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerDefinitionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "values", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseValues", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseVersion", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseUpdatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewerId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "decidedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_submissions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "changedFields", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'system'", + "generated": null, + "identity": null, + "name": "actorType", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorUserId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "restoredFromRevisionId", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_revisions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "action", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduledFor", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "createdBy", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "effectsError", + "entityType": "columns", + "schema": "public", + "table": "core_content_schedules" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "contentTypeId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "retiredAt", + "entityType": "columns", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "module", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nextRun", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schedule", + "entityType": "columns", + "schema": "public", + "table": "core_cron" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "widgets", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "folder", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mimeType", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "size", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'automatic'", + "generated": null, + "identity": null, + "name": "altPolicy", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "text", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedById", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileFingerprint", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "runId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fileId", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "time24", + "entityType": "columns", + "schema": "public", + "table": "core_languages" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginCode", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tableName", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "variable", + "entityType": "columns", + "schema": "public", + "table": "core_languages_words" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(45)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "varchar(10)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'GET'", + "generated": null, + "identity": null, + "name": "method", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'localhost'", + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "500", + "generated": null, + "identity": null, + "name": "statusCode", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_logs" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reportId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "requiresDetails", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetType", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reporterId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "targetTitle", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'new'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigneeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolution", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolutionNote", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolvedById", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeFamily", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopeId", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "openKey", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "unrestricted", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "permissions", + "entityType": "columns", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodStart", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "periodEnd", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "itemCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerMessageId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipReason", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sentAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "schemaVersion", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "idempotencyKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actorId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "data", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "recipientIds", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowSelf", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "recipientCursor", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "deliveredCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "eventId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notificationId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailPending", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "emailDeliveryId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "unreadCount", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "revision", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "type": "bigserial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "groupKey", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "groupBucket", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectType", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subjectId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "latestEventId", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "eventCount", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "activitySeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "readSeq", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "readAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archivedAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastActivityAt", + "entityType": "columns", + "schema": "public", + "table": "core_notifications" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pageId", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "zones", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_page_layouts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ceremony", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "challenge", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(1024)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentialId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicKey", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "counter", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webauthnUserId", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "transports", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceType", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "backedUp", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aaguid", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastUsedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'default'", + "generated": null, + "identity": null, + "name": "queue", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "maxAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "availableAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "completedAt", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupeKey", + "entityType": "columns", + "schema": "public", + "table": "core_queue" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "protected", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "default", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "root", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "guest", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "prefix", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "allowUploadFiles", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "totalMaxStorage", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "maxStorageForSubmit", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadAvatar", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2048", + "generated": null, + "identity": null, + "name": "maxAvatarSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowUploadCover", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5120", + "generated": null, + "identity": null, + "name": "maxCoverSize", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "allowEditPersonalInfo", + "entityType": "columns", + "schema": "public", + "table": "core_roles" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "languageCode", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "authorIds", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "tsvector", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": { + "as": "setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"title\", '')), 'A') || setweight(to_tsvector(CASE lower(split_part(\"core_search_index\".\"languageCode\", '-', 1)) WHEN 'da' THEN 'danish'::regconfig WHEN 'de' THEN 'german'::regconfig WHEN 'en' THEN 'english'::regconfig WHEN 'es' THEN 'spanish'::regconfig WHEN 'fi' THEN 'finnish'::regconfig WHEN 'fr' THEN 'french'::regconfig WHEN 'hu' THEN 'hungarian'::regconfig WHEN 'it' THEN 'italian'::regconfig WHEN 'nl' THEN 'dutch'::regconfig WHEN 'no' THEN 'norwegian'::regconfig WHEN 'pl' THEN 'polish'::regconfig WHEN 'pt' THEN 'portuguese'::regconfig WHEN 'ro' THEN 'romanian'::regconfig WHEN 'ru' THEN 'russian'::regconfig WHEN 'sv' THEN 'swedish'::regconfig WHEN 'tr' THEN 'turkish'::regconfig ELSE 'simple'::regconfig END, coalesce(\"core_search_index\".\"content\", '')), 'B')", + "type": "stored" + }, + "identity": null, + "name": "search_vector", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerType", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "containerId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "isPublic", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isHidden", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "imageId", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "metadata", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "indexedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_index" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemType", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "logScore", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "clicks", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comments", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_search_popularity" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_secrets" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deviceId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publicId", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userAgent", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "lastSeen", + "entityType": "columns", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastManualAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_lock" + }, + { + "type": "varchar(214)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "componentId", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(20)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "channel", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "versions", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hasUnknownVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(256)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coreVersion", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "result", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_results" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "etag", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastModified", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "document", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastAttemptAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastSuccessAt", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastError", + "entityType": "columns", + "schema": "public", + "table": "core_update_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stateHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bindingHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeHash", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeExpiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "codeAttempts", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "codeSends", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeSentAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_sso_pending" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nameCode", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "firstName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lastName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "headline", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "showRealName", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "newsletter", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(6)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarColor", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "emailVerified", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "birthday", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "timeZone", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'en'", + "generated": null, + "identity": null, + "name": "language", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverId", + "entityType": "columns", + "schema": "public", + "table": "core_users" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "varchar(40)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ipAddress", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roleId", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerEmail", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerUsername", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syncOnSignIn", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(2048)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSourceUrl", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarSha256", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "avatarFileId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "intent", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "fields", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerAccountId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preview", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeVerifier", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "nonce", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expiresAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "userId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "field", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "providerId", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "color", + "entityType": "columns", + "schema": "public", + "table": "blog_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImage", + "entityType": "columns", + "schema": "public", + "table": "blog_posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "friendlyUrl", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "coverImageAlt", + "entityType": "columns", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "syndicationIndexable", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "syndicationNoIndex", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "5", + "generated": null, + "identity": null, + "name": "syndicationPriority", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "question", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "answer", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoTitle", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seoDescription", + "entityType": "columns", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "views", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "noIndex", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "animation", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_categories" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "board", + "entityType": "columns", + "schema": "public", + "table": "example_discussions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "example_events_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "primaryTag", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_gallery" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_related_events" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "startsAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_sessions" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_tags" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_events_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "featured", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "content", + "entityType": "columns", + "schema": "public", + "table": "example_pages" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "example_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "relatedItemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "itemId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "languageId", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "example_tags_translations" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "topic", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parent", + "entityType": "columns", + "schema": "public", + "table": "forum_comments" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publishedAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'draft'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenAt", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hiddenBy", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "forum", + "entityType": "columns", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "periodStart", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_budget_periods_scope_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_budget_periods" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_run_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "costSource", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "reconciledAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_calls_reconcile_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "callId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_cost_adjustments_call_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "runId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "budgetPeriodId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_run_period_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_reservations_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actionKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_action_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "leaseExpiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"idempotencyKey\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_runs_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_ai_translation_sources_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "definitionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_definition_parent_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_containers_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_doc_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_documents_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_drafts_updated_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "field", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "language", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_field_locks_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_pending_key", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_queue_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_submissions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "revisionId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_file_refs_file_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_item_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_translation_version_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_language_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "actorUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_revisions_actor_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "action", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduledFor", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_schedules_created_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_shared_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"languageId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_locale_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "contentTypeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_item_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_content_slug_history_plugin_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_content_slug_history" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastRun", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_cron_last_run_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_cron" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_admin_dashboard_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_file_language_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "fileId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fileFingerprint", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_analysis_file_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status <> 'completed'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_alt_state_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lang_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tableName", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "variable", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_languages_words_lookup_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_logs_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reportId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_events_report_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_report_reasons_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_report_reasons" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "targetId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_target_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "assigneeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reporterId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_reporter_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "scopeFamily", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scopeId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderation_reports_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_moderators_permissions_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "status = 'pending'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "eventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"eventId\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_deliveries_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "idempotencyKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_idempotency_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "notificationId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_notification_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"emailPending\" = true AND \"emailDeliveryId\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_receipts_email_pending_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "unreadCount", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notification_user_state_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "groupBucket", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_group_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_inbox_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "lastActivityAt", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": false, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archivedAt\" IS NULL AND \"readSeq\" < \"activitySeq\"", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_unread_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "subjectType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subjectId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_subject_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lastActivityAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_last_activity_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "latestEventId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_notifications_latest_event_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkey_challenges_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "webauthnUserId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_passkeys_webauthn_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "availableAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_status_available_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "completedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"completedAt\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_completed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "pluginId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dedupeKey", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"dedupeKey\" IS NOT NULL AND status IN ('pending', 'processing')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_queue_dedupe_active_unique", + "entityType": "indexes", + "schema": "public", + "table": "core_queue" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_roles_updated_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "search_vector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_search_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "to_tsvector('simple'::regconfig, \"title\")", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_title_vector_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorIds", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "gin", + "concurrently": false, + "name": "core_search_index_author_ids_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_item_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageCode", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_language_code_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "isPublic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_is_public_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "imageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_image_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "containerType", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "containerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_index_container_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "logScore", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_search_popularity_log_score_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_search_popularity" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "deviceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_device_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "ipAddress", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sessions_known_devices_ip_address_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sessions_known_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_attempts_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_sso_pending_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_sso_pending" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "avatarId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_avatar_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_cover_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_created_at_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "roleId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_secondary_roles_role_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_user_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "userId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "providerId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_user_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expiresAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_users_sso_operations_expires_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_categories_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "coverImage", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_cover_image_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_author_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_position_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_category_id_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "friendlyUrl", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "blog_posts_translations_language_id_friendly_url_key", + "entityType": "indexes", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "syndicationPriority", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_syndication_priority_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_categories_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_faq_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_related_articles_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_advanced_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "code", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_code_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "animation", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_animation_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_articles_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_categories_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_categories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "board", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_board_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_discussions_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "primaryTag", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_primary_tag_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_gallery_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_related_events_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_sessions_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_events_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_localized_articles_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_pages_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_pages" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "category", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_category_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "itemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_position_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "relatedItemId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_related_tags_related_item_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "languageId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "example_tags_translations_language_id_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_parent_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "topic", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_topic_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parent", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_comments_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "forum", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_forum_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "slug", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_slug_key", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "author", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_author_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "createdAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updatedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_updated_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "publishedAt", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_status_published_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hiddenBy", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "forum_topics_hidden_by_idx", + "entityType": "indexes", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_calls_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_calls" + }, + { + "nameExplicit": false, + "columns": [ + "callId" + ], + "schemaTo": "public", + "tableTo": "core_ai_calls", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_callId_core_ai_calls_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_cost_adjustments_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_cost_adjustments" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "budgetPeriodId" + ], + "schemaTo": "public", + "tableTo": "core_ai_budget_periods", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_reservations_FClYpPXZwvsm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_reservations" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_permissions_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_role_policies_roleId_core_roles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_role_policies" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_runs_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_runs" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_ai_translation_sources_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_translation_sources" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_ai_user_overrides_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_ai_user_overrides" + }, + { + "nameExplicit": false, + "columns": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_content_containers_q6QIucJ4HJ7B_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_containers" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_documents_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_documents" + }, + { + "nameExplicit": false, + "columns": [ + "updatedBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_drafts_updatedBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_drafts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_field_locks_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_field_locks" + }, + { + "nameExplicit": false, + "columns": [ + "authorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_authorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "reviewerId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_content_submissions_reviewerId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_submissions" + }, + { + "nameExplicit": false, + "columns": [ + "revisionId" + ], + "schemaTo": "public", + "tableTo": "core_content_revisions", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "core_content_file_refs_5get6SfhBPHr_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "core_content_file_refs_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_content_file_refs" + }, + { + "nameExplicit": false, + "columns": [ + "actorUserId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_revisions_actorUserId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_revisions" + }, + { + "nameExplicit": false, + "columns": [ + "createdBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_content_schedules_createdBy_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_content_schedules" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_admin_dashboard_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_admin_dashboard" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_files" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_languageCode_core_languages_code_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "updatedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_updatedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_analysis_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "runId" + ], + "schemaTo": "public", + "tableTo": "core_ai_runs", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_files_alt_analysis_runId_core_ai_runs_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_analysis" + }, + { + "nameExplicit": false, + "columns": [ + "fileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_files_alt_state_fileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_files_alt_state" + }, + { + "nameExplicit": false, + "columns": [ + "languageCode" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_languages_words_languageCode_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_languages_words" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "core_logs_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_logs" + }, + { + "nameExplicit": false, + "columns": [ + "reportId" + ], + "schemaTo": "public", + "tableTo": "core_moderation_reports", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderation_report_events_4CqHOxfheaUm_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_report_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_report_events" + }, + { + "nameExplicit": false, + "columns": [ + "reporterId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_reporterId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "assigneeId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_assigneeId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "resolvedById" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_moderation_reports_resolvedById_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_moderation_reports" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_moderators_permissions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_moderators_permissions" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_deliveries_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_deliveries_vcf37BytIa0M_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_deliveries" + }, + { + "nameExplicit": false, + "columns": [ + "actorId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_notification_events_actorId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_events" + }, + { + "nameExplicit": false, + "columns": [ + "eventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_UQ7ZEs0I6ijx_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "notificationId" + ], + "schemaTo": "public", + "tableTo": "core_notifications", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_receipts_Vxye2Cs6MgcL_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notification_user_state_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notification_user_state" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_notifications_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "latestEventId" + ], + "schemaTo": "public", + "tableTo": "core_notification_events", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "RESTRICT", + "name": "core_notifications_I8QoGbTal5Wt_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_notifications" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkey_challenges_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkey_challenges" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_passkeys_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_passkeys" + }, + { + "nameExplicit": false, + "columns": [ + "imageId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_search_index_imageId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "deviceId" + ], + "schemaTo": "public", + "tableTo": "core_sessions_known_devices", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_sessions_deviceId_core_sessions_known_devices_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "core_users_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "language" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "code" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET DEFAULT", + "name": "core_users_language_core_languages_code_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "avatarId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_avatarId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "coverId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_coverId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_confirm_emails_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_confirm_emails" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_forgot_password_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_forgot_password" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "roleId" + ], + "schemaTo": "public", + "tableTo": "core_roles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_secondary_roles_roleId_core_roles_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_userId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "avatarFileId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_users_sso_avatarFileId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_operations_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_userId_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "schemaTo": "public", + "tableTo": "core_users_sso", + "columnsTo": [ + "userId", + "providerId" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "core_users_sso_profile_sources_connection_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_categories_translations_itemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_categories_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "blog_posts_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "coverImage" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_coverImage_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "blog_posts" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_author_id_relatedItemId_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_category_id_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "blog_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_category_id_relatedItemId_blog_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "blog_posts", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "blog_posts_translations_itemId_blog_posts_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "blog_posts_translations_languageId_core_languages_id_fk", + "entityType": "fks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_categories_itemId_example_advanced_ar", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_categories_relatedItemId_example_cate", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_faq_itemId_example_advanced_articles_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_faq" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_itemId_example_advan", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_related_articles_relatedItemId_exampl", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_tags_ATchHLRtvoCM_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_tags_dEsWdkDmu7nW_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_advanced_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_advanced_articles_translations_itemId_example_advanced_", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_advanced_articles_translations_languageId_core_language", + "entityType": "fks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_articles_author_core_users_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "animation" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_animation_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_category_example_categories_id_fk", + "entityType": "fks", + "schema": "public", + "table": "example_articles" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_articles_gallery_itemId_example_articles_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_articles_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_discussions_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "board" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_discussions_board_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_discussions" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "primaryTag" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_primaryTag_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_events_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_gallery_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "core_files", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_gallery_relatedItemId_core_files_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_related_events_oaRvqnKyAS7L_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_sessions_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_sessions" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_tags_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_events", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_events_translations_itemId_example_events_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_events_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_events_translations" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_localized_articles", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_localized_articles_translations_itemId_example_localize", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_localized_articles_translations_languageId_core_languag", + "entityType": "fks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "category" + ], + "schemaTo": "public", + "tableTo": "example_categories", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "example_tags_category_example_categories_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "relatedItemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_related_tags_relatedItemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "nameExplicit": false, + "columns": [ + "itemId" + ], + "schemaTo": "public", + "tableTo": "example_tags", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "example_tags_translations_itemId_example_tags_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "languageId" + ], + "schemaTo": "public", + "tableTo": "core_languages", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "example_tags_translations_languageId_core_languages_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "topic" + ], + "schemaTo": "public", + "tableTo": "forum_topics", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "CASCADE", + "name": "forum_replies_topic_forum_topics_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "parent" + ], + "schemaTo": "public", + "tableTo": "forum_comments", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_replies_parent_forum_replies_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_comments" + }, + { + "nameExplicit": false, + "columns": [ + "hiddenBy" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_hiddenBy_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "author" + ], + "schemaTo": "public", + "tableTo": "core_users", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "SET NULL", + "name": "forum_topics_author_core_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "nameExplicit": false, + "columns": [ + "forum" + ], + "schemaTo": "public", + "tableTo": "core_content_containers", + "columnsTo": [ + "id" + ], + "onUpdate": "CASCADE", + "onDelete": "RESTRICT", + "name": "forum_topics_forum_core_content_containers_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "forum_topics" + }, + { + "columns": [ + "roleId", + "permission" + ], + "nameExplicit": false, + "name": "core_ai_role_permissions_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_ai_role_permissions" + }, + { + "columns": [ + "eventId", + "userId" + ], + "nameExplicit": false, + "name": "core_notification_receipts_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_notification_receipts" + }, + { + "columns": [ + "itemType", + "itemId" + ], + "nameExplicit": false, + "name": "core_search_popularity_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_search_popularity" + }, + { + "columns": [ + "userId", + "roleId" + ], + "nameExplicit": false, + "name": "core_users_secondary_roles_userId_roleId_pk", + "entityType": "pks", + "schema": "public", + "table": "core_users_secondary_roles" + }, + { + "columns": [ + "userId", + "field" + ], + "nameExplicit": false, + "name": "core_users_sso_profile_sources_pkey", + "entityType": "pks", + "schema": "public", + "table": "core_users_sso_profile_sources" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_categories_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_categories_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_author_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_author_id" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "blog_posts_category_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_category_id" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "blog_posts_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "blog_posts_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_categories_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_categories" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_related_articles_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_related_articles" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_advanced_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_advanced_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_articles_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_articles_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_gallery_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_gallery" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_related_events_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_related_events" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_events_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_events_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_events_translations" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_localized_articles_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_localized_articles_translations" + }, + { + "columns": [ + "itemId", + "relatedItemId" + ], + "nameExplicit": true, + "name": "example_tags_related_tags_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_related_tags" + }, + { + "columns": [ + "itemId", + "languageId" + ], + "nameExplicit": true, + "name": "example_tags_translations_item_id_language_id_pk", + "entityType": "pks", + "schema": "public", + "table": "example_tags_translations" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_permissions_pkey", + "schema": "public", + "table": "core_admin_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_sessions_pkey", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "pks" + }, + { + "columns": [ + "actionKey" + ], + "nameExplicit": false, + "name": "core_ai_action_settings_pkey", + "schema": "public", + "table": "core_ai_action_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_budget_periods_pkey", + "schema": "public", + "table": "core_ai_budget_periods", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_calls_pkey", + "schema": "public", + "table": "core_ai_calls", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_cost_adjustments_pkey", + "schema": "public", + "table": "core_ai_cost_adjustments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_reservations_pkey", + "schema": "public", + "table": "core_ai_reservations", + "entityType": "pks" + }, + { + "columns": [ + "roleId" + ], + "nameExplicit": false, + "name": "core_ai_role_policies_pkey", + "schema": "public", + "table": "core_ai_role_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_runs_pkey", + "schema": "public", + "table": "core_ai_runs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_settings_pkey", + "schema": "public", + "table": "core_ai_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_ai_translation_sources_pkey", + "schema": "public", + "table": "core_ai_translation_sources", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_ai_user_overrides_pkey", + "schema": "public", + "table": "core_ai_user_overrides", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_containers_pkey", + "schema": "public", + "table": "core_content_containers", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_documents_pkey", + "schema": "public", + "table": "core_content_documents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_drafts_pkey", + "schema": "public", + "table": "core_content_drafts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_field_locks_pkey", + "schema": "public", + "table": "core_content_field_locks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_submissions_pkey", + "schema": "public", + "table": "core_content_submissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_file_refs_pkey", + "schema": "public", + "table": "core_content_file_refs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_revisions_pkey", + "schema": "public", + "table": "core_content_revisions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_schedules_pkey", + "schema": "public", + "table": "core_content_schedules", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_content_slug_history_pkey", + "schema": "public", + "table": "core_content_slug_history", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_cron_pkey", + "schema": "public", + "table": "core_cron", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_admin_dashboard_pkey", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_pkey", + "schema": "public", + "table": "core_files", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_pkey", + "schema": "public", + "table": "core_files_alt", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_files_alt_analysis_pkey", + "schema": "public", + "table": "core_files_alt_analysis", + "entityType": "pks" + }, + { + "columns": [ + "fileId" + ], + "nameExplicit": false, + "name": "core_files_alt_state_pkey", + "schema": "public", + "table": "core_files_alt_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_pkey", + "schema": "public", + "table": "core_languages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_languages_words_pkey", + "schema": "public", + "table": "core_languages_words", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_logs_pkey", + "schema": "public", + "table": "core_logs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_events_pkey", + "schema": "public", + "table": "core_moderation_report_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_report_reasons_pkey", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderation_reports_pkey", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_moderators_permissions_pkey", + "schema": "public", + "table": "core_moderators_permissions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_deliveries_pkey", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notification_events_pkey", + "schema": "public", + "table": "core_notification_events", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "core_notification_settings_pkey", + "schema": "public", + "table": "core_notification_settings", + "entityType": "pks" + }, + { + "columns": [ + "userId" + ], + "nameExplicit": false, + "name": "core_notification_user_state_pkey", + "schema": "public", + "table": "core_notification_user_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_notifications_pkey", + "schema": "public", + "table": "core_notifications", + "entityType": "pks" + }, + { + "columns": [ + "pageId" + ], + "nameExplicit": false, + "name": "core_page_layouts_pkey", + "schema": "public", + "table": "core_page_layouts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkey_challenges_pkey", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_passkeys_pkey", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_queue_pkey", + "schema": "public", + "table": "core_queue", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_roles_pkey", + "schema": "public", + "table": "core_roles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_search_index_pkey", + "schema": "public", + "table": "core_search_index", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_secrets_pkey", + "schema": "public", + "table": "core_secrets", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_pkey", + "schema": "public", + "table": "core_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sessions_known_devices_pkey", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "core_update_lock_pkey", + "schema": "public", + "table": "core_update_lock", + "entityType": "pks" + }, + { + "columns": [ + "componentId" + ], + "nameExplicit": false, + "name": "core_update_results_pkey", + "schema": "public", + "table": "core_update_results", + "entityType": "pks" + }, + { + "columns": [ + "url" + ], + "nameExplicit": false, + "name": "core_update_sources_pkey", + "schema": "public", + "table": "core_update_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_attempts_pkey", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_sso_pending_pkey", + "schema": "public", + "table": "core_sso_pending", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_pkey", + "schema": "public", + "table": "core_users", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_confirm_emails_pkey", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_forgot_password_pkey", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "core_users_sso_operations_pkey", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_categories_pkey", + "schema": "public", + "table": "blog_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "blog_posts_pkey", + "schema": "public", + "table": "blog_posts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_pkey", + "schema": "public", + "table": "example_advanced_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_advanced_articles_faq_pkey", + "schema": "public", + "table": "example_advanced_articles_faq", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_articles_pkey", + "schema": "public", + "table": "example_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_categories_pkey", + "schema": "public", + "table": "example_categories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_discussions_pkey", + "schema": "public", + "table": "example_discussions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_pkey", + "schema": "public", + "table": "example_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_events_sessions_pkey", + "schema": "public", + "table": "example_events_sessions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_localized_articles_pkey", + "schema": "public", + "table": "example_localized_articles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_pages_pkey", + "schema": "public", + "table": "example_pages", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "example_tags_pkey", + "schema": "public", + "table": "example_tags", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_replies_pkey", + "schema": "public", + "table": "forum_comments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "forum_topics_pkey", + "schema": "public", + "table": "forum_topics", + "entityType": "pks" + }, + { + "nameExplicit": true, + "columns": [ + "itemType", + "itemId", + "languageCode" + ], + "nullsNotDistinct": false, + "name": "core_search_index_item_unique", + "entityType": "uniques", + "schema": "public", + "table": "core_search_index" + }, + { + "nameExplicit": true, + "columns": [ + "providerId", + "providerAccountId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_provider_account_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": true, + "columns": [ + "userId", + "providerId" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_user_provider_key", + "entityType": "uniques", + "schema": "public", + "table": "core_users_sso" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_admin_sessions_token_unique", + "schema": "public", + "table": "core_admin_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_admin_dashboard_userId_unique", + "schema": "public", + "table": "core_admin_dashboard", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "key" + ], + "nullsNotDistinct": false, + "name": "core_files_key_unique", + "schema": "public", + "table": "core_files", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_languages_code_unique", + "schema": "public", + "table": "core_languages", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "code" + ], + "nullsNotDistinct": false, + "name": "core_moderation_report_reasons_code_key", + "schema": "public", + "table": "core_moderation_report_reasons", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "openKey" + ], + "nullsNotDistinct": false, + "name": "core_moderation_reports_openKey_key", + "schema": "public", + "table": "core_moderation_reports", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "idempotencyKey" + ], + "nullsNotDistinct": false, + "name": "core_notification_deliveries_idempotencyKey_key", + "schema": "public", + "table": "core_notification_deliveries", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_passkey_challenges_tokenHash_key", + "schema": "public", + "table": "core_users_passkey_challenges", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "credentialId" + ], + "nullsNotDistinct": false, + "name": "core_users_passkeys_credentialId_key", + "schema": "public", + "table": "core_users_passkeys", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_sessions_token_unique", + "schema": "public", + "table": "core_sessions", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "publicId" + ], + "nullsNotDistinct": false, + "name": "core_sessions_known_devices_publicId_unique", + "schema": "public", + "table": "core_sessions_known_devices", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "stateHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_attempts_stateHash_key", + "schema": "public", + "table": "core_sso_attempts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_sso_pending_tokenHash_key", + "schema": "public", + "table": "core_sso_pending", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "nameCode" + ], + "nullsNotDistinct": false, + "name": "core_users_nameCode_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "name" + ], + "nullsNotDistinct": false, + "name": "core_users_name_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "core_users_email_unique", + "schema": "public", + "table": "core_users", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_confirm_emails_token_unique", + "schema": "public", + "table": "core_users_confirm_emails", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "userId" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_userId_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "core_users_forgot_password_token_unique", + "schema": "public", + "table": "core_users_forgot_password", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false, + "name": "core_users_sso_operations_tokenHash_key", + "schema": "public", + "table": "core_users_sso_operations", + "entityType": "uniques" + }, + { + "value": "\"intent\" IN ('link', 'import', 'sync', 'preview')", + "name": "core_users_sso_operations_intent_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_operations" + }, + { + "value": "\"field\" IN ('avatar', 'firstName', 'lastName')", + "name": "core_users_sso_profile_sources_field_check", + "entityType": "checks", + "schema": "public", + "table": "core_users_sso_profile_sources" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts index a76a8d4fcf..95e41b8235 100644 --- a/apps/api/src/vitnode.api.config.ts +++ b/apps/api/src/vitnode.api.config.ts @@ -122,6 +122,25 @@ export const vitNodeApiConfig = buildApiConfig({ clientId: process.env.FACEBOOK_CLIENT_ID, clientSecret: process.env.FACEBOOK_CLIENT_SECRET, }), + // More built-in providers, opt-in: import the factory from + // `@vitnode/core/api/adapters/sso/{providerId}`, uncomment its line and + // set its variables in `.env`. A button without credentials still shows + // up and fails on click, so only add the ones you configure. Apple, + // PayPal and TikTok need a public HTTPS `VITNODE_WEB_URL`. + // See https://vitnode.com/docs/dev/sso + // + // AppleSSOApiPlugin({ clientId: process.env.APPLE_CLIENT_ID, teamId: process.env.APPLE_TEAM_ID, keyId: process.env.APPLE_KEY_ID, privateKey: process.env.APPLE_PRIVATE_KEY }), + // AtlassianSSOApiPlugin({ clientId: process.env.ATLASSIAN_CLIENT_ID, clientSecret: process.env.ATLASSIAN_CLIENT_SECRET }), + // GitLabSSOApiPlugin({ clientId: process.env.GITLAB_CLIENT_ID, clientSecret: process.env.GITLAB_CLIENT_SECRET }), + // KickSSOApiPlugin({ clientId: process.env.KICK_CLIENT_ID, clientSecret: process.env.KICK_CLIENT_SECRET }), + // LinkedInSSOApiPlugin({ clientId: process.env.LINKEDIN_CLIENT_ID, clientSecret: process.env.LINKEDIN_CLIENT_SECRET }), + // MicrosoftSSOApiPlugin({ clientId: process.env.MICROSOFT_CLIENT_ID, clientSecret: process.env.MICROSOFT_CLIENT_SECRET, tenant: "common" }), + // PayPalSSOApiPlugin({ clientId: process.env.PAYPAL_CLIENT_ID, clientSecret: process.env.PAYPAL_CLIENT_SECRET, environment: "sandbox" }), + // RedditSSOApiPlugin({ clientId: process.env.REDDIT_CLIENT_ID, clientSecret: process.env.REDDIT_CLIENT_SECRET, userAgent: process.env.REDDIT_USER_AGENT }), + // SlackSSOApiPlugin({ clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, teamId: process.env.SLACK_TEAM_ID }), + // TikTokSSOApiPlugin({ clientKey: process.env.TIKTOK_CLIENT_KEY, clientSecret: process.env.TIKTOK_CLIENT_SECRET }), + // TwitchSSOApiPlugin({ clientId: process.env.TWITCH_CLIENT_ID, clientSecret: process.env.TWITCH_CLIENT_SECRET }), + // TwitterSSOApiPlugin({ clientId: process.env.TWITTER_CLIENT_ID, clientSecret: process.env.TWITTER_CLIENT_SECRET }), ], }, // Bot protection on the routes that declare `withCaptcha` - register, log in, diff --git a/apps/web/content/docs/dev/sso/apple.mdx b/apps/web/content/docs/dev/sso/apple.mdx new file mode 100644 index 0000000000..357d178b3b --- /dev/null +++ b/apps/web/content/docs/dev/sso/apple.mdx @@ -0,0 +1,188 @@ +--- +title: Apple +description: Add Sign in with Apple to VitNode - create a Services ID and key, register the HTTPS return URL and wire up the Apple SSO adapter. +icon: Apple +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Apple** button to `/login` and `/register`. Visitors can share their real email or a private relay address, and both work with VitNode. + +## Before you begin + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) membership. Free accounts cannot create Sign in with Apple keys. +- A public **HTTPS domain** for the site. Apple rejects `http://`, IP addresses and `localhost` return URLs, so local development needs a tunnel (see [Develop locally](#develop-locally)). + +## Quick start + +With a Services ID, Team ID, Key ID and `.p8` key in hand: + +```bash title=".env" +APPLE_CLIENT_ID=com.example.forum.web +APPLE_TEAM_ID=A1B2C3D4E5 +APPLE_KEY_ID=F6G7H8I9J0 +APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----" +``` + +```ts title="src/vitnode.api.config.ts" +import { AppleSSOApiPlugin } from '@vitnode/core/api/adapters/sso/apple' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:10] + authorization: { + ssoAdapters: [ + AppleSSOApiPlugin({ + clientId: process.env.APPLE_CLIENT_ID, + teamId: process.env.APPLE_TEAM_ID, + keyId: process.env.APPLE_KEY_ID, + privateKey: process.env.APPLE_PRIVATE_KEY, + }), + ], + }, +}) +``` + +## Set up Sign in with Apple + + + + +### Create an App ID + +In [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list), add an **App IDs** identifier (type **App**), give it a bundle ID such as `com.example.forum`, and tick **Sign in with Apple** under capabilities. This is the primary App ID the web sign-in groups under. + + + + + +### Create a Services ID + +Add another identifier, this time of type **Services IDs**. Its identifier, for example `com.example.forum.web`, is the **client ID** VitNode sends to Apple. The description is what visitors see on Apple's consent screen. + + + + + +### Register the domain and return URL + +Open the new Services ID, tick **Sign in with Apple** and press **Configure**. Choose the primary App ID, then add: + +| Field | Value | +| ----------- | ----------------------------------------- | +| Domains | `your-domain.com` | +| Return URLs | `https://your-domain.com/login/sso/apple` | + +VitNode builds the return URL from `VITNODE_WEB_URL` as `{VITNODE_WEB_URL}/login/sso/apple`, so they must match exactly - scheme, host, no trailing slash. + +{/* Image prompt: The Apple Developer "Web Authentication Configuration" dialog for a Services ID, with a primary App ID selected, "your-domain.com" in Domains and Subdomains and "https://your-domain.com/login/sso/apple" in Return URLs, light theme, 1100x600. */} + + + + + +### Create a key + +Under **Keys**, add a key, tick **Sign in with Apple**, configure it with the same primary App ID and register it. Download the `.p8` file - Apple lets you download it **once** - and note the 10-character **Key ID**. Your **Team ID** is in the top-right corner of the developer account. + + + + + +### Set the environment variables + +Keep these server-side - never add them to `clientEnv`. Paste the `.p8` contents with `\n` for line breaks; VitNode turns them back into newlines. + +```bash title=".env" +APPLE_CLIENT_ID=com.example.forum.web # [!code ++] +APPLE_TEAM_ID=A1B2C3D4E5 # [!code ++] +APPLE_KEY_ID=F6G7H8I9J0 # [!code ++] +APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----" # [!code ++] +VITNODE_WEB_URL=https://your-domain.com +``` + +Apple's token endpoint wants a **client secret**: a short ES256 JWT signed with the key. `AppleSSOApiPlugin` creates it for you, keeps it for 24 hours and renews it before it expires, so there is no secret to rotate by hand. + + + + + +### Register the adapter + +Add `AppleSSOApiPlugin` to `authorization.ssoAdapters` as in the [quick start](#quick-start), then restart the server so the return URL is rebuilt: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login` on your HTTPS domain and press **Continue with Apple**. Apple asks whether to share or hide the email, then returns to `/login/sso/apple` and you land signed in. + + + + +## How Apple's callback works + +Because VitNode asks for the name and email, Apple returns with a **cross-site POST** (`response_mode=form_post`), and browsers do not send VitNode's `SameSite=Lax` sign-in cookie with it. VitNode's request middleware (part of `createVitNodeStart`) answers that POST with a `303` redirect to the same URL as a normal `GET`, which carries the cookie. Nothing is relaxed: the state is still checked against the browser that started the sign-in. Only `code`, `state`, `error` and the name from Apple's `user` field are forwarded. + +Apple sends the name only on the **first** authorization. VitNode uses it as the new account's display name (or "Apple user" when it is hidden). A later [import from Connected accounts](/docs/dev/sso/connected-accounts) finds no name, because Apple does not send it again. + +## Develop locally + +Apple will not redirect to `localhost`. Expose the dev server through an HTTPS tunnel (for example Cloudflare Tunnel or ngrok) and use that host everywhere: + +```bash title=".env" +VITNODE_WEB_URL=https://forum-dev.example.com +VITNODE_API_URL=https://forum-dev.example.com +``` + +Add `forum-dev.example.com` to the Services ID's domains and `https://forum-dev.example.com/login/sso/apple` to its return URLs. + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ------------------------------------------------------------- | +| Scopes | `name email` | +| Account ID | ID token `sub` (stable per developer team) | +| Email trusted | when the ID token says `email_verified: true` | +| Profile fields | first name, last name (sent on the first authorization only) | +| ID token | RS256 signature, issuer, audience, expiry and nonce validated | + +Private relay addresses (`...@privaterelay.appleid.com`) are real, verified inboxes. To email them, register your sending domain under **Sign in with Apple for Email Communication** in the developer account. + +## Troubleshooting + + + The Services ID, Team ID, Key ID or key do not belong together. Check that the + key has Sign in with Apple enabled for the same primary App ID as the Services + ID, and that `APPLE_CLIENT_ID` is the Services ID - not the App ID. + + +- **"Invalid redirect_uri" on Apple's page** - the return URL is not listed on the Services ID, or `VITNODE_WEB_URL` differs from it. +- **The callback page says the request expired** - the sign-in started on another host than it returned to (for example `localhost` vs. the tunnel), so the browser had no matching cookie. +- **A missing variable** - the button still renders, and pressing it fails with `Missing Apple ...` in the server log. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/atlassian.mdx b/apps/web/content/docs/dev/sso/atlassian.mdx new file mode 100644 index 0000000000..494cf581ea --- /dev/null +++ b/apps/web/content/docs/dev/sso/atlassian.mdx @@ -0,0 +1,142 @@ +--- +title: Atlassian +description: Add Atlassian account sign-in to VitNode - create an OAuth 2.0 (3LO) integration with only the User identity API and wire up the Atlassian SSO adapter. +icon: Layers +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Atlassian** button so people can sign in with the Atlassian account they use for Jira, Confluence or Trello. This is sign-in only: VitNode asks for the person's own profile and nothing from Jira, Confluence or any other product. + +## Quick start + +```bash title=".env" +ATLASSIAN_CLIENT_ID=AbCdEf123456 +ATLASSIAN_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { AtlassianSSOApiPlugin } from '@vitnode/core/api/adapters/sso/atlassian' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + AtlassianSSOApiPlugin({ + clientId: process.env.ATLASSIAN_CLIENT_ID, + clientSecret: process.env.ATLASSIAN_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the integration + + + + +### Create an OAuth 2.0 integration + +Open the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), press **Create** and choose **OAuth 2.0 integration**. Name it after your site - people see the name on the consent screen. + + + + + +### Add only the User identity API + +Open **Permissions**, find **User identity API** and press **Add**, then **Configure** and enable **View user profile** (`read:me`). Leave every product API (Jira, Confluence, Bitbucket) alone - VitNode does not need them, and asking for them would turn a sign-in button into a product integration. + + + + + +### Add the callback URL + +Open **Authorization**, press **Add** next to **OAuth 2.0 (3LO)** and enter the callback URL: + +| Environment | Callback URL | +| ------------------------------------- | --------------------------------------------- | +| Development (`VITNODE_WEB_URL` unset) | `http://localhost:3000/login/sso/atlassian` | +| Production | `https://your-domain.com/login/sso/atlassian` | + +VitNode builds it from `VITNODE_WEB_URL`, so it must match exactly. + + + + + +### Copy the credentials + +Open **Settings** and copy the **Client ID** and **Secret** into server-side variables (never `clientEnv`): + +```bash title=".env" +ATLASSIAN_CLIENT_ID=AbCdEf123456 # [!code ++] +ATLASSIAN_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `AtlassianSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login` and press **Continue with Atlassian**. The consent screen should list only "View your user profile". After **Accept** you return to `/login/sso/atlassian` and are signed in - or asked to [confirm an email](/docs/dev/sso/email-confirmation) if Atlassian did not share a verified one. + + + + +## Share the integration + +A new integration works only for its owner. To let anyone sign in, open **Distribution**, set the status to **Sharing**, and fill in the vendor and privacy details Atlassian asks for. + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ---------------------------------------- | +| Scopes | `read:me` | +| Account ID | `account_id` (one per Atlassian account) | +| Email trusted | only with `email_verified: true` | +| Profile fields | avatar | + +Atlassian has no separate first and last name, and VitNode does not split the display name. People can hide their email in their Atlassian profile; they then confirm one with a code. + +## Troubleshooting + +- **"Something went wrong" on Atlassian's page** - the callback URL is not on the **Authorization** page, or `VITNODE_WEB_URL` differs from it. +- **Only you can sign in** - the integration is not shared yet; see [Share the integration](#share-the-integration). +- **The consent screen asks for Jira or Confluence** - a product API was added under **Permissions**. Remove it; sign-in needs only `read:me`. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/connected-accounts.mdx b/apps/web/content/docs/dev/sso/connected-accounts.mdx index 81806f3658..609f454e77 100644 --- a/apps/web/content/docs/dev/sso/connected-accounts.mdx +++ b/apps/web/content/docs/dev/sso/connected-accounts.mdx @@ -1,6 +1,6 @@ --- title: Connected Accounts -description: Let members connect Google, Discord, Facebook or custom SSO accounts from Settings, import their avatar and name, and keep them in sync on sign-in. +description: Let members connect any configured SSO provider from Settings, import their avatar and name, and keep them in sync on sign-in. icon: Link --- @@ -20,16 +20,18 @@ Only the avatar, first name and last name are ever imported. Email, username, di ## How connecting works -1. The member selects **Connect**. `POST /users/sso/connections/{providerId}/authorize` stores a short-lived, single-use **state** bound to that member, that provider and the `link` intent - only its SHA-256 hash is kept, in `core_users_sso_operations`. +1. The member selects **Connect**. `POST /users/sso/connections/{providerId}/authorize` checks that this browser signed in within the **last hour** - otherwise it answers `403 reauthentication_required` and the member is asked to sign in again. It then stores a short-lived, single-use **state** bound to that member, that provider and the `link` intent, together with the round trip's PKCE verifier and nonce. Only the state's SHA-256 hash is kept, in `core_users_sso_operations`. 2. The browser goes to the provider and comes back to the usual callback, `/login/sso/{providerId}`. That page recognises a settings state and calls the authenticated `POST /users/sso/connections/{providerId}/callback` instead of the public sign-in. 3. The state is spent the moment it is looked up - even a failed attempt burns it - and it must still be fresh (ten minutes), for the same provider, the same intent and the **currently signed-in member**. Anything else is a `400 invalid_state`. -4. The provider account is linked to the member. Accounts are never merged by email. +4. The provider account is linked to the member by its stable account ID. Accounts are never merged by email, so a provider that shares no email (Reddit, TikTok) or an unverified one connects just the same. 5. The member lands back on `/settings/sso` with a toast. A provider account already linked to someone else answers `409 account_taken`. A member can have **one account per provider**; a second one answers `409 provider_already_connected`. Both rules are unique constraints on `core_users_sso`, so two racing requests can't sneak past them. Connecting never creates a session - public or AdminCP - and never changes the profile. +The recent sign-in rule means a stolen session cookie alone cannot add a sign-in method the thief controls. Import and sync round trips are not affected: they only read from an account that is already connected. + The public sign-in and the login-time "Connect *Provider* to your account" password flow work exactly as before. See [How Accounts Link](/docs/dev/sso#how-accounts-link). diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx index 629c3508e0..a06b9b964d 100644 --- a/apps/web/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx @@ -6,7 +6,7 @@ icon: Puzzle import { TypeTable } from "fumadocs-ui/components/type-table" -VitNode includes built-in SSO adapters for Google, Discord, and Facebook. You can connect any other OAuth2 provider (e.g. GitHub, Apple, Slack) by implementing the `SSOApiPlugin` interface. State cookie generation, user session linking, and login buttons are handled automatically. +VitNode includes [15 built-in SSO adapters](/docs/dev/sso#providers), from Apple to Twitter (X). You can connect any other OAuth 2.0 or OpenID Connect provider (for example GitHub, Bitbucket or Zoom) by implementing the `SSOApiPlugin` interface. The sign-in attempt (state, PKCE verifier, nonce), account linking, email confirmation and login buttons are handled by VitNode. ## Quick start: GitHub SSO Adapter @@ -67,12 +67,14 @@ export const GitHubSSOApiPlugin = ({ const emailRes = await fetch("https://api.github.com/user/emails", { headers }) const emails: Array<{ email: string; primary: boolean; verified: boolean }> = await emailRes.json() - const primaryEmail = emails.find((e) => e.primary && e.verified)?.email - if (!primaryEmail) throw new Error("GitHub account has no verified email") + const primary = emails.find((e) => e.primary) return { id: String(user.id), - email: primaryEmail, + email: primary?.email ?? null, + // An unverified (or missing) address sends a new visitor through + // email confirmation instead of being trusted. + emailVerified: primary?.verified === true, username: user.login, avatarUrl: user.avatar_url, } @@ -131,19 +133,19 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and type: "string", }, getUrl: { - description: "Returns the provider authorization redirect URL.", + description: "Returns the provider authorization URL. Pass state through unchanged; send codeChallenge (S256) and nonce when the provider supports PKCE and OpenID Connect.", required: true, - type: "({ state: string }) => string", + type: "(request: { state: string; codeChallenge: string; codeChallengeMethod: 'S256'; nonce: string }) => string", }, fetchToken: { - description: "Exchanges code for access_token.", + description: "Exchanges the code for tokens. The context carries the attempt's PKCE verifier, nonce and any extra callback parameters (such as Apple's user). Extra token fields, like id_token, are kept for fetchUser.", required: true, - type: "(code: string) => Promise<{ access_token: string; token_type: string }>", + type: "(code: string, context: SSOCallbackContext) => Promise", }, fetchUser: { - description: "Fetches the user profile: id, email, username, and optionally emailVerified, avatarUrl, firstName and lastName. VitNode keys accounts on the email, so set emailVerified: false (or throw) for an address the provider has not confirmed - VitNode then refuses the sign-in with a 400 instead of creating, linking or verifying an account with it. Set it to true when the provider confirms the address. The built-in adapters only ever return confirmed addresses.", + description: "Returns the provider account: a stable id, and optionally email, emailVerified, username, avatarUrl, firstName and lastName. Validate the ID token here when the provider issues one. An email counts as trusted unless emailVerified is false: return emailVerified: false (or email: null) when the provider has not confirmed the address, and VitNode asks a new visitor to confirm one with a code instead. A returning member is always recognised by id.", required: true, - type: "(token: TokenResponse) => Promise", + type: "(token: SSOTokenResponse, context: SSOCallbackContext) => Promise", }, profileFields: { description: "Which profile fields the provider can supply for Connected accounts: 'avatar', 'firstName', 'lastName'. Leave it out and nothing is ever imported from this adapter.", @@ -153,6 +155,36 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and }} /> +Adapters written for older versions, with `getUrl({ state })`, `fetchToken(code)` and `fetchUser(token)`, still work: the extra arguments are simply ignored. + +## PKCE, ID tokens and provider calls + +`@vitnode/core/api/lib/sso-protocol` has the small helpers the built-in adapters use. All of them are server-only: + +| Helper | Use it to | +| -------------------------- | ------------------------------------------------------------------------------------------------- | +| `requestSsoProviderJson` | Call a provider endpoint with a 10-second timeout and validate the JSON with a Zod schema (`502` otherwise) | +| `formPost`, `basicClientAuth`, `bearerAuth` | Build a form-encoded token request, HTTP Basic client auth or a bearer header | +| `verifySsoIdToken` | Validate an ID token with `jose`: signature, algorithm, `iss`, `aud`, `exp`/`iat` and `nonce` | +| `remoteJwks` | Fetch and cache the provider's signing keys | + +```ts title="apps/api/src/utils/sso/acme.ts" +import { remoteJwks, verifySsoIdToken } from '@vitnode/core/api/lib/sso-protocol' + +// inside the adapter +fetchUser: async ({ id_token }, { nonce }) => { + const claims = await verifySsoIdToken({ + audience: clientId, + idToken: id_token, + issuer: 'https://id.acme.example', + keys: remoteJwks('https://id.acme.example/.well-known/jwks.json'), + nonce, + }) + + return { id: String(claims.sub), email: null } +}, +``` + See [Provider Icons and Colors](/docs/dev/sso/icons) for the accepted `icon` and `brandColor` formats, and for registering a React component instead. ## Profile data diff --git a/apps/web/content/docs/dev/sso/discord.mdx b/apps/web/content/docs/dev/sso/discord.mdx index b54a4c46cd..de72039071 100644 --- a/apps/web/content/docs/dev/sso/discord.mdx +++ b/apps/web/content/docs/dev/sso/discord.mdx @@ -6,7 +6,7 @@ icon: MessagesSquare import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -Discord is the quickest of the three to set up: no consent screen review, no app +Discord is one of the quickest providers to set up: no consent screen review, no app modes, no verification. Create an application, add one redirect URL, copy two values. @@ -195,7 +195,7 @@ permissions. | Profile URL | `https://discord.com/api/users/@me` | | Scopes | `identify email` | | Provider id | `discord` | -| Fields read | `id`, `email`, `username`, `avatar` | +| Fields read | `id`, `email`, `verified`, `username`, `avatar` | Both scopes are required: `identify` for the id and username, `email` for the address the account is created with. @@ -216,11 +216,10 @@ avatar is offered. generic error screen. - - Unlike the Google adapter, which rejects an unverified address, this one takes - Discord's `email` at face value and never reads Discord's `verified` field. If - you need that guarantee, copy the adapter into your own project and add the - check - see [Custom adapter](/docs/dev/sso/custom-adapter). + + The adapter reads Discord's `verified` field and answers `400` when it is + `false`, so a Discord account with an unconfirmed address cannot sign in until + it confirms the address in Discord. The visitor sees the generic error screen. diff --git a/apps/web/content/docs/dev/sso/email-confirmation.mdx b/apps/web/content/docs/dev/sso/email-confirmation.mdx new file mode 100644 index 0000000000..45e8ff76a1 --- /dev/null +++ b/apps/web/content/docs/dev/sso/email-confirmation.mdx @@ -0,0 +1,106 @@ +--- +title: Email confirmation +description: How VitNode signs people in with providers that share no verified email - Reddit, TikTok, Kick and others - by confirming an address with a six-digit code. +icon: MailCheck +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import emailStep from './email-confirmation/email-step.webp' +import codeStep from './email-confirmation/code-step.webp' + +Some providers never share an email address (Reddit, TikTok), share one without saying whether it is verified (Kick), or share one that is not always trustworthy (Microsoft, X without email access). VitNode still signs these people in: a returning member is recognised by their provider account, and a new visitor confirms an address of their own with a six-digit code before any account exists. + +## When VitNode trusts a provider email + +An adapter's `fetchUser` returns `email` and `emailVerified`. VitNode treats the address as **trusted** only when it is present and `emailVerified` is not `false`. Each built-in adapter sets `emailVerified` from the provider's own flag - for example `email_verified`, Microsoft's `xms_edov` or PayPal's `confirmed` - and the provider guides list which one. + +A trusted email can create an account, match an existing account (which then needs its password before linking) and confirm an unconfirmed account. An untrusted email does none of these. It is only offered to the visitor as a suggestion they can confirm. + +## What the visitor sees + + + + +### The provider sign-in finishes + +The visitor signs in at the provider and lands on `/login/sso/{providerId}`. VitNode validates the round trip and asks the provider who they are. + + + + + +### Returning members go straight in + +If this provider account was linked before, the visitor is signed in. The email does not matter here, and an address stored earlier is kept even when the provider leaves it out this time. + + + + + +### New visitors enter an email + +Otherwise the page shows **Confirm your email**. The visitor types an address (pre-filled with the provider's unverified one, if there was any) and presses **Send code**. VitNode emails a six-digit code and answers the same way whether or not the address has an account. + + + + + + + +### The code decides what happens + +The visitor enters the code from the email. **Send a new code** and **Use a different email** are there if it does not arrive. + + + +Then one of two things happens: + +- **The address is new** - VitNode creates the account with `emailVerified: true`, links the provider account and signs the visitor in. +- **The address belongs to an account** - the page shows **Connect _Provider_ to your account** and asks for that account's password, exactly like a provider email that matches an account. + + + + +## Security rules + +- The pending sign-in lives on the server, in `core_sso_pending`, for 30 minutes. The browser holds only a random token in an HttpOnly cookie. Provider tokens are never stored, and nothing sensitive goes into the URL. +- A code works for 10 minutes and allows five guesses. A new code replaces the old one. The visitor can ask for one every 30 seconds, up to five times. +- The code must be entered in the browser that started the sign-in. Someone who types your address on their own device cannot finish with a code that only you received. +- The pending sign-in is consumed once. A second confirmation with the same code fails. +- VitNode never makes up an address from a username, never marks a provider email verified just because OAuth succeeded, and never links accounts by an unverified email. + +## Requirements + +Confirmation needs an [email adapter](/docs/dev/email). Without one, VitNode cannot send the code: a new visitor from such a provider sees **We need a verified email**, and the callback answers `422 { "error": "email_unavailable" }`. Returning members are not affected. + +## API + +| Method | Path | Body | Answers | +| ------ | --------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------- | +| `GET` | `/users/sso/{providerId}/callback` | - | `202 { emailRequired: true, email }` when a code is needed | +| `POST` | `/users/sso/pending/{providerId}/email` | `{ email }` | `201`, `400 invalid_pending`, `429 too_many_codes` | +| `POST` | `/users/sso/pending/{providerId}/verify` | `{ code }` | `200 { id }` signed in, `409` link offer, `400 invalid_code`, `code_expired` or `invalid_pending` | + +The code email uses the `core.auth.sso_email_code.email` translations, so it follows the visitor's language. + +## Related + + + + + diff --git a/apps/web/content/docs/dev/sso/email-confirmation/code-step.webp b/apps/web/content/docs/dev/sso/email-confirmation/code-step.webp new file mode 100644 index 0000000000000000000000000000000000000000..f067ad924c8137c1de94587c83c5d2ce0d801807 GIT binary patch literal 40988 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
  • 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 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/sso/facebook.mdx b/apps/web/content/docs/dev/sso/facebook.mdx index fb4d39874b..8dadfb7411 100644 --- a/apps/web/content/docs/dev/sso/facebook.mdx +++ b/apps/web/content/docs/dev/sso/facebook.mdx @@ -282,14 +282,10 @@ counts as no avatar. toast is an environment problem, not a dashboard problem. - - If someone registered with the same address using the password form, the - callback answers `409` and offers them the login page instead. There is no - link-an-existing-account screen yet - see [how the callback handles - accounts](/docs/dev/sso#what-the-callback-does-with-the-account). + + If someone registered with the same address - with a password or another + provider - the callback asks for that account's password and then links the + two. See [how accounts link](/docs/dev/sso#how-accounts-link). ## Next diff --git a/apps/web/content/docs/dev/sso/gitlab.mdx b/apps/web/content/docs/dev/sso/gitlab.mdx new file mode 100644 index 0000000000..faea4a0edf --- /dev/null +++ b/apps/web/content/docs/dev/sso/gitlab.mdx @@ -0,0 +1,174 @@ +--- +title: GitLab +description: Add GitLab sign-in to VitNode for GitLab.com or a self-managed GitLab instance, using OpenID Connect with PKCE. +icon: GitBranch +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with GitLab** button that signs people in with GitLab.com or with your own GitLab instance. The adapter uses OpenID Connect with PKCE and validates GitLab's ID token. + +## Quick start + +```bash title=".env" +GITLAB_CLIENT_ID=0123456789abcdef0123456789abcdef +GITLAB_CLIENT_SECRET=gloas-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { GitLabSSOApiPlugin } from '@vitnode/core/api/adapters/sso/gitlab' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + GitLabSSOApiPlugin({ + clientId: process.env.GITLAB_CLIENT_ID, + clientSecret: process.env.GITLAB_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the application + + + + +### Create an application + +On GitLab.com open **Edit profile → Applications** (or a group's **Settings → Applications** to own it as a team), and press **Add new application**. On a self-managed instance, an administrator can also create an instance-wide one under **Admin → Applications**. + + + + + +### Fill in the redirect URI and scopes + +| Field | Value | +| ------------ | ---------------------------------------------------------------------------- | +| Name | your site's name | +| Redirect URI | `http://localhost:3000/login/sso/gitlab` and `https://your-domain.com/login/sso/gitlab`, one per line | +| Confidential | ticked | +| Scopes | `openid`, `profile`, `email` | + +VitNode builds the redirect URI from `VITNODE_WEB_URL` and the adapter `id`, so it must match exactly. Do not tick `api` or `read_user`; sign-in needs only the three OpenID scopes. + + + + + +### Copy the credentials + +Save the application and copy the **Application ID** and **Secret** into server-side variables: + +```bash title=".env" +GITLAB_CLIENT_ID=0123456789abcdef0123456789abcdef # [!code ++] +GITLAB_CLIENT_SECRET=gloas-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `GitLabSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with GitLab** and authorize the application. You return to `/login/sso/gitlab` signed in. + + + + +## Use a self-managed instance + +Pass the instance URL as `issuer`. It must be HTTPS (plain HTTP is accepted only for `localhost`), and it is also the issuer VitNode expects in the ID token. + +```ts title="src/vitnode.api.config.ts" +GitLabSSOApiPlugin({ + clientId: process.env.GITLAB_CLIENT_ID, + clientSecret: process.env.GITLAB_CLIENT_SECRET, + issuer: 'https://gitlab.example.com', // [!code ++] +}), +``` + +GitLab user IDs are unique only within one instance. For any issuer other than GitLab.com, VitNode stores the account as `gitlab.example.com:{id}`, so user `42` on your instance can never sign in as user `42` on GitLab.com. + +To offer GitLab.com and your instance side by side, register the adapter twice with different `id` and `name` values. Each `id` gets its own redirect URI: + +```ts title="src/vitnode.api.config.ts" +ssoAdapters: [ + GitLabSSOApiPlugin({ + clientId: process.env.GITLAB_CLIENT_ID, + clientSecret: process.env.GITLAB_CLIENT_SECRET, + }), + GitLabSSOApiPlugin({ + clientId: process.env.GITLAB_ACME_CLIENT_ID, + clientSecret: process.env.GITLAB_ACME_CLIENT_SECRET, + id: 'gitlab-acme', // redirect URI: /login/sso/gitlab-acme + issuer: 'https://gitlab.acme.example', + name: 'Acme GitLab', + }), +], +``` + +Choose the `issuer` before people sign in. Changing it later changes every stored account ID, and existing members would no longer be recognised. + +## Options + +| Name | Type | Required | Default | Description | +| -------------- | --------------------- | -------- | -------------------- | ------------------------------------------------ | +| `clientId` | `string \| undefined` | Yes | - | The Application ID | +| `clientSecret` | `string \| undefined` | Yes | - | The application Secret | +| `issuer` | `string` | No | `https://gitlab.com` | Instance URL; must be HTTPS | +| `id` | `string` | No | `gitlab` | Provider id in URLs and stored accounts | +| `name` | `string` | No | `GitLab` | Name on the button | + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ------------------------------------------------------- | +| Scopes | `openid profile email` | +| PKCE | `S256` | +| Account ID | `sub`, prefixed with the instance for self-managed | +| Email trusted | only with `email_verified: true` | +| Profile fields | avatar, first name, last name | + +## Troubleshooting + +- **"The redirect URI included is not valid"** - the URI on the application differs from `{VITNODE_WEB_URL}/login/sso/{id}`. +- **"The requested scope is invalid"** - the application is missing `openid`, `profile` or `email`. +- **The server refuses to start with `Invalid GitLab issuer`** - the `issuer` is not an HTTPS URL, or it has a query string. +- **Sign-in fails after the GitLab screen with a 400** - the ID token came from another issuer than `issuer`, for example the wrong instance URL. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/google.mdx b/apps/web/content/docs/dev/sso/google.mdx index d3ca1a0a74..9f0f76549e 100644 --- a/apps/web/content/docs/dev/sso/google.mdx +++ b/apps/web/content/docs/dev/sso/google.mdx @@ -271,14 +271,10 @@ account without one simply has nothing to import for that field. console. - - If someone registered with the same address using the password form, the - callback answers `409` and offers them the login page instead. There is no - link-an-existing-account screen yet - see [how the callback handles - accounts](/docs/dev/sso#what-the-callback-does-with-the-account). + + If someone registered with the same address - with a password or another + provider - the callback asks for that account's password and then links the + two. See [how accounts link](/docs/dev/sso#how-accounts-link). ## Next diff --git a/apps/web/content/docs/dev/sso/icons.mdx b/apps/web/content/docs/dev/sso/icons.mdx index d477a88235..8081d917cb 100644 --- a/apps/web/content/docs/dev/sso/icons.mdx +++ b/apps/web/content/docs/dev/sso/icons.mdx @@ -10,10 +10,12 @@ import { ImgDocs } from '@/components/fumadocs/img' import loginSsoButtons from './icons/login-sso-buttons.png' -Every SSO provider gets a full-width **Continue with _Provider_** button on -`/login` and `/register`. The built-in Google, Discord and Facebook adapters -already carry their brand marks and colors, so those buttons look right without -any configuration. A custom adapter starts out as a neutral button with +Every SSO provider gets a **Continue with _Provider_** button on `/login` and +`/register` (with five or more providers they go in two columns with just the +provider name, keeping "Continue with _Provider_" as the accessible name). The [built-in adapters](/docs/dev/sso#providers) already carry their +brand marks and, where the brand allows, a fill color, so those buttons look +right without any configuration. Twitch, Slack and Microsoft stay neutral +outline buttons with their mark. A custom adapter starts out as a neutral button with text only - a **GitHub** button with no octocat - and this page shows how to give it a mark and a color. diff --git a/apps/web/content/docs/dev/sso/index.mdx b/apps/web/content/docs/dev/sso/index.mdx index c95b087121..d32bb11ef3 100644 --- a/apps/web/content/docs/dev/sso/index.mdx +++ b/apps/web/content/docs/dev/sso/index.mdx @@ -1,135 +1,124 @@ --- title: Single Sign-On (SSO) -description: Enable social authentication with Google, Discord, Facebook, or custom OAuth2 providers in VitNode. +description: Enable social and OpenID Connect sign-in in VitNode with 15 built-in providers - Apple, Google, Microsoft, GitLab and more - or a custom OAuth2 adapter. icon: KeyRound --- -VitNode provides built-in OAuth2 single sign-on. Register an adapter in your API configuration, and authentication buttons automatically appear on `/login` and `/register`. +VitNode signs people in with OAuth 2.0 and OpenID Connect providers. Register an adapter in your API configuration, and its button appears on `/login`, `/register` and **Settings → Connected accounts**. ## Quick start -Register an SSO adapter in `apps/api/src/vitnode.api.config.ts`: +Register an SSO adapter in your API config (`apps/api/src/vitnode.api.config.ts`, or `src/vitnode.api.config.ts` in an app that serves its own API): -```ts title="apps/api/src/vitnode.api.config.ts" -import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google" -import { buildApiConfig } from "@vitnode/core/vitnode.config" +```ts title="src/vitnode.api.config.ts" +import { GoogleSSOApiPlugin } from '@vitnode/core/api/adapters/sso/google' +import { buildApiConfig } from '@vitnode/core/vitnode.config' export const vitNodeApiConfig = buildApiConfig({ // [!code ++:8] authorization: { ssoAdapters: [ GoogleSSOApiPlugin({ - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), ], }, }) ``` -A **Continue with Google** button automatically renders on your login and -registration forms, four-color G included. The built-in adapters ship their own -marks and colors - Discord and Facebook buttons are filled with their brand -color - and a custom adapter can [add its own](/docs/dev/sso/icons). - ---- - -## Supported Providers - - - - - - - - - ---- +A **Continue with Google** button renders on the login and registration forms. Built-in adapters ship their own marks and, where the brand allows it, a filled button color; a custom adapter can [add its own](/docs/dev/sso/icons). With five or more providers, the buttons go in two columns. + +## Providers + +| Provider | Import | Shares a verified email | Local `http://localhost` | +| --------------------------------------------- | ---------------------------------------------- | --------------------------------- | ------------------------ | +| [Apple](/docs/dev/sso/apple) | `AppleSSOApiPlugin` from `.../sso/apple` | Yes (may be a private relay) | No - HTTPS domain | +| [Atlassian](/docs/dev/sso/atlassian) | `AtlassianSSOApiPlugin` from `.../sso/atlassian` | When `email_verified` | Yes | +| [Discord](/docs/dev/sso/discord) | `DiscordSSOApiPlugin` from `.../sso/discord` | Yes (unverified ones are refused) | Yes | +| [Facebook](/docs/dev/sso/facebook) | `FacebookSSOApiPlugin` from `.../sso/facebook` | Taken as returned | Yes | +| [GitLab](/docs/dev/sso/gitlab) | `GitLabSSOApiPlugin` from `.../sso/gitlab` | When `email_verified` | Yes | +| [Google](/docs/dev/sso/google) | `GoogleSSOApiPlugin` from `.../sso/google` | Yes (unverified ones are refused) | Yes | +| [Kick](/docs/dev/sso/kick) | `KickSSOApiPlugin` from `.../sso/kick` | No - code confirmation | Yes | +| [LinkedIn](/docs/dev/sso/linkedin) | `LinkedInSSOApiPlugin` from `.../sso/linkedin` | When `email_verified` | Yes | +| [Microsoft](/docs/dev/sso/microsoft) | `MicrosoftSSOApiPlugin` from `.../sso/microsoft` | When `xms_edov` | Yes | +| [PayPal](/docs/dev/sso/paypal) | `PayPalSSOApiPlugin` from `.../sso/paypal` | When confirmed | No - HTTPS | +| [Reddit](/docs/dev/sso/reddit) | `RedditSSOApiPlugin` from `.../sso/reddit` | No - code confirmation | Yes | +| [Slack](/docs/dev/sso/slack) | `SlackSSOApiPlugin` from `.../sso/slack` | When `email_verified` | HTTPS tunnel | +| [TikTok](/docs/dev/sso/tiktok) | `TikTokSSOApiPlugin` from `.../sso/tiktok` | No - code confirmation | No - HTTPS | +| [Twitch](/docs/dev/sso/twitch) | `TwitchSSOApiPlugin` from `.../sso/twitch` | When `email_verified` | Yes | +| [Twitter (X)](/docs/dev/sso/twitter) | `TwitterSSOApiPlugin` from `.../sso/twitter` | `confirmed_email`, when shared | `http://127.0.0.1` | + +Every import path starts with `@vitnode/core/api/adapters/sso/`. For anything else, write a [custom adapter](/docs/dev/sso/custom-adapter). ## Callback URLs -When configuring OAuth2 applications in provider developer consoles, use the following redirect URI pattern: +Register this redirect (callback) URL with the provider: ```text -https://your-domain.com/login/sso/{provider_id} +https://your-domain.com/login/sso/{providerId} ``` -For example: `https://your-domain.com/login/sso/google`. +VitNode builds it from `VITNODE_WEB_URL` with `getRedirectUri(providerId)`, for example `https://your-domain.com/login/sso/google`. It must match exactly: same scheme and host, no trailing slash, no locale prefix. ---- +Providers that require a public HTTPS URL (Apple, PayPal, TikTok, and in practice Slack) need a tunnel for local work. Point `VITNODE_WEB_URL` (and `VITNODE_API_URL`, when the app serves its own API) at the tunnel's HTTPS address, and open the site there - the sign-in cookie belongs to the host the sign-in started on. + +## How accounts link -## How Accounts Link +When the provider sends the visitor back, VitNode checks the state, asks the adapter for the provider account, then: -When a user signs in via SSO: -1. **Known Identity**: The provider account was linked before, so the user is signed straight into it. -2. **Existing Email Match**: An account already uses that address. The visitor is asked for that account's password; once it checks out, the provider identity is linked to the account and they are signed in. From then on that provider signs them straight in (case 1). A social login can never take over an account on its own - the password is what proves ownership. -3. **New Visitor**: A new user is created with their social display name and email. Avatar and real name stay empty until the member imports them from [Connected accounts](/docs/dev/sso/connected-accounts). +1. **Known identity** - the provider account was linked before. The visitor is signed straight in, recognised by the provider's stable account ID. The email does not matter here, and an address stored earlier is kept even when the provider omits it this time. +2. **Trusted email, existing account** - an account already uses that address. The visitor is asked for that account's password; once it checks out, the provider account is linked and they are signed in. A social login never takes over an account on its own. +3. **Trusted email, new visitor** - a new account is created with the provider's display name and `emailVerified: true`, and linked. +4. **No trusted email** - the provider shared no address, or one it has not verified. The visitor [confirms an address with a code](/docs/dev/sso/email-confirmation) first, then continues as in case 2 or 3. -Signed-in members can also connect more providers, import their avatar and name, and disconnect accounts in **Settings → Connected accounts** - see [Connected Accounts](/docs/dev/sso/connected-accounts). +An email is **trusted** when the adapter returns it without `emailVerified: false`. Each provider guide says which provider flag decides it. + +Signed-in members can also connect more providers, import their avatar and name, and disconnect accounts in **Settings → Connected accounts** - see [Connected accounts](/docs/dev/sso/connected-accounts). ### SSO confirms the email -A provider only hands VitNode an address it has verified itself (the built-in -adapters refuse anything else), so a social sign-in doubles as email -confirmation: +A trusted provider email doubles as email confirmation: + +- a **new visitor** starts out with `emailVerified: true`, even when an email adapter is configured and password sign-ups would have to confirm first; +- a **known identity** or a **freshly linked** account whose address is still unconfirmed is marked confirmed - provided the trusted address the provider returned is the one on the account. An account whose email was changed since the link was made is left alone. -- a **new visitor** starts out with `emailVerified: true`, even when an email - adapter is configured and password sign-ups would have to confirm first; -- a **known identity** or a **freshly linked** account whose address is still - unconfirmed is marked confirmed - provided the address the provider returned - is the one on the account. An account whose email was changed since the link - was made is left alone. +An untrusted address never confirms anything. ### Linking an existing account -The callback answers `409` with a short-lived, signed **link offer** - the -account's email, whether it has a password, and a token good for ten minutes. -The login page turns that into a "Connect *Provider* to your account" form: the -email is shown read-only, the visitor types their password, and -`POST /users/sso/{providerId}/link` verifies both before it writes the -`core_users_sso` row and mints a session. The token carries the provider account -id, so nothing about *which* identity gets linked is taken from the browser. +The callback answers `409` with a short-lived, signed **link offer** - the account's email, whether it has a password, and a token good for ten minutes. The login page turns that into a "Connect *Provider* to your account" form: the email is shown read-only, the visitor types their password, and `POST /users/sso/{providerId}/link` verifies both before it writes the `core_users_sso` row and mints a session. The token carries the provider account ID, so nothing about *which* identity gets linked is taken from the browser. -An account created by another provider has no password to confirm with. When an -email adapter is configured the form offers **Set a password** (the ordinary -reset flow) and asks them to try the provider again afterwards; without one it -points back to the login page. +An account created by another provider has no password to confirm with. When an email adapter is configured the form offers **Set a password** (the ordinary reset flow) and asks them to try the provider again afterwards; without one it points back to the login page. Every successful link emits [`user.sso.linked`](/docs/dev/events/built-in-events). The address the provider returns is matched in its canonical form, so a Google account that reports `jan.kowalski@gmail.com` finds the member who registered as `jankowalski@gmail.com` instead of quietly becoming a second account. See [One Mailbox, One Account](/docs/dev/advanced/auth#one-mailbox-one-account). -## Learn More +## The sign-in round trip + +Pressing a provider button calls `POST /users/sso/{providerId}`. VitNode stores a **sign-in attempt** in `core_sso_attempts`: hashes of a random `state` and of a browser-binding secret, plus a PKCE verifier and an OpenID Connect nonce. The browser gets only the binding secret, in an HttpOnly `SameSite=Lax` cookie. + +The callback consumes the attempt once. It is refused with `400` when it is unknown, older than ten minutes, already used, started in another browser, or started for another provider. The adapter then receives the PKCE verifier and nonce for the token exchange and ID-token checks; neither ever reaches the browser. + +Apple returns with a cross-site `POST`. VitNode's Start middleware turns it into a same-URL `GET` so the attempt is checked like any other - see [Apple](/docs/dev/sso/apple#how-apples-callback-works). + +## Learn more + diff --git a/apps/web/content/docs/dev/sso/kick.mdx b/apps/web/content/docs/dev/sso/kick.mdx new file mode 100644 index 0000000000..e28695e1b8 --- /dev/null +++ b/apps/web/content/docs/dev/sso/kick.mdx @@ -0,0 +1,136 @@ +--- +title: Kick +description: Add Kick sign-in to VitNode - create a Kick developer app, register the redirect URL and wire up the Kick SSO adapter with PKCE. +icon: Zap +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Kick** button for streamers and viewers. Kick requires OAuth 2.1 with PKCE, which the adapter handles for you, and asks only for `user:read`. + +## Before you begin + +- A Kick account with **two-factor authentication** turned on. Kick will not let you create an app without it. +- An [email adapter](/docs/dev/email). Kick does not say whether an account's email is verified, so every new member [confirms an address with a code](/docs/dev/sso/email-confirmation). + +## Quick start + +```bash title=".env" +KICK_CLIENT_ID=01ABCDEFGHJKMNPQRSTVWXYZ +KICK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { KickSSOApiPlugin } from '@vitnode/core/api/adapters/sso/kick' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + KickSSOApiPlugin({ + clientId: process.env.KICK_CLIENT_ID, + clientSecret: process.env.KICK_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the app + + + + +### Create a developer app + +On kick.com open **Settings → Developer** and press **Create new**. Give the app the name people will see when they authorize it. + + + + + +### Add the redirect URL and scope + +Enter the redirect URL and tick only **Read user information** (`user:read`): + +| Environment | Redirect URL | +| ------------------------------------- | ---------------------------------------- | +| Development (`VITNODE_WEB_URL` unset) | `http://localhost:3000/login/sso/kick` | +| Production | `https://your-domain.com/login/sso/kick` | + +VitNode builds it from `VITNODE_WEB_URL`, so it must match exactly. Use `localhost`, not `127.0.0.1` - Kick rewrites `127.0.0.1` in redirect URLs to `localhost`, which then no longer matches. + + + + + +### Copy the credentials + +Copy the **Client ID** and **Client Secret** into server-side variables: + +```bash title=".env" +KICK_CLIENT_ID=01ABCDEFGHJKMNPQRSTVWXYZ # [!code ++] +KICK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `KickSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with Kick** and allow access. The first time, VitNode asks for an email and a code; afterwards the same Kick account signs straight in. + + + + +## Scopes and profile fields + +| Setting | Value | +| -------------- | -------------------------------------------------------- | +| Scopes | `user:read` | +| PKCE | `S256` (required by Kick) | +| Account ID | `user_id` | +| Email trusted | never - Kick has no verification flag | +| Profile fields | avatar | + +Kick's email, when present, only pre-fills the confirmation form. + +## Troubleshooting + +- **Kick shows an invalid redirect error** - the redirect URL differs from `{VITNODE_WEB_URL}/login/sso/kick`, or uses `127.0.0.1`. +- **New visitors see "We need a verified email"** - no email adapter is configured, so VitNode cannot send the code. +- **"Create new" is missing in Settings → Developer** - turn on two-factor authentication first. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/linkedin.mdx b/apps/web/content/docs/dev/sso/linkedin.mdx new file mode 100644 index 0000000000..25033e39b9 --- /dev/null +++ b/apps/web/content/docs/dev/sso/linkedin.mdx @@ -0,0 +1,144 @@ +--- +title: LinkedIn +description: Add Sign In with LinkedIn using OpenID Connect to VitNode - add the product to a LinkedIn app, register the redirect URL and wire up the adapter. +icon: Briefcase +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with LinkedIn** button using LinkedIn's current OpenID Connect product. The adapter asks for `openid profile email` and reads the member from LinkedIn's UserInfo endpoint. + +## Before you begin + +- A LinkedIn account and a [LinkedIn Page](https://www.linkedin.com/company/setup/new/) for your organization. LinkedIn ties every developer app to a Page. + +## Quick start + +```bash title=".env" +LINKEDIN_CLIENT_ID=77abcdefghijkl +LINKEDIN_CLIENT_SECRET=xxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { LinkedInSSOApiPlugin } from '@vitnode/core/api/adapters/sso/linkedin' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + LinkedInSSOApiPlugin({ + clientId: process.env.LINKEDIN_CLIENT_ID, + clientSecret: process.env.LINKEDIN_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the app + + + + +### Create an app + +In the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps), press **Create app**, enter the name and logo people will see, and choose your LinkedIn Page. + + + + + +### Add the OpenID Connect product + +Open the **Products** tab and request **Sign In with LinkedIn using OpenID Connect**. It is self-serve and is approved immediately. Without it, LinkedIn rejects the `openid`, `profile` and `email` scopes. + + + + + +### Add the redirect URL + +On the **Auth** tab, under **OAuth 2.0 settings**, add each redirect URL: + +| Environment | Redirect URL | +| ------------------------------------- | -------------------------------------------- | +| Development (`VITNODE_WEB_URL` unset) | `http://localhost:3000/login/sso/linkedin` | +| Production | `https://your-domain.com/login/sso/linkedin` | + +VitNode builds it from `VITNODE_WEB_URL`, so it must match exactly. + + + + + +### Copy the credentials + +The **Client ID** and **Primary Client Secret** are at the top of the **Auth** tab. Keep them server-side: + +```bash title=".env" +LINKEDIN_CLIENT_ID=77abcdefghijkl # [!code ++] +LINKEDIN_CLIENT_SECRET=xxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `LinkedInSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with LinkedIn** and allow access. You return to `/login/sso/linkedin` signed in. + + + + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ------------------------------------------- | +| Scopes | `openid profile email` | +| Account ID | `sub` | +| Email trusted | only with `email_verified: true` | +| Profile fields | avatar, first name, last name | + +LinkedIn may leave out the email, or mark it unverified. A new member then [confirms an address with a code](/docs/dev/sso/email-confirmation); a returning member still signs in by their LinkedIn ID. + +VitNode reads UserInfo with the access token it has just received from LinkedIn's token endpoint over TLS, so it does not decode LinkedIn's ID token. LinkedIn notes that this product does not verify identities, so treat a LinkedIn sign-in like any other social account. + +## Troubleshooting + +- **"unauthorized_scope_error"** - the **Sign In with LinkedIn using OpenID Connect** product is missing from the **Products** tab. +- **"The redirect_uri does not match the registered value"** - the URL on the **Auth** tab differs from `{VITNODE_WEB_URL}/login/sso/linkedin`. +- **The older `r_liteprofile` and `r_emailaddress` scopes** - LinkedIn retired them in 2023. The adapter never requests them. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/meta.json b/apps/web/content/docs/dev/sso/meta.json index fbdfb43945..3c9f325ec2 100644 --- a/apps/web/content/docs/dev/sso/meta.json +++ b/apps/web/content/docs/dev/sso/meta.json @@ -1,16 +1,29 @@ { "title": "Single Sign-On (SSO)", - "description": "OAuth2 sign-in with Google, Discord, Facebook or your own adapter", + "description": "OAuth and OpenID Connect sign-in with 15 built-in providers or your own adapter", "icon": "KeyRound", "pages": [ "index", + "email-confirmation", "icons", "connected-accounts", "using-connected-accounts", "---Providers---", - "google", + "apple", + "atlassian", "discord", "facebook", + "gitlab", + "google", + "kick", + "linkedin", + "microsoft", + "paypal", + "reddit", + "slack", + "tiktok", + "twitch", + "twitter", "custom-adapter" ] } diff --git a/apps/web/content/docs/dev/sso/microsoft.mdx b/apps/web/content/docs/dev/sso/microsoft.mdx new file mode 100644 index 0000000000..163c1bc835 --- /dev/null +++ b/apps/web/content/docs/dev/sso/microsoft.mdx @@ -0,0 +1,175 @@ +--- +title: Microsoft +description: Add Microsoft sign-in to VitNode for work, school and personal Microsoft accounts - register an Entra app, pick the tenant and enforce who may sign in. +icon: Building2 +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Microsoft** button for Microsoft Entra ID (work and school) and personal Microsoft accounts. The adapter uses OpenID Connect with PKCE, validates the ID token and enforces the tenant you choose on every sign-in. + +## Quick start + +```bash title=".env" +MICROSOFT_CLIENT_ID=00000000-1111-2222-3333-444444444444 +MICROSOFT_CLIENT_SECRET=xxxxx~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { MicrosoftSSOApiPlugin } from '@vitnode/core/api/adapters/sso/microsoft' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:9] + authorization: { + ssoAdapters: [ + MicrosoftSSOApiPlugin({ + clientId: process.env.MICROSOFT_CLIENT_ID, + clientSecret: process.env.MICROSOFT_CLIENT_SECRET, + tenant: 'common', + }), + ], + }, +}) +``` + +## Register the application + + + + +### Create an app registration + +In the [Microsoft Entra admin center](https://entra.microsoft.com/), open **Identity → Applications → App registrations** and press **New registration**. + + + + + +### Choose who can sign in + +Pick the **Supported account types** and use the matching `tenant` value: + +| Supported account types | `tenant` | +| ---------------------------------------------------------- | --------------- | +| Accounts in any organizational directory and personal accounts | `common` | +| Accounts in any organizational directory | `organizations` | +| Personal Microsoft accounts only | `consumers` | +| Accounts in this organizational directory only | your tenant ID | + + + + + +### Add the redirect URI + +Under **Redirect URI**, choose the **Web** platform and enter the URI, then press **Register**. Add more under **Authentication** later. + +| Environment | Redirect URI | +| ------------------------------------- | --------------------------------------------- | +| Development (`VITNODE_WEB_URL` unset) | `http://localhost:3000/login/sso/microsoft` | +| Production | `https://your-domain.com/login/sso/microsoft` | + +Microsoft allows plain `http://` only for `localhost`. + + + + + +### Create a client secret + +Copy the **Application (client) ID** from **Overview**. Open **Certificates & secrets → Client secrets → New client secret**, and copy its **Value** - not the Secret ID. Microsoft shows the value once, and it expires on the date you pick. + +```bash title=".env" +MICROSOFT_CLIENT_ID=00000000-1111-2222-3333-444444444444 # [!code ++] +MICROSOFT_CLIENT_SECRET=xxxxx~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `MicrosoftSSOApiPlugin` with your `tenant`, as in the [quick start](#quick-start), and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with Microsoft**, sign in and accept. You return to `/login/sso/microsoft` signed in, or are asked to [confirm an email](/docs/dev/sso/email-confirmation). + + + + +## Tenant restrictions are enforced + +The tenant in the authorize URL is only a hint to Microsoft. VitNode checks the validated ID token instead: its issuer must be `https://login.microsoftonline.com/{tid}/v2.0` for the token's own `tid`, and that `tid` must fit `tenant`: + +- `organizations` refuses personal accounts (tenant `9188040d-6c67-4c5b-b112-36a304b66dad`). +- `consumers` accepts only personal accounts. +- A tenant ID accepts only that tenant. + +To allow a few partner organizations on a multi-tenant app, add `allowedTenantIds`. Any other tenant gets a `403`: + +```ts title="src/vitnode.api.config.ts" +MicrosoftSSOApiPlugin({ + clientId: process.env.MICROSOFT_CLIENT_ID, + clientSecret: process.env.MICROSOFT_CLIENT_SECRET, + tenant: 'organizations', + allowedTenantIds: ['11111111-2222-3333-4444-555555555555'], // [!code ++] +}), +``` + +`tenant` must be `common`, `organizations`, `consumers` or a tenant ID (GUID); a domain such as `contoso.com` is refused at startup because it cannot be compared with `tid`. + +## Email and account ID + +Microsoft's `email` claim can change and is often not verified, so VitNode trusts it only when Microsoft adds `xms_edov: true` (the email's domain owner is verified). To receive it, open **Token configuration → Add optional claim → ID** and add `email` and `xms_edov`. Without it, new members confirm an address with a code. + +The account is stored as `{tid}:{oid}`, as Microsoft recommends, so the same person in two tenants is two accounts. + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ---------------------------------------------------- | +| Scopes | `openid profile email` | +| PKCE | `S256` | +| Account ID | `{tid}:{oid}` | +| Email trusted | only with `xms_edov: true` | +| Profile fields | first name, last name | + +The profile photo would need Microsoft Graph's `User.Read` permission, so the adapter does not offer the avatar. + +## Troubleshooting + +- **AADSTS50011, redirect URI mismatch** - add `{VITNODE_WEB_URL}/login/sso/microsoft` under **Authentication** on the **Web** platform. +- **AADSTS7000215, invalid client secret** - you copied the Secret ID, or the secret expired. +- **AADSTS50020 or a 403 after signing in** - the account's tenant does not fit `tenant` or `allowedTenantIds`. +- **"Need admin approval"** - the organization blocks user consent; an administrator can grant consent for the tenant. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/paypal.mdx b/apps/web/content/docs/dev/sso/paypal.mdx new file mode 100644 index 0000000000..db901bbd79 --- /dev/null +++ b/apps/web/content/docs/dev/sso/paypal.mdx @@ -0,0 +1,164 @@ +--- +title: PayPal +description: Add Log in with PayPal to VitNode - enable it on a PayPal REST app, pick sandbox or live, register the HTTPS return URL and wire up the adapter. +icon: Wallet +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with PayPal** button that signs people in with their PayPal account. This is **authentication only**: VitNode takes no payments, sees no balances and needs no payment permissions. To accept payments, use a payments integration separately. + +## Before you begin + +- A [PayPal developer account](https://developer.paypal.com/). +- A public **HTTPS** return URL. PayPal does not accept `localhost`, so local testing needs a tunnel (see [Develop locally](#develop-locally)). + +## Quick start + +```bash title=".env" +PAYPAL_CLIENT_ID=AYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +PAYPAL_CLIENT_SECRET=EKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { PayPalSSOApiPlugin } from '@vitnode/core/api/adapters/sso/paypal' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:9] + authorization: { + ssoAdapters: [ + PayPalSSOApiPlugin({ + clientId: process.env.PAYPAL_CLIENT_ID, + clientSecret: process.env.PAYPAL_CLIENT_SECRET, + environment: 'sandbox', + }), + ], + }, +}) +``` + +## Sandbox or live + +`environment` has no default, on purpose. Sandbox and live are separate apps with separate credentials, accounts and endpoints: + +| `environment` | Sign-in page | API | Who can sign in | +| ------------- | ------------------------ | ---------------------------------- | ---------------------------- | +| `'sandbox'` | `www.sandbox.paypal.com` | `https://api-m.sandbox.paypal.com` | sandbox test accounts only | +| `'live'` | `www.paypal.com` | `https://api-m.paypal.com` | real PayPal accounts | + +Sandbox credentials fail against live, and the other way round. A common pattern is `environment: process.env.PAYPAL_ENVIRONMENT === 'live' ? 'live' : 'sandbox'`. + +## Set up Log in with PayPal + + + + +### Create a REST app + +In the [PayPal Developer Dashboard](https://developer.paypal.com/dashboard/applications/sandbox), choose **Sandbox** or **Live** at the top, open **Apps & Credentials** and press **Create App** (type **Merchant**). + + + + + +### Enable Log in with PayPal + +Open the app, scroll to **Features** (or **Other features**) and tick **Log in with PayPal**, then open **Advanced Settings**. + + + + + +### Add the return URL and user information + +In **Advanced Settings**: + +- **Return URL**: `https://your-domain.com/login/sso/paypal`. VitNode builds it from `VITNODE_WEB_URL`, so it must match exactly. +- **Information requested from customers**: tick **Full name** and **Email**. Nothing else is needed. +- Add the **Privacy policy URL** and **User agreement URL** PayPal asks for, then save. + + + + + +### Copy the credentials + +The app page shows the **Client ID** and **Secret** for the environment you chose. Keep them server-side: + +```bash title=".env" +PAYPAL_CLIENT_ID=AYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +PAYPAL_CLIENT_SECRET=EKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `PayPalSSOApiPlugin` with the matching `environment`, as in the [quick start](#quick-start), and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +For sandbox, create a personal test account under **Testing tools → Sandbox accounts**. Open `/login` on your HTTPS domain, press **Continue with PayPal**, sign in with the test account and agree. You return to `/login/sso/paypal` signed in. + + + + +## Go live + +Repeat the setup with **Live** selected, switch the variables to the live credentials and set `environment: 'live'`. PayPal reviews the requested customer information when you save a live app, which can take some time; sign-in shows an error until it is approved. + +## Develop locally + +Use an HTTPS tunnel (for example Cloudflare Tunnel or ngrok), add its return URL to the sandbox app, and point VitNode at it: + +```bash title=".env" +VITNODE_WEB_URL=https://forum-dev.example.com +VITNODE_API_URL=https://forum-dev.example.com +``` + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ------------------------------------------------------ | +| Scopes | `openid email profile` | +| Token auth | HTTP Basic with the client ID and secret | +| Account ID | `user_id` | +| Email trusted | the primary email, when PayPal marks it `confirmed` | +| Profile fields | first name, last name | + +## Troubleshooting + +- **"invalid_client"** - sandbox credentials with `environment: 'live'`, or the other way round. +- **PayPal shows "We're sorry, things don't appear to be working"** - the return URL in **Advanced Settings** differs from `{VITNODE_WEB_URL}/login/sso/paypal`, or is not HTTPS. +- **Signed in, but asked to confirm an email** - **Email** is not ticked under the requested information, or the PayPal account's email is unconfirmed. See [Email confirmation](/docs/dev/sso/email-confirmation). + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/reddit.mdx b/apps/web/content/docs/dev/sso/reddit.mdx new file mode 100644 index 0000000000..5968389db4 --- /dev/null +++ b/apps/web/content/docs/dev/sso/reddit.mdx @@ -0,0 +1,140 @@ +--- +title: Reddit +description: Add Reddit sign-in to VitNode - create a Reddit web app, register the redirect URI, set a User-Agent and confirm new members' email with a code. +icon: MessageCircle +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Reddit** button that asks only for the `identity` scope - no posting, voting or moderation. Reddit never shares an email address, so new members confirm one with a code. + +## Before you begin + +- An [email adapter](/docs/dev/email). Without one, VitNode cannot send the confirmation code and new Reddit visitors cannot register. + +## Quick start + +```bash title=".env" +REDDIT_CLIENT_ID=AbCdEfGhIjKlMnOpQrStUv +REDDIT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +REDDIT_USER_AGENT="web:com.example.forum:1.0.0 (by /u/your_username)" +``` + +```ts title="src/vitnode.api.config.ts" +import { RedditSSOApiPlugin } from '@vitnode/core/api/adapters/sso/reddit' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:9] + authorization: { + ssoAdapters: [ + RedditSSOApiPlugin({ + clientId: process.env.REDDIT_CLIENT_ID, + clientSecret: process.env.REDDIT_CLIENT_SECRET, + userAgent: process.env.REDDIT_USER_AGENT, + }), + ], + }, +}) +``` + +## Set up the app + + + + +### Create a web app + +Sign in to Reddit, open [reddit.com/prefs/apps](https://www.reddit.com/prefs/apps) and press **create another app...**. Choose the **web app** type - the others cannot keep a secret. + + + + + +### Add the redirect URI + +Fill in the name and description people will see, and set **redirect uri**: + +| Environment | Redirect URI | +| ------------------------------------- | ------------------------------------------ | +| Development (`VITNODE_WEB_URL` unset) | `http://localhost:3000/login/sso/reddit` | +| Production | `https://your-domain.com/login/sso/reddit` | + +Reddit allows one redirect URI per app, so create a second app for development. VitNode builds the URI from `VITNODE_WEB_URL`, so it must match exactly. Press **create app**. + + + + + +### Copy the credentials + +The **client ID** is the short string under the app's name; the **secret** is next to **secret**. Keep both server-side, and set a descriptive User-Agent - Reddit throttles or blocks generic ones: + +```bash title=".env" +REDDIT_CLIENT_ID=AbCdEfGhIjKlMnOpQrStUv # [!code ++] +REDDIT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +REDDIT_USER_AGENT="web:com.example.forum:1.0.0 (by /u/your_username)" # [!code ++] +``` + +Without `userAgent`, VitNode sends `web:{clientId}:1.0 (by VitNode)`. + + + + + +### Register the adapter and restart + +Add `RedditSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with Reddit** and press **Allow**. The first time, VitNode asks for an email and the code sent to it. Next time, the same Reddit account signs straight in. + + + + +## Scopes and profile fields + +| Setting | Value | +| -------------- | -------------------------------------------------------- | +| Scopes | `identity`, with `duration=temporary` (no refresh token) | +| Token auth | HTTP Basic, with the User-Agent | +| Account ID | `id` (Reddit's base-36 account ID, not the username) | +| Email | never shared - [confirmed with a code](/docs/dev/sso/email-confirmation) | +| Profile fields | avatar | + +The username becomes the new account's display name. A renamed or deleted Reddit username does not change the stored account ID. + +## Troubleshooting + +- **"invalid redirect_uri parameter"** - the app's redirect URI differs from `{VITNODE_WEB_URL}/login/sso/reddit`. +- **Sign-in fails after Allow, with a provider error** - the app is not of the **web app** type, the secret is wrong, or Reddit is rate limiting a generic User-Agent. +- **New visitors see "We need a verified email"** - no email adapter is configured. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/slack.mdx b/apps/web/content/docs/dev/sso/slack.mdx new file mode 100644 index 0000000000..13d480bd26 --- /dev/null +++ b/apps/web/content/docs/dev/sso/slack.mdx @@ -0,0 +1,152 @@ +--- +title: Slack +description: Add Sign in with Slack to VitNode using OpenID Connect - create a Slack app without a bot, register the redirect URL and optionally restrict sign-in to one workspace. +icon: Hash +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Slack** button using Sign in with Slack (OpenID Connect). No bot is installed and no workspace data is read - VitNode only learns who the person is and which workspace they signed in from. + +## Quick start + +```bash title=".env" +SLACK_CLIENT_ID=1234567890.1234567890123 +SLACK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Optional: only members of this workspace may sign in +SLACK_TEAM_ID=T0123ABCD +``` + +```ts title="src/vitnode.api.config.ts" +import { SlackSSOApiPlugin } from '@vitnode/core/api/adapters/sso/slack' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:9] + authorization: { + ssoAdapters: [ + SlackSSOApiPlugin({ + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + teamId: process.env.SLACK_TEAM_ID, + }), + ], + }, +}) +``` + +## Set up the app + + + + +### Create a Slack app + +Open [api.slack.com/apps](https://api.slack.com/apps), press **Create New App → From scratch**, name it and pick a development workspace. + + + + + +### Add the redirect URL + +Open **OAuth & Permissions**, press **Add New Redirect URL** under **Redirect URLs** and save: + +| Environment | Redirect URL | +| ----------- | ----------------------------------------- | +| Production | `https://your-domain.com/login/sso/slack` | +| Development | `https://forum-dev.example.com/login/sso/slack` (an HTTPS tunnel) | + +Slack expects HTTPS redirect URLs, so develop through a tunnel and set `VITNODE_WEB_URL` to its address. VitNode builds the URL as `{VITNODE_WEB_URL}/login/sso/slack`. + + + + + +### Add the OpenID scopes + +Still on **OAuth & Permissions**, under **User Token Scopes**, add `openid`, `profile` and `email`. Leave **Bot Token Scopes** empty - Sign in with Slack does not install a bot. + + + + + +### Copy the credentials + +Open **Basic Information → App Credentials** and copy the **Client ID** and **Client Secret** into server-side variables: + +```bash title=".env" +SLACK_CLIENT_ID=1234567890.1234567890123 # [!code ++] +SLACK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `SlackSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with Slack**, choose the workspace and allow. You return to `/login/sso/slack` signed in. + + + + +## Restrict sign-in to one workspace + +Set `teamId` to the workspace ID (starts with `T`, or `E` for an Enterprise organization; it is in the workspace URL `app.slack.com/client/T0123ABCD`). VitNode then: + +1. sends `team=T0123ABCD` so Slack preselects the workspace, and +2. checks the `https://slack.com/team_id` claim of the validated ID token and answers `403` for any other workspace. + +The second check is what makes the restriction real: anyone can edit the authorize URL, but nobody can change the signed token. + +To let people outside your workspace sign in, turn on **Manage Distribution → Activate Public Distribution** for the app. + +## Scopes and profile fields + +| Setting | Value | +| -------------- | -------------------------------------------------------------- | +| Scopes | `openid profile email` (user scopes) | +| ID token | RS256 signature, issuer `https://slack.com`, audience, expiry, nonce | +| Account ID | `{team_id}:{user_id}` - Slack user IDs are per workspace | +| Email trusted | only with `email_verified: true` | +| Profile fields | avatar, first name, last name | + +## Troubleshooting + +- **"redirect_uri did not match any configured URIs"** - add `{VITNODE_WEB_URL}/login/sso/slack` under **OAuth & Permissions**. +- **Slack asks to install a bot** - the app has **Bot Token Scopes**. Remove them; sign-in needs only the user scopes above. +- **403 after signing in** - the person signed in from a workspace other than `teamId`. +- **Only your workspace can sign in** - public distribution is off. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/tiktok.mdx b/apps/web/content/docs/dev/sso/tiktok.mdx new file mode 100644 index 0000000000..7f8bf1737d --- /dev/null +++ b/apps/web/content/docs/dev/sso/tiktok.mdx @@ -0,0 +1,161 @@ +--- +title: TikTok +description: Add TikTok sign-in to VitNode with Login Kit for Web - create a TikTok app, register the HTTPS redirect URI, test in sandbox and confirm new members' email. +icon: Music +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with TikTok** button using TikTok's Login Kit for Web. The adapter asks only for `user.info.basic`. TikTok never shares an email address, so new members confirm one with a code. + +## Before you begin + +- A [TikTok for Developers](https://developers.tiktok.com/) account. +- A public **HTTPS** redirect URI. TikTok does not accept `http://` or `localhost` for web apps, so develop through a tunnel (see [Develop locally](#develop-locally)). +- An [email adapter](/docs/dev/email), to send the [confirmation code](/docs/dev/sso/email-confirmation). + +## Quick start + +TikTok calls the client ID a **Client Key**, and so does the adapter. + +```bash title=".env" +TIKTOK_CLIENT_KEY=awxxxxxxxxxxxxxx +TIKTOK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { TikTokSSOApiPlugin } from '@vitnode/core/api/adapters/sso/tiktok' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + TikTokSSOApiPlugin({ + clientKey: process.env.TIKTOK_CLIENT_KEY, + clientSecret: process.env.TIKTOK_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the app + + + + +### Create an app + +In the [TikTok developer portal](https://developers.tiktok.com/apps/), press **Connect an app** (or **Create app**), choose an organization or individual owner, and fill in the app name, icon, category, description, terms of service and privacy policy URLs. + + + + + +### Add Login Kit and the redirect URI + +Under **Products**, add **Login Kit**. In its settings, choose **Web** and add the redirect URI: + +| Environment | Redirect URI | +| ----------- | ------------------------------------------------ | +| Production | `https://your-domain.com/login/sso/tiktok` | +| Development | `https://forum-dev.example.com/login/sso/tiktok` | + +TikTok needs an absolute, static HTTPS URL with no query string. VitNode builds it as `{VITNODE_WEB_URL}/login/sso/tiktok`, so they must match exactly. + + + + + +### Check the scope + +Under **Scopes**, `user.info.basic` is added with Login Kit. It is all VitNode needs; leave out `user.info.profile`, `video.list` and the rest. + + + + + +### Copy the credentials + +Copy the **Client Key** and **Client Secret** from the app's credentials into server-side variables: + +```bash title=".env" +TIKTOK_CLIENT_KEY=awxxxxxxxxxxxxxx # [!code ++] +TIKTOK_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `TikTokSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login` on your HTTPS domain, press **Continue with TikTok** and authorize. The first time, VitNode asks for an email and the code sent to it; afterwards the same TikTok account signs straight in. + + + + +## Test in sandbox, then submit for review + +A new app cannot be used by the public until TikTok approves it. To test before that, create a **Sandbox** for the app in the developer portal and add up to ten **target users** - only those TikTok accounts can sign in, and the sandbox has its own Client Key and Secret. + +When it works, switch the variables to the production credentials and **Submit for review**. TikTok checks the app details, the website and the requested scopes. + +## Develop locally + +Run the dev server behind an HTTPS tunnel (for example Cloudflare Tunnel or ngrok), add that URL to Login Kit, and set: + +```bash title=".env" +VITNODE_WEB_URL=https://forum-dev.example.com +VITNODE_API_URL=https://forum-dev.example.com +``` + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ------------------------------------------------------------- | +| Scopes | `user.info.basic` | +| Account ID | `open_id` (unique to your app) | +| Email | never shared - [confirmed with a code](/docs/dev/sso/email-confirmation) | +| Profile fields | avatar | + +The display name becomes the new account's name. TikTok's avatar links are signed and expire, so import the avatar from [Connected accounts](/docs/dev/sso/connected-accounts) soon after connecting. + +## Troubleshooting + +- **"redirect_uri" error on TikTok's page** - the URI in Login Kit differs from `{VITNODE_WEB_URL}/login/sso/tiktok`, or it is not HTTPS. +- **"client_key" error** - you used the sandbox key with production credentials, or the other way round. +- **Only some accounts can sign in** - the app is still in sandbox; add them as target users or submit the app for review. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/twitch.mdx b/apps/web/content/docs/dev/sso/twitch.mdx new file mode 100644 index 0000000000..c4266260f1 --- /dev/null +++ b/apps/web/content/docs/dev/sso/twitch.mdx @@ -0,0 +1,132 @@ +--- +title: Twitch +description: Add Twitch sign-in to VitNode using OpenID Connect - register a Twitch application, add the redirect URL and wire up the Twitch SSO adapter. +icon: Tv +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with Twitch** button. The adapter uses Twitch's OpenID Connect flow: it validates the ID token, then reads the email and avatar from Twitch's UserInfo endpoint. + +## Quick start + +```bash title=".env" +TWITCH_CLIENT_ID=abcdefghijklmnopqrstuvwxyz0123 +TWITCH_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { TwitchSSOApiPlugin } from '@vitnode/core/api/adapters/sso/twitch' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + TwitchSSOApiPlugin({ + clientId: process.env.TWITCH_CLIENT_ID, + clientSecret: process.env.TWITCH_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the application + + + + +### Register an application + +Open the [Twitch Developer Console](https://dev.twitch.tv/console/apps) (two-factor authentication must be on), press **Register Your Application**, and choose the **Website Integration** category and the **Confidential** client type. + + + + + +### Add the OAuth redirect URL + +Add one entry per environment: + +| Environment | OAuth Redirect URL | +| ------------------------------------- | ------------------------------------------ | +| Development (`VITNODE_WEB_URL` unset) | `http://localhost:3000/login/sso/twitch` | +| Production | `https://your-domain.com/login/sso/twitch` | + +Twitch accepts `http://localhost` but not `127.0.0.1` with an arbitrary port. VitNode builds the URL from `VITNODE_WEB_URL`, so it must match exactly. + + + + + +### Copy the credentials + +Open the application under **Manage**, copy the **Client ID**, press **New Secret** and copy the **Client Secret**: + +```bash title=".env" +TWITCH_CLIENT_ID=abcdefghijklmnopqrstuvwxyz0123 # [!code ++] +TWITCH_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `TwitchSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with Twitch** and **Authorize**. You return to `/login/sso/twitch` signed in. + + + + +## Scopes and profile fields + +| Setting | Value | +| -------------- | ---------------------------------------------------------------------- | +| Scopes | `openid user:read:email` | +| Claims | `email`, `email_verified`, `picture`, `preferred_username` (requested with the `claims` parameter) | +| ID token | RS256 signature, issuer `https://id.twitch.tv/oauth2`, audience, expiry, nonce | +| Account ID | `sub` (the Twitch user ID) | +| Email trusted | only with `email_verified: true` | +| Profile fields | avatar | + +`user:read:email` is required for the email claims. Twitch leaves the email out until the account has verified it; a new member then [confirms an address with a code](/docs/dev/sso/email-confirmation), and a returning member signs in by their Twitch ID. Twitch has no first or last name. + +## Troubleshooting + +- **"redirect_mismatch"** - the OAuth Redirect URL differs from `{VITNODE_WEB_URL}/login/sso/twitch`. +- **"invalid client secret"** - a **New Secret** replaces the old one; update `TWITCH_CLIENT_SECRET`. +- **Asked to confirm an email** - the Twitch account has no verified email yet. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/twitter.mdx b/apps/web/content/docs/dev/sso/twitter.mdx new file mode 100644 index 0000000000..ec7152203f --- /dev/null +++ b/apps/web/content/docs/dev/sso/twitter.mdx @@ -0,0 +1,170 @@ +--- +title: Twitter (X) +description: Add X (formerly Twitter) sign-in to VitNode with OAuth 2.0 and PKCE - set up user authentication, request the confirmed email and wire up the adapter. +icon: AtSign +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Add a **Continue with X** button using X's OAuth 2.0 authorization code flow with PKCE. The provider id stays `twitter`, so the callback is `/login/sso/twitter`. + +## Before you begin + +- An [X developer account](https://developer.x.com/) with a Project and App. Reading the signed-in account (`GET /2/users/me`) is a billed API call on X's current plans, so check the access your account has. + +## Quick start + +```bash title=".env" +TWITTER_CLIENT_ID=AbCdEfGhIjKlMnOpQrStUvWxYz +TWITTER_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```ts title="src/vitnode.api.config.ts" +import { TwitterSSOApiPlugin } from '@vitnode/core/api/adapters/sso/twitter' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + // [!code ++:8] + authorization: { + ssoAdapters: [ + TwitterSSOApiPlugin({ + clientId: process.env.TWITTER_CLIENT_ID, + clientSecret: process.env.TWITTER_CLIENT_SECRET, + }), + ], + }, +}) +``` + +## Set up the app + + + + +### Open user authentication settings + +In the [X developer portal](https://developer.x.com/en/portal/dashboard), open your App inside a Project and press **Set up** (or **Edit**) under **User authentication settings**. + + + + + +### Choose a confidential web app + +Set: + +- **App permissions**: **Read**. Turn on **Request email from users** to receive the confirmed email. +- **Type of App**: **Web App, Automated App or Bot** (a confidential client - VitNode keeps the secret on the server). +- **Website URL**: your site. + + + + + +### Add the callback URI + +Add each callback URI. X matches them exactly, including a trailing slash: + +| Environment | Callback URI | +| ----------- | -------------------------------------------- | +| Development | `http://127.0.0.1:3000/login/sso/twitter` | +| Production | `https://your-domain.com/login/sso/twitter` | + +X documents `127.0.0.1` rather than `localhost` for local work. Set `VITNODE_WEB_URL=http://127.0.0.1:3000` and open the site at that address, because VitNode builds the URI as `{VITNODE_WEB_URL}/login/sso/twitter` and the sign-in cookie belongs to the host you started on. + + + + + +### Copy the OAuth 2.0 credentials + +After saving, copy the **OAuth 2.0 Client ID and Client Secret** - not the API key and secret, which belong to OAuth 1.0a: + +```bash title=".env" +TWITTER_CLIENT_ID=AbCdEfGhIjKlMnOpQrStUvWxYz # [!code ++] +TWITTER_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # [!code ++] +``` + + + + + +### Register the adapter and restart + +Add `TwitterSSOApiPlugin` as in the [quick start](#quick-start) and restart the server: + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + + + + + +### Check the result + +Open `/login`, press **Continue with X** and **Authorize app**. You return to `/login/sso/twitter` signed in, or are asked to confirm an email when X shares none. + + + + +## Email access + +With **Request email from users** on, the adapter requests the `users.email` scope and reads `confirmed_email`, which X only returns for a confirmed address. Some accounts (for example phone-only ones) have none. + +If your app cannot request email, X rejects the `users.email` scope. Turn it off in the adapter, and new members [confirm an address with a code](/docs/dev/sso/email-confirmation) instead: + +```ts title="src/vitnode.api.config.ts" +TwitterSSOApiPlugin({ + clientId: process.env.TWITTER_CLIENT_ID, + clientSecret: process.env.TWITTER_CLIENT_SECRET, + requestEmail: false, // [!code ++] +}), +``` + +## Options + +| Name | Type | Required | Default | Description | +| -------------- | --------------------- | -------- | ------- | --------------------------------------------------- | +| `clientId` | `string \| undefined` | Yes | - | OAuth 2.0 Client ID | +| `clientSecret` | `string \| undefined` | Yes | - | OAuth 2.0 Client Secret | +| `requestEmail` | `boolean` | No | `true` | Request `users.email` and the confirmed email | + +## Scopes and profile fields + +| Setting | Value | +| -------------- | --------------------------------------------------------------- | +| Scopes | `tweet.read users.read users.email` (`users.email` optional) | +| PKCE | `S256` (required by X) | +| Token auth | HTTP Basic with the client ID and secret | +| Account ID | `data.id` | +| Email trusted | `confirmed_email`, when present | +| Profile fields | avatar | + +`tweet.read` is required by X to read the signed-in account, even though VitNode reads no posts. + +## Troubleshooting + +- **"Something went wrong - You weren't able to give access to the App"** - the callback URI differs from `{VITNODE_WEB_URL}/login/sso/twitter`, or `users.email` is requested while email access is off. +- **"unauthorized_client"** - the app type is a public client (Native App or Single page App), or you used the OAuth 1.0a keys. +- **A provider error after authorizing** - `GET /2/users/me` was refused; check your API access and plan in the developer portal. + +## Next + + + + + diff --git a/apps/web/content/docs/dev/sso/using-connected-accounts.mdx b/apps/web/content/docs/dev/sso/using-connected-accounts.mdx index 113795176e..4437cb7811 100644 --- a/apps/web/content/docs/dev/sso/using-connected-accounts.mdx +++ b/apps/web/content/docs/dev/sso/using-connected-accounts.mdx @@ -1,6 +1,6 @@ --- title: Using connected accounts -description: A friendly guide for members - connect Google, Discord or Facebook, import your avatar and name, and disconnect safely. +description: A friendly guide for members - connect Google, Apple, Microsoft and other accounts, import your avatar and name, and disconnect safely. icon: UserRoundCheck --- @@ -13,7 +13,9 @@ Connected accounts let you sign in with an account you already have, like Google 3. Sign in at the provider and allow access. 4. You're back in settings with "Account connected". From now on you can use that button on the login page. -Your email here never changes, even if the provider account uses a different one. Each provider account can belong to one member only, and you can connect one account per provider. +If you see "Please sign in again", your last sign-in was more than an hour ago. Sign out, sign back in, and connect the account right away - this keeps someone who borrowed your browser from adding their own account. + +Your email here never changes, even if the provider account uses a different one - or has none, like Reddit and TikTok. Each provider account can belong to one member only, and you can connect one account per provider. ## Import your avatar or name diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 23d0f14fb0..60ced5338a 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -285,6 +285,7 @@ "dotenv": "^18.0.6", "embla-carousel-react": "^8.6.0", "input-otp": "^1.5.0", + "jose": "^6.2.12", "jiti": "^2.7.0", "motion": "^14.0.0", "ocache": "^0.3.0", diff --git a/packages/vitnode/src/api/adapters/sso/apple.ts b/packages/vitnode/src/api/adapters/sso/apple.ts new file mode 100644 index 0000000000..cb01b7c2b2 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/apple.ts @@ -0,0 +1,234 @@ +import { importPKCS8, SignJWT } from "jose"; +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + claimBoolean, + claimString, + formPost, + remoteJwks, + requestSsoProviderJson, + requireSsoOption, + verifySsoIdToken, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { APPLE_ICON } from "./icons"; + +const ISSUER = "https://appleid.apple.com"; + +/** How long a generated client secret lives. Apple allows up to six months. */ +export const APPLE_CLIENT_SECRET_TTL_S = 60 * 60 * 24; + +/** A cached secret is replaced once it has less than this left. */ +const RENEW_BEFORE_S = 60 * 60; + +const KEY_ID = /^[A-Z0-9]{10}$/; +const TEAM_ID = /^[A-Z0-9]{10}$/; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + id_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const firstAuthorizationSchema = z.object({ + name: z + .object({ + firstName: z.string().max(64).optional(), + lastName: z.string().max(64).optional(), + }) + .optional(), +}); + +/** + * Apple sends the name only on the first authorization, as JSON posted next + * to the code. Untrusted, and not signed - used for profile fields only. + */ +const nameFromCallback = ( + raw: string | undefined, +): { firstName: null | string; lastName: null | string } => { + if (!raw) return { firstName: null, lastName: null }; + + try { + const parsed = firstAuthorizationSchema.safeParse(JSON.parse(raw)); + + return { + firstName: claimString(parsed.data?.name?.firstName), + lastName: claimString(parsed.data?.name?.lastName), + }; + } catch { + return { firstName: null, lastName: null }; + } +}; + +/** + * Builds Apple's client secret: an ES256 JWT signed with the Sign in with + * Apple key, valid for {@link APPLE_CLIENT_SECRET_TTL_S}. Server-only. + */ +export const createAppleClientSecret = async ({ + clientId, + keyId, + now = new Date(), + privateKey, + teamId, +}: { + clientId: string; + keyId: string; + now?: Date; + /** The `.p8` key's PEM contents. Literal `\n` sequences are accepted. */ + privateKey: string; + teamId: string; +}): Promise<{ expiresAt: number; secret: string }> => { + const key = await importPKCS8( + privateKey.replace(/\\n/g, "\n").trim(), + "ES256", + ); + const issuedAt = Math.floor(now.getTime() / 1000); + const expiresAt = issuedAt + APPLE_CLIENT_SECRET_TTL_S; + const secret = await new SignJWT({}) + .setProtectedHeader({ alg: "ES256", kid: keyId }) + .setIssuer(teamId) + .setIssuedAt(issuedAt) + .setExpirationTime(expiresAt) + .setAudience(ISSUER) + .setSubject(clientId) + .sign(key); + + return { expiresAt, secret }; +}; + +/** + * Sign in with Apple: an SSO adapter for the API config's + * `authorization.ssoAdapters`. Requests `name email` with + * `response_mode=form_post`, so Apple POSTs the callback to + * `login/sso/apple` under `VITNODE_WEB_URL`; VitNode's request middleware + * turns that POST into the usual `GET` without loosening any cookie rule. + * + * Generates and renews the ES256 client secret from the Team ID, Key ID and + * `.p8` private key. Validates the ID token (signature, issuer, audience, + * expiry, nonce). The email - possibly a private relay address, which is a + * real, verified inbox - is trusted only with `email_verified`. Apple shares + * the name once, on the first authorization; later sign-ins keep the stored + * profile. Supplies first and last name for profile sync. + * + * Apple only accepts HTTPS return URLs on a real domain - no `localhost`. + * + * Server-only. + * + * @example + * ```ts + * import { AppleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/apple"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * AppleSSOApiPlugin({ + * clientId: process.env.APPLE_CLIENT_ID, // the Services ID + * teamId: process.env.APPLE_TEAM_ID, + * keyId: process.env.APPLE_KEY_ID, + * privateKey: process.env.APPLE_PRIVATE_KEY, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const AppleSSOApiPlugin = ({ + clientId, + keyId, + privateKey, + teamId, +}: { + /** The Services ID's identifier, e.g. `com.example.web`. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The 10-character ID of the Sign in with Apple key. */ + keyId: string | undefined; + /** The `.p8` key file's contents (PEM). Keep it server-side; `\n` escapes are accepted. */ + privateKey: string | undefined; + /** The 10-character Apple Developer Team ID. */ + teamId: string | undefined; +}): SSOApiPlugin => { + const id = "apple"; + const redirectUri = getRedirectUri(id); + let cached: undefined | { expiresAt: number; secret: string }; + + const clientSecret = async (): Promise => { + const now = Math.floor(Date.now() / 1000); + if (cached && cached.expiresAt - now > RENEW_BEFORE_S) return cached.secret; + + const team = requireSsoOption(teamId, "Apple", "team ID"); + const key = requireSsoOption(keyId, "Apple", "key ID"); + if (!(TEAM_ID.test(team) && KEY_ID.test(key))) { + throw new Error("Apple team ID and key ID are 10 characters each"); + } + cached = await createAppleClientSecret({ + clientId: requireSsoOption(clientId, "Apple", "client ID"), + keyId: key, + privateKey: requireSsoOption(privateKey, "Apple", "private key"), + teamId: team, + }); + + return cached.secret; + }; + + return { + brandColor: "#000000", + icon: APPLE_ICON, + id, + name: "Apple", + profileFields: ["firstName", "lastName"], + getUrl: ({ nonce, state }) => { + const url = new URL(`${ISSUER}/auth/authorize`); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Apple", "client ID"), + ); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("response_mode", "form_post"); + url.searchParams.set("scope", "name email"); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "Apple", "client ID"), + client_secret: await clientSecret(), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: `${ISSUER}/auth/token`, + }), + fetchUser: async ({ id_token }, { nonce, params }) => { + const claims = await verifySsoIdToken({ + audience: requireSsoOption(clientId, "Apple", "client ID"), + idToken: id_token, + issuer: ISSUER, + keys: remoteJwks(`${ISSUER}/auth/keys`), + nonce, + }); + const email = claimString(claims.email); + const { firstName, lastName } = nameFromCallback(params.user); + const fullName = [firstName, lastName].filter(Boolean).join(" "); + + return { + email, + emailVerified: + email !== null && claimBoolean(claims.email_verified) === true, + firstName, + id: String(claims.sub), + lastName, + username: fullName || null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/atlassian.ts b/packages/vitnode/src/api/adapters/sso/atlassian.ts new file mode 100644 index 0000000000..5691850e42 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/atlassian.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + claimString, + formPost, + requestSsoProviderJson, + requireSsoOption, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { ATLASSIAN_ICON } from "./icons"; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1).default("Bearer"), +}); + +const meSchema = z.object({ + account_id: z.string().min(1), + email: z.string().nullable().optional(), + email_verified: z.boolean().nullable().optional(), + name: z.string().nullable().optional(), + nickname: z.string().nullable().optional(), + picture: z.string().nullable().optional(), +}); + +/** + * Sign in with Atlassian: an SSO adapter for the API config's + * `authorization.ssoAdapters`, using Atlassian's OAuth 2.0 (3LO). Requests + * only `read:me` - the signed-in person's account, nothing from Jira, + * Confluence or any other product - and redirects back to + * `login/sso/atlassian` under `VITNODE_WEB_URL`. + * + * The email is trusted only with `email_verified: true`; Atlassian may also + * hide it under the person's privacy settings, and a new member then confirms + * an address in VitNode with a code. Supplies the avatar for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { AtlassianSSOApiPlugin } from "@vitnode/core/api/adapters/sso/atlassian"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * AtlassianSSOApiPlugin({ + * clientId: process.env.ATLASSIAN_CLIENT_ID, + * clientSecret: process.env.ATLASSIAN_CLIENT_SECRET, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const AtlassianSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + /** The OAuth 2.0 integration's Client ID (Settings). `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The integration's Secret. Needed to exchange the code. */ + clientSecret: string | undefined; +}): SSOApiPlugin => { + const id = "atlassian"; + const redirectUri = getRedirectUri(id); + + return { + brandColor: "#0052CC", + icon: ATLASSIAN_ICON, + id, + name: "Atlassian", + profileFields: ["avatar"], + getUrl: ({ state }) => { + const url = new URL("https://auth.atlassian.com/authorize"); + url.searchParams.set("audience", "api.atlassian.com"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Atlassian", "client ID"), + ); + url.searchParams.set("scope", "read:me"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + url.searchParams.set("response_type", "code"); + url.searchParams.set("prompt", "consent"); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "Atlassian", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "Atlassian", + "client secret", + ), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: "https://auth.atlassian.com/oauth/token", + }), + fetchUser: async ({ access_token }) => { + const me = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: meSchema, + step: "profile", + url: "https://api.atlassian.com/me", + }); + const email = claimString(me.email); + + return { + avatarUrl: me.picture ?? null, + email, + emailVerified: email !== null && me.email_verified === true, + id: me.account_id, + username: me.nickname ?? me.name ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/gitlab.ts b/packages/vitnode/src/api/adapters/sso/gitlab.ts new file mode 100644 index 0000000000..830fac2867 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/gitlab.ts @@ -0,0 +1,205 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + claimString, + formPost, + remoteJwks, + requestSsoProviderJson, + requireSsoOption, + ssoProviderFailure, + verifySsoIdToken, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { GITLAB_ICON } from "./icons"; + +/** GitLab.com's issuer, the default. */ +export const GITLAB_COM_ISSUER = "https://gitlab.com"; + +const PROVIDER_ID = /^[\w-]{1,64}$/; + +const LOCAL_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]); + +const tokenSchema = z.object({ + access_token: z.string().min(1), + id_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const userInfoSchema = z.object({ + email: z.string().nullable().optional(), + email_verified: z.boolean().nullable().optional(), + family_name: z.string().nullable().optional(), + given_name: z.string().nullable().optional(), + name: z.string().nullable().optional(), + nickname: z.string().nullable().optional(), + picture: z.string().nullable().optional(), + sub: z.string().min(1), +}); + +/** + * A GitLab instance's issuer, normalized: HTTPS (plain HTTP only for + * localhost), no query, fragment or trailing slash. A relative URL root + * (`https://example.com/gitlab`) is kept. + * + * @throws When the URL is not one. + */ +export const normalizeGitLabIssuer = (issuer: string): string => { + let url: URL; + try { + url = new URL(issuer); + } catch { + throw new Error(`Invalid GitLab issuer "${issuer}"`); + } + const secure = + url.protocol === "https:" || + (url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname)); + if (!secure || url.search || url.hash || url.username || url.password) { + throw new Error( + `Invalid GitLab issuer "${issuer}" - use the instance's HTTPS URL, e.g. https://gitlab.example.com`, + ); + } + + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +}; + +/** + * Sign in with GitLab - GitLab.com or a self-managed instance: an SSO adapter + * for the API config's `authorization.ssoAdapters`, using OpenID Connect with + * PKCE. Requests `openid profile email`, validates the ID token (signature, + * issuer, audience, expiry, nonce) and redirects back to `login/sso/{id}` + * under `VITNODE_WEB_URL`. + * + * GitLab user IDs are only unique within an instance, so for any issuer but + * GitLab.com the stored account id is `{issuer}:{sub}`. To offer two instances + * side by side, register the adapter twice with different `id`s. Supplies the + * avatar, first and last name for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { GitLabSSOApiPlugin } from "@vitnode/core/api/adapters/sso/gitlab"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * GitLabSSOApiPlugin({ + * clientId: process.env.GITLAB_CLIENT_ID, + * clientSecret: process.env.GITLAB_CLIENT_SECRET, + * // issuer: "https://gitlab.example.com", // self-managed + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const GitLabSSOApiPlugin = ({ + clientId, + clientSecret, + id = "gitlab", + issuer = GITLAB_COM_ISSUER, + name = "GitLab", +}: { + /** The application's Application ID. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The application's Secret. Needed to exchange the code. */ + clientSecret: string | undefined; + /** The provider id, used in the callback URL (`login/sso/{id}`) and to store accounts. Defaults to `gitlab`. */ + id?: string; + /** + * The instance URL, which is also its OpenID Connect issuer. Defaults to + * `https://gitlab.com`. Must be HTTPS (HTTP only for localhost). + */ + issuer?: string; + /** The button label's provider name. Defaults to `GitLab`. */ + name?: string; +}): SSOApiPlugin => { + if (!PROVIDER_ID.test(id)) { + throw new Error( + `Invalid GitLab provider id "${id}" - letters, digits, _ and -`, + ); + } + const base = normalizeGitLabIssuer(issuer); + const redirectUri = getRedirectUri(id); + const namespace = + base === GITLAB_COM_ISSUER ? null : base.replace(/^https?:\/\//, ""); + + return { + brandColor: "#FC6D26", + icon: GITLAB_ICON, + id, + name, + profileFields: ["avatar", "firstName", "lastName"], + getUrl: ({ codeChallenge, codeChallengeMethod, nonce, state }) => { + const url = new URL(`${base}/oauth/authorize`); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "GitLab", "client ID"), + ); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "openid profile email"); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", codeChallengeMethod); + + return url.toString(); + }, + fetchToken: async (code, { codeVerifier }) => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "GitLab", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "GitLab", + "client secret", + ), + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: `${base}/oauth/token`, + }), + fetchUser: async ({ access_token, id_token }, { nonce }) => { + const claims = await verifySsoIdToken({ + audience: requireSsoOption(clientId, "GitLab", "client ID"), + idToken: id_token, + issuer: base, + keys: remoteJwks(`${base}/oauth/discovery/keys`), + nonce, + }); + const info = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userInfoSchema, + step: "profile", + url: `${base}/oauth/userinfo`, + }); + if (info.sub !== claims.sub) { + throw ssoProviderFailure( + "GitLab UserInfo answered for another account", + ); + } + + const email = claimString(info.email); + + return { + avatarUrl: info.picture ?? null, + email, + emailVerified: email !== null && info.email_verified === true, + firstName: info.given_name ?? null, + id: namespace ? `${namespace}:${info.sub}` : info.sub, + lastName: info.family_name ?? null, + username: info.nickname ?? info.name ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/icons.ts b/packages/vitnode/src/api/adapters/sso/icons.ts index cf1e1a3828..465476cc02 100644 --- a/packages/vitnode/src/api/adapters/sso/icons.ts +++ b/packages/vitnode/src/api/adapters/sso/icons.ts @@ -6,3 +6,39 @@ export const FACEBOOK_ICON = export const GOOGLE_ICON = ''; + +export const APPLE_ICON = + ''; + +export const ATLASSIAN_ICON = + ''; + +export const GITLAB_ICON = + ''; + +export const KICK_ICON = + ''; + +export const LINKEDIN_ICON = + ''; + +export const PAYPAL_ICON = + ''; + +export const REDDIT_ICON = + ''; + +export const SLACK_ICON = + ''; + +export const TIKTOK_ICON = + ''; + +export const TWITCH_ICON = + ''; + +export const X_ICON = + ''; + +export const MICROSOFT_ICON = + ''; diff --git a/packages/vitnode/src/api/adapters/sso/kick.ts b/packages/vitnode/src/api/adapters/sso/kick.ts new file mode 100644 index 0000000000..8850cd90b9 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/kick.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + claimString, + formPost, + requestSsoProviderJson, + requireSsoOption, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { KICK_ICON } from "./icons"; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1).default("Bearer"), +}); + +const usersSchema = z.object({ + data: z + .array( + z.object({ + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + profile_picture: z.string().nullable().optional(), + user_id: z.union([ + z.number().int().nonnegative(), + z.string().regex(/^\d+$/), + ]), + }), + ) + .min(1), +}); + +/** + * Sign in with Kick: an SSO adapter for the API config's + * `authorization.ssoAdapters`, using OAuth 2.1 with PKCE (which Kick + * requires). Requests only `user:read` and redirects back to `login/sso/kick` + * under `VITNODE_WEB_URL`. + * + * Kick does not say whether an account's email is verified, so VitNode never + * trusts it: a new member confirms an address with a code first, and a + * returning member signs in by their Kick user ID. Supplies the avatar for + * profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { KickSSOApiPlugin } from "@vitnode/core/api/adapters/sso/kick"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * KickSSOApiPlugin({ + * clientId: process.env.KICK_CLIENT_ID, + * clientSecret: process.env.KICK_CLIENT_SECRET, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const KickSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + /** The app's Client ID. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The app's Client Secret. Needed to exchange the code. */ + clientSecret: string | undefined; +}): SSOApiPlugin => { + const id = "kick"; + const redirectUri = getRedirectUri(id); + + return { + brandColor: "#53FC19", + icon: KICK_ICON, + id, + name: "Kick", + profileFields: ["avatar"], + getUrl: ({ codeChallenge, codeChallengeMethod, state }) => { + const url = new URL("https://id.kick.com/oauth/authorize"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Kick", "client ID"), + ); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", "user:read"); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", codeChallengeMethod); + + return url.toString(); + }, + fetchToken: async (code, { codeVerifier }) => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "Kick", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "Kick", + "client secret", + ), + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: "https://id.kick.com/oauth/token", + }), + fetchUser: async ({ access_token }) => { + const { + data: [user], + } = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: usersSchema, + step: "profile", + url: "https://api.kick.com/public/v1/users", + }); + + return { + avatarUrl: user.profile_picture ?? null, + email: claimString(user.email), + emailVerified: false, + id: String(user.user_id), + username: user.name ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/linkedin.ts b/packages/vitnode/src/api/adapters/sso/linkedin.ts new file mode 100644 index 0000000000..5469fb0039 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/linkedin.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + claimString, + formPost, + requestSsoProviderJson, + requireSsoOption, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { LINKEDIN_ICON } from "./icons"; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1).default("Bearer"), +}); + +const userInfoSchema = z.object({ + email: z.string().nullable().optional(), + email_verified: z.boolean().nullable().optional(), + family_name: z.string().nullable().optional(), + given_name: z.string().nullable().optional(), + name: z.string().nullable().optional(), + picture: z.string().nullable().optional(), + sub: z.string().min(1), +}); + +/** + * Sign In with LinkedIn using OpenID Connect: an SSO adapter for the API + * config's `authorization.ssoAdapters`. Requests `openid profile email` and + * reads the member from LinkedIn's UserInfo endpoint with the access token it + * just received from LinkedIn's token endpoint over TLS (OIDC Core §5.3), so + * no ID token needs to be decoded. Redirects back to `login/sso/linkedin` + * under `VITNODE_WEB_URL`. + * + * The email is trusted only with `email_verified: true`; without one a new + * member confirms an address in VitNode with a code. Supplies the avatar, + * first and last name for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { LinkedInSSOApiPlugin } from "@vitnode/core/api/adapters/sso/linkedin"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * LinkedInSSOApiPlugin({ + * clientId: process.env.LINKEDIN_CLIENT_ID, + * clientSecret: process.env.LINKEDIN_CLIENT_SECRET, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const LinkedInSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + /** The app's Client ID (Auth tab). `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The app's Primary Client Secret. Needed to exchange the code. */ + clientSecret: string | undefined; +}): SSOApiPlugin => { + const id = "linkedin"; + const redirectUri = getRedirectUri(id); + + return { + brandColor: "#0A66C2", + icon: LINKEDIN_ICON, + id, + name: "LinkedIn", + profileFields: ["avatar", "firstName", "lastName"], + getUrl: ({ state }) => { + const url = new URL("https://www.linkedin.com/oauth/v2/authorization"); + url.searchParams.set("response_type", "code"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "LinkedIn", "client ID"), + ); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", "openid profile email"); + url.searchParams.set("state", state); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "LinkedIn", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "LinkedIn", + "client secret", + ), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: "https://www.linkedin.com/oauth/v2/accessToken", + }), + fetchUser: async ({ access_token }) => { + const info = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userInfoSchema, + step: "profile", + url: "https://api.linkedin.com/v2/userinfo", + }); + const email = claimString(info.email); + + return { + avatarUrl: info.picture ?? null, + email, + emailVerified: email !== null && info.email_verified === true, + firstName: info.given_name ?? null, + id: info.sub, + lastName: info.family_name ?? null, + username: info.name ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/microsoft.ts b/packages/vitnode/src/api/adapters/sso/microsoft.ts new file mode 100644 index 0000000000..889f15ded4 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/microsoft.ts @@ -0,0 +1,237 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + claimBoolean, + claimString, + formPost, + remoteJwks, + requestSsoProviderJson, + requireSsoOption, + ssoProviderFailure, + verifySsoIdToken, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { MICROSOFT_ICON } from "./icons"; + +const AUTHORITY = "https://login.microsoftonline.com"; + +/** The tenant every personal Microsoft account signs in through. */ +export const MICROSOFT_CONSUMERS_TENANT_ID = + "9188040d-6c67-4c5b-b112-36a304b66dad"; + +const GUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +/** + * Who may sign in: `common` (work, school and personal accounts), + * `organizations` (work and school only), `consumers` (personal only) or one + * tenant's ID. + */ +export type MicrosoftTenant = + | "common" + | "consumers" + | "organizations" + | (string & {}); + +const tokenSchema = z.object({ + access_token: z.string().min(1), + id_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const userInfoSchema = z.object({ + family_name: z.string().nullable().optional(), + given_name: z.string().nullable().optional(), + sub: z.string().min(1), +}); + +/** + * Whether the tenant an ID token came from (`tid`) may sign in. Enforced on + * the validated token: the authorize URL's tenant is only a suggestion to + * Microsoft, and `common` accepts any tenant at all. + */ +export const microsoftTenantAllowed = ({ + allowedTenantIds, + tenant, + tid, +}: { + allowedTenantIds?: readonly string[]; + tenant: MicrosoftTenant; + tid: string; +}): boolean => { + const id = tid.toLowerCase(); + if ( + allowedTenantIds && + !allowedTenantIds.some(one => one.toLowerCase() === id) + ) { + return false; + } + + if (tenant === "common") return true; + if (tenant === "consumers") return id === MICROSOFT_CONSUMERS_TENANT_ID; + if (tenant === "organizations") return id !== MICROSOFT_CONSUMERS_TENANT_ID; + + return id === tenant.toLowerCase(); +}; + +/** + * Sign in with Microsoft (Microsoft Entra ID and personal Microsoft accounts): + * an SSO adapter for the API config's `authorization.ssoAdapters`, using + * OpenID Connect with PKCE. Requests `openid profile email`, validates the ID + * token (signature, audience, expiry, nonce, and an issuer matching the + * token's own tenant), enforces `tenant` and `allowedTenantIds`, and redirects + * back to `login/sso/microsoft` under `VITNODE_WEB_URL`. + * + * The account id is `{tid}:{oid}`, as Microsoft recommends. Microsoft's + * `email` claim is mutable and often unverified, so it is trusted only with + * `xms_edov: true` (domain owner verified); otherwise a new member confirms an + * address in VitNode with a code. Supplies first and last name for profile + * sync - the photo would need Microsoft Graph's `User.Read`, which this + * adapter does not request. + * + * Server-only. + * + * @example + * ```ts + * import { MicrosoftSSOApiPlugin } from "@vitnode/core/api/adapters/sso/microsoft"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * MicrosoftSSOApiPlugin({ + * clientId: process.env.MICROSOFT_CLIENT_ID, + * clientSecret: process.env.MICROSOFT_CLIENT_SECRET, + * tenant: "common", + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const MicrosoftSSOApiPlugin = ({ + allowedTenantIds, + clientId, + clientSecret, + tenant = "common", +}: { + /** Optional allowlist of tenant IDs (GUIDs), checked against every ID token on top of `tenant`. */ + allowedTenantIds?: readonly string[]; + /** The app registration's Application (client) ID. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** A client secret's Value (not its ID). Needed to exchange the code. */ + clientSecret: string | undefined; + /** + * Which accounts may sign in - match the registration's "Supported account + * types". `common`, `organizations`, `consumers` or a tenant ID (GUID). + * Defaults to `common`. + * + * @throws When it is none of these - a domain name is not accepted, use + * the tenant ID. + */ + tenant?: MicrosoftTenant; +}): SSOApiPlugin => { + const id = "microsoft"; + const redirectUri = getRedirectUri(id); + if ( + !["common", "consumers", "organizations"].includes(tenant) && + !GUID.test(tenant) + ) { + throw new Error( + `Invalid Microsoft tenant "${tenant}" - use "common", "organizations", "consumers" or a tenant ID`, + ); + } + if (allowedTenantIds?.some(one => !GUID.test(one))) { + throw new Error("Microsoft allowedTenantIds must be tenant IDs (GUIDs)"); + } + + return { + icon: MICROSOFT_ICON, + id, + name: "Microsoft", + profileFields: ["firstName", "lastName"], + getUrl: ({ codeChallenge, codeChallengeMethod, nonce, state }) => { + const url = new URL(`${AUTHORITY}/${tenant}/oauth2/v2.0/authorize`); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Microsoft", "client ID"), + ); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_mode", "query"); + url.searchParams.set("scope", "openid profile email"); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", codeChallengeMethod); + + return url.toString(); + }, + fetchToken: async (code, { codeVerifier }) => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "Microsoft", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "Microsoft", + "client secret", + ), + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: `${AUTHORITY}/${tenant}/oauth2/v2.0/token`, + }), + fetchUser: async ({ access_token, id_token }, { nonce }) => { + const claims = await verifySsoIdToken({ + audience: requireSsoOption(clientId, "Microsoft", "client ID"), + idToken: id_token, + // Multi-tenant metadata publishes `.../{tenantid}/v2.0`: the issuer + // must name the very tenant the token says it came from. + issuer: payload => + typeof payload.tid === "string" && + GUID.test(payload.tid) && + payload.iss === `${AUTHORITY}/${payload.tid}/v2.0`, + keys: remoteJwks(`${AUTHORITY}/${tenant}/discovery/v2.0/keys`), + nonce, + }); + const tid = String(claims.tid); + if (!microsoftTenantAllowed({ allowedTenantIds, tenant, tid })) { + throw new HTTPException(403, { message: "Tenant not allowed" }); + } + + const oid = claimString(claims.oid); + if (!oid) throw ssoProviderFailure("Microsoft ID token without oid"); + + const info = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userInfoSchema, + step: "profile", + url: "https://graph.microsoft.com/oidc/userinfo", + }); + if (info.sub !== claims.sub) { + throw ssoProviderFailure( + "Microsoft UserInfo answered for another account", + ); + } + + const email = claimString(claims.email); + + return { + email, + emailVerified: email !== null && claimBoolean(claims.xms_edov) === true, + firstName: info.given_name ?? null, + id: `${tid.toLowerCase()}:${oid.toLowerCase()}`, + lastName: info.family_name ?? null, + username: claimString(claims.name), + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/paypal.ts b/packages/vitnode/src/api/adapters/sso/paypal.ts new file mode 100644 index 0000000000..96573def93 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/paypal.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + basicClientAuth, + bearerAuth, + claimString, + formPost, + requestSsoProviderJson, + requireSsoOption, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { PAYPAL_ICON } from "./icons"; + +/** Which PayPal you sign in against. Sandbox credentials only work with `"sandbox"`. */ +export type PayPalEnvironment = "live" | "sandbox"; + +/** Where each PayPal environment answers. */ +export const PAYPAL_ENDPOINTS: Record< + PayPalEnvironment, + { api: string; web: string } +> = { + live: { api: "https://api-m.paypal.com", web: "https://www.paypal.com" }, + sandbox: { + api: "https://api-m.sandbox.paypal.com", + web: "https://www.sandbox.paypal.com", + }, +}; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const userInfoSchema = z.object({ + email: z.string().nullable().optional(), + email_verified: z.boolean().nullable().optional(), + emails: z + .array( + z.object({ + confirmed: z.boolean().optional(), + primary: z.boolean().optional(), + value: z.string(), + }), + ) + .optional(), + family_name: z.string().nullable().optional(), + given_name: z.string().nullable().optional(), + name: z.string().nullable().optional(), + user_id: z.string().min(1).max(255), +}); + +/** The primary address and whether PayPal confirmed it. */ +const emailOf = ( + info: z.infer, +): { email: null | string; verified: boolean } => { + const primary = + info.emails?.find(one => one.primary) ?? info.emails?.at(0) ?? null; + if (primary) { + return { + email: claimString(primary.value), + verified: primary.confirmed === true, + }; + } + + const email = claimString(info.email); + + return { email, verified: email !== null && info.email_verified === true }; +}; + +/** + * Log in with PayPal: an SSO adapter for the API config's + * `authorization.ssoAdapters`. Signs people in with their PayPal account - + * it takes no payments and needs no payment permissions. Requests + * `openid email profile` and redirects back to `login/sso/paypal` under + * `VITNODE_WEB_URL`, which PayPal requires to be HTTPS. + * + * `environment` is required: sandbox and live have separate apps, + * credentials and endpoints. The primary email is trusted only when PayPal + * marks it confirmed; otherwise a new member confirms an address in VitNode + * with a code. Supplies first and last name for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { PayPalSSOApiPlugin } from "@vitnode/core/api/adapters/sso/paypal"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * PayPalSSOApiPlugin({ + * clientId: process.env.PAYPAL_CLIENT_ID, + * clientSecret: process.env.PAYPAL_CLIENT_SECRET, + * environment: "sandbox", + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const PayPalSSOApiPlugin = ({ + clientId, + clientSecret, + environment, +}: { + /** The REST app's Client ID for `environment`. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The REST app's Secret for `environment`. Needed to exchange the code. */ + clientSecret: string | undefined; + /** `"sandbox"` for test accounts, `"live"` for real ones. No default, on purpose. */ + environment: PayPalEnvironment; +}): SSOApiPlugin => { + const id = "paypal"; + const redirectUri = getRedirectUri(id); + const endpoints = PAYPAL_ENDPOINTS[environment] as + | (typeof PAYPAL_ENDPOINTS)[PayPalEnvironment] + | undefined; + if (!endpoints) { + throw new Error( + `Invalid PayPal environment "${environment}" - use "sandbox" or "live"`, + ); + } + + return { + brandColor: "#003087", + icon: PAYPAL_ICON, + id, + name: "PayPal", + profileFields: ["firstName", "lastName"], + getUrl: ({ state }) => { + const url = new URL(`${endpoints.web}/signin/authorize`); + url.searchParams.set("flowEntry", "static"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "PayPal", "client ID"), + ); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "openid email profile"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost( + { code, grant_type: "authorization_code" }, + basicClientAuth( + requireSsoOption(clientId, "PayPal", "client ID"), + requireSsoOption(clientSecret, "PayPal", "client secret"), + ), + ), + schema: tokenSchema, + step: "token", + url: `${endpoints.api}/v1/oauth2/token`, + }), + fetchUser: async ({ access_token }) => { + const url = new URL(`${endpoints.api}/v1/identity/oauth2/userinfo`); + url.searchParams.set("schema", "paypalv1.1"); + const info = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userInfoSchema, + step: "profile", + url, + }); + const { email, verified } = emailOf(info); + + return { + email, + emailVerified: email !== null && verified, + firstName: info.given_name ?? null, + id: info.user_id, + lastName: info.family_name ?? null, + username: info.name ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/profile.test.ts b/packages/vitnode/src/api/adapters/sso/profile.test.ts index 38534754ca..574408f624 100644 --- a/packages/vitnode/src/api/adapters/sso/profile.test.ts +++ b/packages/vitnode/src/api/adapters/sso/profile.test.ts @@ -10,6 +10,8 @@ const credentials = { clientId: "client", clientSecret: "secret" }; const TOKEN = { access_token: "token", token_type: "Bearer" }; +const CONTEXT = { codeVerifier: "verifier", nonce: "nonce", params: {} }; + const answer = (body: unknown) => { const fetch = vi.fn(async (_input: unknown) => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), @@ -37,7 +39,7 @@ describe("the Google adapter", () => { verified_email: true, }); - expect(await google.fetchUser(TOKEN)).toEqual({ + expect(await google.fetchUser(TOKEN, CONTEXT)).toEqual({ avatarUrl: "https://lh3.googleusercontent.com/a/photo=s96-c", email: "alice@gmail.test", emailVerified: true, @@ -56,7 +58,7 @@ describe("the Google adapter", () => { verified_email: true, }); - expect(await google.fetchUser(TOKEN)).toMatchObject({ + expect(await google.fetchUser(TOKEN, CONTEXT)).toMatchObject({ avatarUrl: null, firstName: null, lastName: null, @@ -77,7 +79,7 @@ describe("the Discord adapter", () => { verified: true, }); - expect(await discord.fetchUser(TOKEN)).toEqual({ + expect(await discord.fetchUser(TOKEN, CONTEXT)).toEqual({ avatarUrl: "https://cdn.discordapp.com/avatars/80351110224678912/a_1269e74af4df7417b13759eae50c83dc.png?size=512", email: "alice@discord.test", @@ -97,7 +99,7 @@ describe("the Discord adapter", () => { verified: true, }); - expect((await discord.fetchUser(TOKEN)).avatarUrl).toBeNull(); + expect((await discord.fetchUser(TOKEN, CONTEXT)).avatarUrl).toBeNull(); } }); }); @@ -120,7 +122,7 @@ describe("the Facebook adapter", () => { }, }); - expect(await facebook.fetchUser(TOKEN)).toEqual({ + expect(await facebook.fetchUser(TOKEN, CONTEXT)).toEqual({ avatarUrl: "https://platform-lookaside.fbsbx.com/photo.jpg", email: "alice@facebook.test", emailVerified: true, @@ -145,7 +147,7 @@ describe("the Facebook adapter", () => { picture: { data: { is_silhouette: true, url: "https://x.test/s.jpg" } }, }); - expect(await facebook.fetchUser(TOKEN)).toMatchObject({ + expect(await facebook.fetchUser(TOKEN, CONTEXT)).toMatchObject({ avatarUrl: null, firstName: null, lastName: null, diff --git a/packages/vitnode/src/api/adapters/sso/providers.test.ts b/packages/vitnode/src/api/adapters/sso/providers.test.ts new file mode 100644 index 0000000000..7bdf999e8a --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/providers.test.ts @@ -0,0 +1,1121 @@ +import { HTTPException } from "hono/http-exception"; +import { + decodeProtectedHeader, + exportJWK, + generateKeyPair, + jwtVerify, + SignJWT, +} from "jose"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { SSOApiPlugin, SSOTokenResponse } from "@/api/models/sso"; + +import { AppleSSOApiPlugin, createAppleClientSecret } from "./apple"; +import { AtlassianSSOApiPlugin } from "./atlassian"; +import { GitLabSSOApiPlugin, normalizeGitLabIssuer } from "./gitlab"; +import { KickSSOApiPlugin } from "./kick"; +import { LinkedInSSOApiPlugin } from "./linkedin"; +import { + MICROSOFT_CONSUMERS_TENANT_ID, + MicrosoftSSOApiPlugin, + microsoftTenantAllowed, +} from "./microsoft"; +import { PayPalSSOApiPlugin } from "./paypal"; +import { RedditSSOApiPlugin } from "./reddit"; +import { SlackSSOApiPlugin } from "./slack"; +import { TikTokSSOApiPlugin } from "./tiktok"; +import { TwitchSSOApiPlugin } from "./twitch"; +import { TwitterSSOApiPlugin } from "./twitter"; + +const creds = { clientId: "client", clientSecret: "secret" }; + +const REQUEST = { + codeChallenge: "challenge", + codeChallengeMethod: "S256" as const, + nonce: "nonce-1", + state: "state-1", +}; + +const CONTEXT = { codeVerifier: "verifier", nonce: "nonce-1", params: {} }; + +const TENANT = "11111111-2222-3333-4444-555555555555"; + +let rsa: Awaited>; +let apple: { pem: string; publicKey: CryptoKey }; + +beforeAll(async () => { + rsa = await generateKeyPair("RS256"); + const ec = await generateKeyPair("ES256", { extractable: true }); + const pkcs8 = Buffer.from( + await crypto.subtle.exportKey("pkcs8", ec.privateKey), + ).toString("base64"); + apple = { + pem: `-----BEGIN PRIVATE KEY-----\\n${pkcs8}\\n-----END PRIVATE KEY-----`, + publicKey: ec.publicKey, + }; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +interface Call { + body: undefined | URLSearchParams; + headers: Headers; + url: URL; +} + +/** Answers `fetch` by URL prefix, longest first, and records every call. */ +const route = (answers: Record) => { + const calls: Call[] = []; + const jwk = exportJWK(rsa.publicKey); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = new URL(String(input)); + calls.push({ + body: init?.body instanceof URLSearchParams ? init.body : undefined, + headers: new Headers(init?.headers), + url, + }); + if (/\/(keys|jwks)$/.test(url.pathname)) { + return Response.json({ + keys: [{ ...(await jwk), alg: "RS256", kid: "k1", use: "sig" }], + }); + } + const match = Object.keys(answers) + .sort((a, b) => b.length - a.length) + .find(prefix => url.href.startsWith(prefix)); + if (!match) return new Response("not found", { status: 404 }); + + return Response.json(answers[match]); + }), + ); + + return calls; +}; + +const idToken = async (claims: Record) => + await new SignJWT({ nonce: "nonce-1", ...claims }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuedAt() + .setExpirationTime("5m") + .sign(rsa.privateKey); + +const statusOf = async (promise: Promise) => { + try { + await promise; + } catch (error) { + return error instanceof HTTPException ? error.status : "error"; + } + + return "resolved"; +}; + +const signIn = async (adapter: SSOApiPlugin) => { + const token: SSOTokenResponse = await adapter.fetchToken("code-1", CONTEXT); + + return await adapter.fetchUser(token, CONTEXT); +}; + +const ADAPTERS: [ + SSOApiPlugin, + { authorize: string; nonce: boolean; pkce: boolean; scope: string }, +][] = [ + [ + AppleSSOApiPlugin({ + clientId: "com.example.web", + keyId: "ABCDEFGHIJ", + privateKey: "unused", + teamId: "TEAM123456", + }), + { + authorize: "https://appleid.apple.com/auth/authorize", + nonce: true, + pkce: false, + scope: "name email", + }, + ], + [ + AtlassianSSOApiPlugin(creds), + { + authorize: "https://auth.atlassian.com/authorize", + nonce: false, + pkce: false, + scope: "read:me", + }, + ], + [ + GitLabSSOApiPlugin(creds), + { + authorize: "https://gitlab.com/oauth/authorize", + nonce: true, + pkce: true, + scope: "openid profile email", + }, + ], + [ + KickSSOApiPlugin(creds), + { + authorize: "https://id.kick.com/oauth/authorize", + nonce: false, + pkce: true, + scope: "user:read", + }, + ], + [ + LinkedInSSOApiPlugin(creds), + { + authorize: "https://www.linkedin.com/oauth/v2/authorization", + nonce: false, + pkce: false, + scope: "openid profile email", + }, + ], + [ + MicrosoftSSOApiPlugin(creds), + { + authorize: + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + nonce: true, + pkce: true, + scope: "openid profile email", + }, + ], + [ + PayPalSSOApiPlugin({ ...creds, environment: "live" }), + { + authorize: "https://www.paypal.com/signin/authorize", + nonce: false, + pkce: false, + scope: "openid email profile", + }, + ], + [ + RedditSSOApiPlugin(creds), + { + authorize: "https://www.reddit.com/api/v1/authorize", + nonce: false, + pkce: false, + scope: "identity", + }, + ], + [ + SlackSSOApiPlugin(creds), + { + authorize: "https://slack.com/openid/connect/authorize", + nonce: true, + pkce: false, + scope: "openid profile email", + }, + ], + [ + TikTokSSOApiPlugin({ clientKey: "client", clientSecret: "secret" }), + { + authorize: "https://www.tiktok.com/v2/auth/authorize/", + nonce: false, + pkce: false, + scope: "user.info.basic", + }, + ], + [ + TwitchSSOApiPlugin(creds), + { + authorize: "https://id.twitch.tv/oauth2/authorize", + nonce: true, + pkce: false, + scope: "openid user:read:email", + }, + ], + [ + TwitterSSOApiPlugin(creds), + { + authorize: "https://x.com/i/oauth2/authorize", + nonce: false, + pkce: true, + scope: "tweet.read users.read users.email", + }, + ], +]; + +describe("the authorization URL", () => { + it.each( + ADAPTERS.map( + ([adapter, expected]) => [adapter.id, adapter, expected] as const, + ), + )( + "%s asks for the minimal scopes and returns to its own callback", + (id, adapter, expected) => { + const url = new URL(adapter.getUrl(REQUEST)); + const params = url.searchParams; + + expect(`${url.origin}${url.pathname}`).toBe(expected.authorize); + expect(params.get("redirect_uri")).toBe( + `http://localhost:3000/login/sso/${id}`, + ); + expect(params.get("state")).toBe("state-1"); + expect(params.get("response_type")).toBe("code"); + expect(params.get("scope")).toBe(expected.scope); + expect(params.get("code_challenge")).toBe( + expected.pkce ? "challenge" : null, + ); + expect(params.get("code_challenge_method")).toBe( + expected.pkce ? "S256" : null, + ); + expect(params.get("nonce")).toBe(expected.nonce ? "nonce-1" : null); + }, + ); + + it("names TikTok's client ID `client_key`", () => { + const url = new URL( + TikTokSSOApiPlugin({ clientKey: "key", clientSecret: "s" }).getUrl( + REQUEST, + ), + ); + + expect(url.searchParams.get("client_key")).toBe("key"); + expect(url.searchParams.has("client_id")).toBe(false); + }); + + it("asks Apple to post the callback, and Reddit for a token without refresh", () => { + const appleUrl = new URL(ADAPTERS[0][0].getUrl(REQUEST)); + const redditUrl = new URL(RedditSSOApiPlugin(creds).getUrl(REQUEST)); + + expect(appleUrl.searchParams.get("response_mode")).toBe("form_post"); + expect(redditUrl.searchParams.get("duration")).toBe("temporary"); + }); + + it("leaves `users.email` out for an X app without email access", () => { + const url = new URL( + TwitterSSOApiPlugin({ ...creds, requestEmail: false }).getUrl(REQUEST), + ); + + expect(url.searchParams.get("scope")).toBe("tweet.read users.read"); + }); +}); + +describe("missing credentials", () => { + it.each(ADAPTERS.map(([adapter]) => [adapter.id] as const))( + "%s keeps its button but refuses to start or finish a sign-in", + async id => { + const factories: Record SSOApiPlugin> = { + apple: () => + AppleSSOApiPlugin({ + clientId: undefined, + keyId: undefined, + privateKey: undefined, + teamId: undefined, + }), + atlassian: () => + AtlassianSSOApiPlugin({ + clientId: undefined, + clientSecret: undefined, + }), + gitlab: () => + GitLabSSOApiPlugin({ clientId: undefined, clientSecret: undefined }), + kick: () => + KickSSOApiPlugin({ clientId: undefined, clientSecret: undefined }), + linkedin: () => + LinkedInSSOApiPlugin({ + clientId: undefined, + clientSecret: undefined, + }), + microsoft: () => + MicrosoftSSOApiPlugin({ + clientId: undefined, + clientSecret: undefined, + }), + paypal: () => + PayPalSSOApiPlugin({ + clientId: undefined, + clientSecret: undefined, + environment: "sandbox", + }), + reddit: () => + RedditSSOApiPlugin({ clientId: undefined, clientSecret: undefined }), + slack: () => + SlackSSOApiPlugin({ clientId: undefined, clientSecret: undefined }), + tiktok: () => + TikTokSSOApiPlugin({ clientKey: undefined, clientSecret: undefined }), + twitch: () => + TwitchSSOApiPlugin({ clientId: undefined, clientSecret: undefined }), + twitter: () => + TwitterSSOApiPlugin({ clientId: undefined, clientSecret: undefined }), + }; + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const adapter = factories[id](); + + expect(adapter.name.length).toBeGreaterThan(0); + expect(() => adapter.getUrl(REQUEST)).toThrow(/^Missing /); + await expect(adapter.fetchToken("code", CONTEXT)).rejects.toThrow( + /^Missing /, + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); +}); + +describe("the token exchange", () => { + const TOKEN = { + access_token: "at", + id_token: "it", + ok: true, + token_type: "Bearer", + }; + + it.each([ + [ + "atlassian", + AtlassianSSOApiPlugin(creds), + "https://auth.atlassian.com/oauth/token", + "post", + false, + ], + [ + "gitlab", + GitLabSSOApiPlugin(creds), + "https://gitlab.com/oauth/token", + "post", + true, + ], + [ + "kick", + KickSSOApiPlugin(creds), + "https://id.kick.com/oauth/token", + "post", + true, + ], + [ + "linkedin", + LinkedInSSOApiPlugin(creds), + "https://www.linkedin.com/oauth/v2/accessToken", + "post", + false, + ], + [ + "microsoft", + MicrosoftSSOApiPlugin(creds), + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "post", + true, + ], + [ + "paypal", + PayPalSSOApiPlugin({ ...creds, environment: "live" }), + "https://api-m.paypal.com/v1/oauth2/token", + "basic", + false, + ], + [ + "reddit", + RedditSSOApiPlugin(creds), + "https://www.reddit.com/api/v1/access_token", + "basic", + false, + ], + [ + "slack", + SlackSSOApiPlugin(creds), + "https://slack.com/api/openid.connect.token", + "post", + false, + ], + [ + "twitch", + TwitchSSOApiPlugin(creds), + "https://id.twitch.tv/oauth2/token", + "post", + false, + ], + [ + "twitter", + TwitterSSOApiPlugin(creds), + "https://api.x.com/2/oauth2/token", + "basic", + true, + ], + ] as const)( + "%s posts the code to its token endpoint with the right client authentication", + async (_, adapter, tokenUrl, auth, pkce) => { + const calls = route({ [tokenUrl]: TOKEN }); + + await adapter.fetchToken("code-1", CONTEXT); + + const [call] = calls; + expect(call?.url.href).toBe(tokenUrl); + expect(call?.body?.get("code")).toBe("code-1"); + expect(call?.body?.get("grant_type")).toBe("authorization_code"); + expect(call?.body?.get("code_verifier")).toBe(pkce ? "verifier" : null); + if (auth === "basic") { + expect(call?.headers.get("authorization")).toBe( + `Basic ${Buffer.from("client:secret").toString("base64")}`, + ); + expect(call?.body?.has("client_secret")).toBe(false); + } else { + expect(call?.body?.get("client_secret")).toBe("secret"); + expect(call?.headers.has("authorization")).toBe(false); + } + }, + ); + + it("sends TikTok's `client_key` and Reddit's own User-Agent", async () => { + const tiktok = route({ + "https://open.tiktokapis.com/v2/oauth/token/": TOKEN, + }); + await TikTokSSOApiPlugin({ + clientKey: "key", + clientSecret: "s", + }).fetchToken("code-1", CONTEXT); + expect(tiktok[0]?.body?.get("client_key")).toBe("key"); + + const reddit = route({ "https://www.reddit.com/api/v1/": TOKEN }); + await RedditSSOApiPlugin({ + ...creds, + userAgent: "web:forum:1.0 (by /u/me)", + }).fetchToken("code-1", CONTEXT); + expect(reddit[0]?.headers.get("user-agent")).toBe( + "web:forum:1.0 (by /u/me)", + ); + }); + + it("fails a token answer without an access token, or a Slack answer that is not ok", async () => { + route({ "https://id.kick.com/oauth/token": { error: "invalid_grant" } }); + expect( + await statusOf(KickSSOApiPlugin(creds).fetchToken("c", CONTEXT)), + ).toBe(502); + + route({ + "https://slack.com/api/openid.connect.token": { + error: "invalid_code", + ok: false, + }, + }); + expect( + await statusOf(SlackSSOApiPlugin(creds).fetchToken("c", CONTEXT)), + ).toBe(502); + }); +}); + +describe("profiles and email trust", () => { + it("X: trusts a confirmed email and asks for the larger avatar", async () => { + route({ + "https://api.x.com/2/oauth2/token": { + access_token: "at", + token_type: "bearer", + }, + "https://api.x.com/2/users/me": { + data: { + confirmed_email: "alice@x.test", + id: "1234", + name: "Alice", + profile_image_url: + "https://pbs.twimg.com/profile_images/1/a_normal.jpg", + username: "alice", + }, + }, + }); + + expect(await signIn(TwitterSSOApiPlugin(creds))).toEqual({ + avatarUrl: "https://pbs.twimg.com/profile_images/1/a_400x400.jpg", + email: "alice@x.test", + emailVerified: true, + id: "1234", + username: "Alice", + }); + }); + + it("X: an account without a confirmed email has none", async () => { + route({ + "https://api.x.com/2/oauth2/token": { + access_token: "at", + token_type: "bearer", + }, + "https://api.x.com/2/users/me": { + data: { id: "1234", username: "alice" }, + }, + }); + + expect(await signIn(TwitterSSOApiPlugin(creds))).toMatchObject({ + avatarUrl: null, + email: null, + emailVerified: false, + username: "alice", + }); + }); + + it("Kick: stringifies the numeric id and never trusts the email", async () => { + route({ + "https://id.kick.com/oauth/token": { + access_token: "at", + token_type: "Bearer", + }, + "https://api.kick.com/public/v1/users": { + data: [ + { + email: "alice@kick.test", + name: "alice", + profile_picture: "https://files.kick.com/a.png", + user_id: 42, + }, + ], + }, + }); + + expect(await signIn(KickSSOApiPlugin(creds))).toEqual({ + avatarUrl: "https://files.kick.com/a.png", + email: "alice@kick.test", + emailVerified: false, + id: "42", + username: "alice", + }); + }); + + it("Reddit: no email, and an avatar with its entities decoded", async () => { + const calls = route({ + "https://www.reddit.com/api/v1/access_token": { + access_token: "at", + token_type: "bearer", + }, + "https://oauth.reddit.com/api/v1/me": { + icon_img: "https://styles.redditmedia.com/a.png?width=256&s=abc", + id: "abc12", + name: "alice", + }, + }); + + expect(await signIn(RedditSSOApiPlugin(creds))).toEqual({ + avatarUrl: "https://styles.redditmedia.com/a.png?width=256&s=abc", + email: null, + emailVerified: false, + id: "abc12", + username: "alice", + }); + expect(calls[1]?.headers.get("user-agent")).toBe( + "web:client:1.0 (by VitNode)", + ); + }); + + it("TikTok: keyed on open_id, with no email; an error answer or another account fails", async () => { + const answer = (user: Record, code = "ok") => + route({ + "https://open.tiktokapis.com/v2/oauth/token/": { + access_token: "at", + open_id: "open-1", + }, + "https://open.tiktokapis.com/v2/user/info/": { + data: { user }, + error: { code }, + }, + }); + const tiktok = TikTokSSOApiPlugin({ clientKey: "key", clientSecret: "s" }); + + answer({ + avatar_url: "https://p16.tiktokcdn.test/a.jpg", + display_name: "Alice", + open_id: "open-1", + }); + expect(await signIn(tiktok)).toEqual({ + avatarUrl: "https://p16.tiktokcdn.test/a.jpg", + email: null, + emailVerified: false, + id: "open-1", + username: "Alice", + }); + + answer({ open_id: "open-1" }, "access_token_invalid"); + expect(await statusOf(signIn(tiktok))).toBe(502); + + answer({ open_id: "open-2" }); + expect(await statusOf(signIn(tiktok))).toBe(502); + }); + + it.each([ + [ + "Atlassian", + () => AtlassianSSOApiPlugin(creds), + "https://auth.atlassian.com/oauth/token", + "https://api.atlassian.com/me", + { + account_id: "acc-1", + nickname: "alice", + picture: "https://avatar.atl.test/a.png", + }, + ], + [ + "LinkedIn", + () => LinkedInSSOApiPlugin(creds), + "https://www.linkedin.com/oauth/v2/accessToken", + "https://api.linkedin.com/v2/userinfo", + { family_name: "Smith", given_name: "Alice", sub: "acc-1" }, + ], + ] as const)( + "%s: trusts the email only with email_verified: true", + async (_, make, tokenUrl, profileUrl, profile) => { + for (const [emailVerified, trusted] of [ + [true, true], + [false, false], + [undefined, false], + ] as const) { + route({ + [profileUrl]: { + ...profile, + email: "alice@example.test", + email_verified: emailVerified, + }, + [tokenUrl]: { access_token: "at" }, + }); + + expect(await signIn(make())).toMatchObject({ + email: "alice@example.test", + emailVerified: trusted, + id: "acc-1", + }); + } + }, + ); + + it("LinkedIn: maps given and family name without splitting the display name", async () => { + route({ + "https://www.linkedin.com/oauth/v2/accessToken": { access_token: "at" }, + "https://api.linkedin.com/v2/userinfo": { + name: "Alice Mary Smith", + sub: "acc-1", + }, + }); + + expect(await signIn(LinkedInSSOApiPlugin(creds))).toMatchObject({ + firstName: null, + lastName: null, + username: "Alice Mary Smith", + }); + }); + + it("PayPal: picks the sandbox or live endpoints, and trusts only a confirmed primary email", async () => { + for (const [environment, api] of [ + ["sandbox", "https://api-m.sandbox.paypal.com"], + ["live", "https://api-m.paypal.com"], + ] as const) { + const calls = route({ + [`${api}/v1/identity/oauth2/userinfo`]: { + emails: [ + { confirmed: false, primary: false, value: "old@paypal.test" }, + { confirmed: true, primary: true, value: "alice@paypal.test" }, + ], + family_name: "Smith", + given_name: "Alice", + name: "Alice Smith", + user_id: "https://www.paypal.com/webapps/auth/identity/user/abc", + }, + [`${api}/v1/oauth2/token`]: { + access_token: "at", + token_type: "Bearer", + }, + }); + + expect( + await signIn(PayPalSSOApiPlugin({ ...creds, environment })), + ).toEqual({ + email: "alice@paypal.test", + emailVerified: true, + firstName: "Alice", + id: "https://www.paypal.com/webapps/auth/identity/user/abc", + lastName: "Smith", + username: "Alice Smith", + }); + expect(calls.every(call => call.url.origin === api)).toBe(true); + } + + expect( + new URL( + PayPalSSOApiPlugin({ ...creds, environment: "sandbox" }).getUrl( + REQUEST, + ), + ).origin, + ).toBe("https://www.sandbox.paypal.com"); + expect(() => + PayPalSSOApiPlugin({ ...creds, environment: "production" as never }), + ).toThrow(/sandbox/); + }); + + it("PayPal: an unconfirmed primary email is not trusted", async () => { + route({ + "https://api-m.paypal.com/v1/identity/oauth2/userinfo": { + emails: [ + { confirmed: false, primary: true, value: "alice@paypal.test" }, + ], + user_id: "u-1", + }, + "https://api-m.paypal.com/v1/oauth2/token": { + access_token: "at", + token_type: "Bearer", + }, + }); + + expect( + await signIn(PayPalSSOApiPlugin({ ...creds, environment: "live" })), + ).toMatchObject({ emailVerified: false }); + }); +}); + +describe("OpenID Connect providers", () => { + const tokenAnswer = async (claims: Record) => ({ + access_token: "at", + id_token: await idToken(claims), + ok: true, + token_type: "Bearer", + }); + + it("Twitch: validates the ID token and drops an unverified email", async () => { + const claims = { + aud: "client", + iss: "https://id.twitch.tv/oauth2", + sub: "t-1", + }; + route({ + "https://id.twitch.tv/oauth2/token": await tokenAnswer(claims), + "https://id.twitch.tv/oauth2/userinfo": { + email: "alice@twitch.test", + email_verified: false, + picture: "https://static-cdn.jtvnw.test/a.png", + preferred_username: "alice", + sub: "t-1", + }, + }); + + expect(await signIn(TwitchSSOApiPlugin(creds))).toEqual({ + avatarUrl: "https://static-cdn.jtvnw.test/a.png", + email: "alice@twitch.test", + emailVerified: false, + id: "t-1", + username: "alice", + }); + + route({ + "https://id.twitch.tv/oauth2/token": await tokenAnswer({ + ...claims, + iss: "https://evil.test", + }), + "https://id.twitch.tv/oauth2/userinfo": { sub: "t-1" }, + }); + expect(await statusOf(signIn(TwitchSSOApiPlugin(creds)))).toBe(400); + }); + + it("Slack: keys accounts by workspace and user, and enforces a configured workspace", async () => { + const claims = { + aud: "client", + email: "alice@acme.test", + email_verified: true, + family_name: "Smith", + given_name: "Alice", + "https://slack.com/team_id": "T0001", + "https://slack.com/user_id": "U0001", + iss: "https://slack.com", + name: "Alice Smith", + picture: "https://avatars.slack-edge.test/a.png", + sub: "U0001", + }; + route({ + "https://slack.com/api/openid.connect.token": await tokenAnswer(claims), + }); + + expect(await signIn(SlackSSOApiPlugin(creds))).toEqual({ + avatarUrl: "https://avatars.slack-edge.test/a.png", + email: "alice@acme.test", + emailVerified: true, + firstName: "Alice", + id: "T0001:U0001", + lastName: "Smith", + username: "Alice Smith", + }); + + const restricted = SlackSSOApiPlugin({ ...creds, teamId: "T0002" }); + expect(new URL(restricted.getUrl(REQUEST)).searchParams.get("team")).toBe( + "T0002", + ); + route({ + "https://slack.com/api/openid.connect.token": await tokenAnswer(claims), + }); + expect(await statusOf(signIn(restricted))).toBe(403); + expect(() => SlackSSOApiPlugin({ ...creds, teamId: "acme" })).toThrow( + /teamId/, + ); + }); + + it("GitLab: isolates self-managed accounts from GitLab.com ones", async () => { + const answer = async (issuer: string) => + route({ + [`${issuer}/oauth/token`]: await tokenAnswer({ + aud: "client", + iss: issuer, + sub: "42", + }), + [`${issuer}/oauth/userinfo`]: { + email: "alice@gitlab.test", + email_verified: true, + given_name: "Alice", + picture: "https://gitlab.test/a.png", + sub: "42", + }, + }); + + await answer("https://gitlab.com"); + expect((await signIn(GitLabSSOApiPlugin(creds))).id).toBe("42"); + + await answer("https://git.example.test"); + const selfManaged = GitLabSSOApiPlugin({ + ...creds, + id: "gitlab-acme", + issuer: "https://git.example.test/", + }); + expect(await signIn(selfManaged)).toMatchObject({ + emailVerified: true, + firstName: "Alice", + id: "git.example.test:42", + }); + expect(selfManaged.getUrl(REQUEST)).toContain( + "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Flogin%2Fsso%2Fgitlab-acme", + ); + + // A token issued by another instance does not pass for this one. + route({ + "https://git.example.test/oauth/token": await tokenAnswer({ + aud: "client", + iss: "https://gitlab.com", + sub: "42", + }), + "https://git.example.test/oauth/userinfo": { sub: "42" }, + }); + expect(await statusOf(signIn(selfManaged))).toBe(400); + }); + + it("GitLab: accepts only an HTTPS issuer (HTTP for localhost)", () => { + expect(normalizeGitLabIssuer("https://git.example.test/gitlab/")).toBe( + "https://git.example.test/gitlab", + ); + expect(normalizeGitLabIssuer("http://localhost:8080")).toBe( + "http://localhost:8080", + ); + for (const bad of [ + "http://git.example.test", + "https://git.example.test?x=1", + "not a url", + ]) { + expect(() => normalizeGitLabIssuer(bad)).toThrow(/Invalid GitLab issuer/); + } + expect(() => GitLabSSOApiPlugin({ ...creds, id: "git lab" })).toThrow( + /provider id/, + ); + }); + + describe("Microsoft", () => { + const claims = (tid: string, extra: Record = {}) => ({ + aud: "client", + email: "alice@contoso.test", + iss: `https://login.microsoftonline.com/${tid}/v2.0`, + name: "Alice Smith", + oid: "aaaaaaaa-0000-0000-0000-000000000001", + sub: "pairwise-sub", + tid, + ...extra, + }); + const answer = async ( + tenant: string, + tokenClaims: Record, + ) => + route({ + [`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`]: + await tokenAnswer(tokenClaims), + "https://graph.microsoft.com/oidc/userinfo": { + family_name: "Smith", + given_name: "Alice", + sub: "pairwise-sub", + }, + }); + + it("keys the account by tenant and object id, and trusts only a domain-verified email", async () => { + await answer("common", claims(TENANT)); + expect(await signIn(MicrosoftSSOApiPlugin(creds))).toEqual({ + email: "alice@contoso.test", + emailVerified: false, + firstName: "Alice", + id: `${TENANT}:aaaaaaaa-0000-0000-0000-000000000001`, + lastName: "Smith", + username: "Alice Smith", + }); + + await answer("common", claims(TENANT, { xms_edov: true })); + expect(await signIn(MicrosoftSSOApiPlugin(creds))).toMatchObject({ + emailVerified: true, + }); + }); + + it("refuses an issuer that does not name the token's own tenant", async () => { + await answer("common", { + ...claims(TENANT), + iss: "https://login.microsoftonline.com/common/v2.0", + }); + + expect(await statusOf(signIn(MicrosoftSSOApiPlugin(creds)))).toBe(400); + }); + + it("enforces the configured tenant on the token, not only in the URL", async () => { + await answer("consumers", claims(TENANT)); + expect( + await statusOf( + signIn(MicrosoftSSOApiPlugin({ ...creds, tenant: "consumers" })), + ), + ).toBe(403); + + await answer("common", claims(TENANT)); + expect( + await statusOf( + signIn( + MicrosoftSSOApiPlugin({ + ...creds, + allowedTenantIds: ["99999999-2222-3333-4444-555555555555"], + }), + ), + ), + ).toBe(403); + }); + + it.each([ + ["common", TENANT, true], + ["common", MICROSOFT_CONSUMERS_TENANT_ID, true], + ["organizations", TENANT, true], + ["organizations", MICROSOFT_CONSUMERS_TENANT_ID, false], + ["consumers", MICROSOFT_CONSUMERS_TENANT_ID, true], + ["consumers", TENANT, false], + [TENANT, TENANT.toUpperCase(), true], + [TENANT, MICROSOFT_CONSUMERS_TENANT_ID, false], + ])("tenant %s accepts tid %s: %s", (tenant, tid, allowed) => { + expect(microsoftTenantAllowed({ tenant, tid })).toBe(allowed); + }); + + it("refuses a tenant it cannot enforce", () => { + expect(() => + MicrosoftSSOApiPlugin({ ...creds, tenant: "contoso.com" }), + ).toThrow(/tenant/); + }); + }); + + describe("Apple", () => { + const appleAdapter = () => + AppleSSOApiPlugin({ + clientId: "com.example.web", + keyId: "ABCDEFGHIJ", + privateKey: apple.pem, + teamId: "TEAM123456", + }); + const appleClaims = (extra: Record = {}) => ({ + aud: "com.example.web", + email: "abc@privaterelay.appleid.com", + email_verified: "true", + is_private_email: "true", + iss: "https://appleid.apple.com", + sub: "001234.abc", + ...extra, + }); + + it("signs a client secret Apple accepts: ES256, Key ID, Team ID, Services ID", async () => { + const { expiresAt, secret } = await createAppleClientSecret({ + clientId: "com.example.web", + keyId: "ABCDEFGHIJ", + now: new Date("2026-10-01T00:00:00Z"), + privateKey: apple.pem, + teamId: "TEAM123456", + }); + const { payload } = await jwtVerify(secret, apple.publicKey, { + currentDate: new Date("2026-10-01T00:00:00Z"), + }); + + expect(decodeProtectedHeader(secret)).toEqual({ + alg: "ES256", + kid: "ABCDEFGHIJ", + }); + expect(payload).toMatchObject({ + aud: "https://appleid.apple.com", + iss: "TEAM123456", + sub: "com.example.web", + }); + expect(expiresAt - (payload.iat ?? 0)).toBeLessThanOrEqual(15_777_000); + }); + + it("reuses the client secret until it nears expiry, then renews it", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const adapter = appleAdapter(); + const calls = route({ + "https://appleid.apple.com/auth/token": + await tokenAnswer(appleClaims()), + }); + + await adapter.fetchToken("c", CONTEXT); + await adapter.fetchToken("c", CONTEXT); + vi.setSystemTime(Date.now() + 23.5 * 60 * 60_000); + await adapter.fetchToken("c", CONTEXT); + vi.useRealTimers(); + + const secrets = calls.map(call => call.body?.get("client_secret")); + expect(secrets[0]).toBe(secrets[1]); + expect(secrets[2]).not.toBe(secrets[0]); + }); + + it("trusts a verified relay email and takes the name from the first authorization only", async () => { + route({ + "https://appleid.apple.com/auth/token": + await tokenAnswer(appleClaims()), + }); + const adapter = appleAdapter(); + const token = await adapter.fetchToken("c", CONTEXT); + const first = await adapter.fetchUser(token, { + ...CONTEXT, + params: { + user: JSON.stringify({ + name: { firstName: "Alice", lastName: "Smith" }, + }), + }, + }); + const later = await adapter.fetchUser(token, CONTEXT); + + expect(first).toEqual({ + email: "abc@privaterelay.appleid.com", + emailVerified: true, + firstName: "Alice", + id: "001234.abc", + lastName: "Smith", + username: "Alice Smith", + }); + expect(later).toMatchObject({ + firstName: null, + lastName: null, + username: null, + }); + }); + + it("rejects an ID token for another Services ID or another attempt's nonce", async () => { + for (const claims of [ + appleClaims({ aud: "com.other.web" }), + appleClaims({ nonce: "nonce-2" }), + ]) { + route({ + "https://appleid.apple.com/auth/token": await tokenAnswer(claims), + }); + + expect(await statusOf(signIn(appleAdapter()))).toBe(400); + } + }); + + it("does not trust an email Apple marks unverified", async () => { + route({ + "https://appleid.apple.com/auth/token": await tokenAnswer( + appleClaims({ email_verified: "false" }), + ), + }); + + expect(await signIn(appleAdapter())).toMatchObject({ + emailVerified: false, + }); + }); + }); +}); diff --git a/packages/vitnode/src/api/adapters/sso/reddit.ts b/packages/vitnode/src/api/adapters/sso/reddit.ts new file mode 100644 index 0000000000..9937d096a3 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/reddit.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + basicClientAuth, + bearerAuth, + formPost, + requestSsoProviderJson, + requireSsoOption, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { REDDIT_ICON } from "./icons"; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const meSchema = z.object({ + icon_img: z.string().nullable().optional(), + id: z.string().regex(/^[\da-z]{1,32}$/), + name: z.string().min(1), + snoovatar_img: z.string().nullable().optional(), +}); + +/** Reddit escapes `&` in image URLs as `&`. */ +const unescapeUrl = (value: null | string | undefined): null | string => + value ? value.replaceAll("&", "&") : null; + +/** + * Sign in with Reddit: an SSO adapter for the API config's + * `authorization.ssoAdapters`. Requests only the `identity` scope - no + * posting, voting or moderation - with a temporary (one-hour, no refresh) + * token, and redirects back to `login/sso/reddit` under `VITNODE_WEB_URL`. + * + * Reddit never shares an email address, so a new member confirms one in + * VitNode with a code, and a returning member signs in by their Reddit + * account ID. Supplies the avatar for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { RedditSSOApiPlugin } from "@vitnode/core/api/adapters/sso/reddit"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * RedditSSOApiPlugin({ + * clientId: process.env.REDDIT_CLIENT_ID, + * clientSecret: process.env.REDDIT_CLIENT_SECRET, + * userAgent: "web:com.example.forum:1.0.0 (by /u/your_username)", + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const RedditSSOApiPlugin = ({ + clientId, + clientSecret, + userAgent, +}: { + /** The "web app"'s client ID (under its name on reddit.com/prefs/apps). `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The app's secret. Needed to exchange the code. */ + clientSecret: string | undefined; + /** + * The `User-Agent` sent to Reddit, which throttles generic ones. Reddit asks + * for `:: (by /u/)`. Defaults to + * `web::1.0 (by VitNode)`. + */ + userAgent?: string; +}): SSOApiPlugin => { + const id = "reddit"; + const redirectUri = getRedirectUri(id); + const agent = () => + userAgent ?? + `web:${requireSsoOption(clientId, "Reddit", "client ID")}:1.0 (by VitNode)`; + + return { + brandColor: "#FF4500", + icon: REDDIT_ICON, + id, + name: "Reddit", + profileFields: ["avatar"], + getUrl: ({ state }) => { + const url = new URL("https://www.reddit.com/api/v1/authorize"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Reddit", "client ID"), + ); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", state); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("duration", "temporary"); + url.searchParams.set("scope", "identity"); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost( + { code, grant_type: "authorization_code", redirect_uri: redirectUri }, + { + ...basicClientAuth( + requireSsoOption(clientId, "Reddit", "client ID"), + requireSsoOption(clientSecret, "Reddit", "client secret"), + ), + "User-Agent": agent(), + }, + ), + schema: tokenSchema, + step: "token", + url: "https://www.reddit.com/api/v1/access_token", + }), + fetchUser: async ({ access_token }) => { + const me = await requestSsoProviderJson({ + init: { + headers: { ...bearerAuth(access_token), "User-Agent": agent() }, + }, + schema: meSchema, + step: "profile", + url: "https://oauth.reddit.com/api/v1/me", + }); + + return { + avatarUrl: unescapeUrl(me.snoovatar_img) ?? unescapeUrl(me.icon_img), + email: null, + emailVerified: false, + id: me.id, + username: me.name, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/slack.ts b/packages/vitnode/src/api/adapters/sso/slack.ts new file mode 100644 index 0000000000..13423e8e14 --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/slack.ts @@ -0,0 +1,155 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + claimString, + formPost, + remoteJwks, + requestSsoProviderJson, + requireSsoOption, + ssoProviderFailure, + verifySsoIdToken, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { SLACK_ICON } from "./icons"; + +const ISSUER = "https://slack.com"; + +const TEAM_ID = /^[TE][A-Z0-9]{2,20}$/; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + id_token: z.string().min(1), + ok: z.literal(true), + token_type: z.string().min(1), +}); + +/** + * Sign in with Slack: an SSO adapter for the API config's + * `authorization.ssoAdapters`, using Slack's OpenID Connect flow - no bot is + * installed and no workspace data is read. Requests `openid profile email`, + * validates the ID token (signature, issuer, audience, expiry, nonce) and + * redirects back to `login/sso/slack` under `VITNODE_WEB_URL`. + * + * Slack user IDs are only unique within a workspace, so the stored account id + * is `{team_id}:{user_id}`. With `teamId` set, only members of that workspace + * can sign in - checked on the ID token, not just suggested to Slack. Supplies + * the avatar, first and last name for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { SlackSSOApiPlugin } from "@vitnode/core/api/adapters/sso/slack"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * SlackSSOApiPlugin({ + * clientId: process.env.SLACK_CLIENT_ID, + * clientSecret: process.env.SLACK_CLIENT_SECRET, + * teamId: process.env.SLACK_TEAM_ID, // optional + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const SlackSSOApiPlugin = ({ + clientId, + clientSecret, + teamId, +}: { + /** The app's Client ID (Basic Information → App Credentials). `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The app's Client Secret. Needed to exchange the code. */ + clientSecret: string | undefined; + /** + * Restrict sign-in to one workspace, by its ID (`T…`, or `E…` for an + * Enterprise organization). Preselects it on Slack's screen and rejects + * (`403`) an ID token from any other workspace. Leave unset to accept + * every workspace. + */ + teamId?: string; +}): SSOApiPlugin => { + const id = "slack"; + const redirectUri = getRedirectUri(id); + const team = claimString(teamId) ?? undefined; + if (team !== undefined && !TEAM_ID.test(team)) { + throw new Error( + `Invalid Slack teamId "${team}" - expected an ID like T0123ABCD`, + ); + } + + return { + icon: SLACK_ICON, + id, + name: "Slack", + profileFields: ["avatar", "firstName", "lastName"], + getUrl: ({ nonce, state }) => { + const url = new URL(`${ISSUER}/openid/connect/authorize`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "openid profile email"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Slack", "client ID"), + ); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + if (team) url.searchParams.set("team", team); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "Slack", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "Slack", + "client secret", + ), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: `${ISSUER}/api/openid.connect.token`, + }), + fetchUser: async ({ id_token }, { nonce }) => { + const claims = await verifySsoIdToken({ + audience: requireSsoOption(clientId, "Slack", "client ID"), + idToken: id_token, + issuer: ISSUER, + keys: remoteJwks(`${ISSUER}/openid/connect/keys`), + nonce, + }); + const teamOf = claimString(claims["https://slack.com/team_id"]); + const userOf = claimString(claims["https://slack.com/user_id"]); + if (!(teamOf && userOf)) { + throw ssoProviderFailure("Slack ID token without a workspace or user"); + } + if (team && teamOf !== team) { + throw new HTTPException(403, { message: "Workspace not allowed" }); + } + + const email = claimString(claims.email); + + return { + avatarUrl: claimString(claims.picture), + email, + emailVerified: email !== null && claims.email_verified === true, + firstName: claimString(claims.given_name), + id: `${teamOf}:${userOf}`, + lastName: claimString(claims.family_name), + username: claimString(claims.name), + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/tiktok.ts b/packages/vitnode/src/api/adapters/sso/tiktok.ts new file mode 100644 index 0000000000..b55d56345a --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/tiktok.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + formPost, + requestSsoProviderJson, + requireSsoOption, + ssoProviderFailure, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { TIKTOK_ICON } from "./icons"; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + open_id: z.string().min(1).optional(), + token_type: z.string().min(1).default("Bearer"), +}); + +const userSchema = z.object({ + data: z.object({ + user: z.object({ + avatar_url: z.string().nullable().optional(), + display_name: z.string().nullable().optional(), + open_id: z.string().min(1).max(255), + }), + }), + error: z.object({ code: z.literal("ok") }), +}); + +/** + * Sign in with TikTok (Login Kit for Web): an SSO adapter for the API + * config's `authorization.ssoAdapters`. Requests only `user.info.basic` and + * redirects back to `login/sso/tiktok` under `VITNODE_WEB_URL`, which TikTok + * requires to be a static HTTPS URL. + * + * TikTok calls the client ID a "Client Key". The account id is the `open_id`, + * unique to your app. TikTok never shares an email address, so a new member + * confirms one in VitNode with a code. Supplies the avatar for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { TikTokSSOApiPlugin } from "@vitnode/core/api/adapters/sso/tiktok"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * TikTokSSOApiPlugin({ + * clientKey: process.env.TIKTOK_CLIENT_KEY, + * clientSecret: process.env.TIKTOK_CLIENT_SECRET, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const TikTokSSOApiPlugin = ({ + clientKey, + clientSecret, +}: { + /** The app's Client Key. `undefined` still registers the button, but signing in throws until it is set. */ + clientKey: string | undefined; + /** The app's Client Secret. Needed to exchange the code. */ + clientSecret: string | undefined; +}): SSOApiPlugin => { + const id = "tiktok"; + const redirectUri = getRedirectUri(id); + + return { + brandColor: "#000000", + icon: TIKTOK_ICON, + id, + name: "TikTok", + profileFields: ["avatar"], + getUrl: ({ state }) => { + const url = new URL("https://www.tiktok.com/v2/auth/authorize/"); + url.searchParams.set( + "client_key", + requireSsoOption(clientKey, "TikTok", "client key"), + ); + url.searchParams.set("scope", "user.info.basic"); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost({ + client_key: requireSsoOption(clientKey, "TikTok", "client key"), + client_secret: requireSsoOption( + clientSecret, + "TikTok", + "client secret", + ), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: "https://open.tiktokapis.com/v2/oauth/token/", + }), + fetchUser: async ({ access_token, open_id }) => { + const url = new URL("https://open.tiktokapis.com/v2/user/info/"); + url.searchParams.set("fields", "open_id,avatar_url,display_name"); + const { + data: { user }, + } = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userSchema, + step: "profile", + url, + }); + if (typeof open_id === "string" && open_id !== user.open_id) { + throw ssoProviderFailure("TikTok answered for another account"); + } + + return { + avatarUrl: user.avatar_url ?? null, + email: null, + emailVerified: false, + id: user.open_id, + username: user.display_name ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/twitch.ts b/packages/vitnode/src/api/adapters/sso/twitch.ts new file mode 100644 index 0000000000..c3c50a64ae --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/twitch.ts @@ -0,0 +1,156 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + bearerAuth, + claimString, + formPost, + remoteJwks, + requestSsoProviderJson, + requireSsoOption, + ssoProviderFailure, + verifySsoIdToken, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { TWITCH_ICON } from "./icons"; + +const ISSUER = "https://id.twitch.tv/oauth2"; + +const CLAIMS = JSON.stringify({ + id_token: { email: null, email_verified: null }, + userinfo: { + email: null, + email_verified: null, + picture: null, + preferred_username: null, + }, +}); + +const tokenSchema = z.object({ + access_token: z.string().min(1), + id_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const userInfoSchema = z.object({ + email: z.string().nullable().optional(), + email_verified: z.boolean().nullable().optional(), + picture: z.string().nullable().optional(), + preferred_username: z.string().nullable().optional(), + sub: z.string().min(1), +}); + +/** + * Sign in with Twitch: an SSO adapter for the API config's + * `authorization.ssoAdapters`, using OpenID Connect. Requests + * `openid user:read:email`, validates the ID token (signature, issuer, + * audience, expiry, nonce) and reads the email and avatar from Twitch's + * UserInfo endpoint. Redirects back to `login/sso/twitch` under + * `VITNODE_WEB_URL`. + * + * Twitch shares the email only once the account has verified it; without one + * a new member confirms an address in VitNode with a code. Supplies the avatar + * for profile sync. + * + * Server-only. + * + * @example + * ```ts + * import { TwitchSSOApiPlugin } from "@vitnode/core/api/adapters/sso/twitch"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * TwitchSSOApiPlugin({ + * clientId: process.env.TWITCH_CLIENT_ID, + * clientSecret: process.env.TWITCH_CLIENT_SECRET, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const TwitchSSOApiPlugin = ({ + clientId, + clientSecret, +}: { + /** The Client ID from the Twitch Developer Console. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The Client Secret. Needed to exchange the code. */ + clientSecret: string | undefined; +}): SSOApiPlugin => { + const id = "twitch"; + const redirectUri = getRedirectUri(id); + + return { + icon: TWITCH_ICON, + id, + name: "Twitch", + profileFields: ["avatar"], + getUrl: ({ nonce, state }) => { + const url = new URL(`${ISSUER}/authorize`); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "Twitch", "client ID"), + ); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "openid user:read:email"); + url.searchParams.set("claims", CLAIMS); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + + return url.toString(); + }, + fetchToken: async code => + await requestSsoProviderJson({ + init: formPost({ + client_id: requireSsoOption(clientId, "Twitch", "client ID"), + client_secret: requireSsoOption( + clientSecret, + "Twitch", + "client secret", + ), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + schema: tokenSchema, + step: "token", + url: `${ISSUER}/token`, + }), + fetchUser: async ({ access_token, id_token }, { nonce }) => { + const claims = await verifySsoIdToken({ + audience: requireSsoOption(clientId, "Twitch", "client ID"), + idToken: id_token, + issuer: ISSUER, + keys: remoteJwks(`${ISSUER}/keys`), + nonce, + }); + const info = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userInfoSchema, + step: "profile", + url: `${ISSUER}/userinfo`, + }); + if (info.sub !== claims.sub) { + throw ssoProviderFailure( + "Twitch UserInfo answered for another account", + ); + } + + const email = claimString(info.email); + + return { + avatarUrl: info.picture ?? null, + email, + emailVerified: email !== null && info.email_verified === true, + id: info.sub, + username: info.preferred_username ?? null, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/adapters/sso/twitter.ts b/packages/vitnode/src/api/adapters/sso/twitter.ts new file mode 100644 index 0000000000..44b7fb9cfc --- /dev/null +++ b/packages/vitnode/src/api/adapters/sso/twitter.ts @@ -0,0 +1,153 @@ +import { z } from "zod"; + +import type { SSOApiPlugin } from "@/api/models/sso"; + +import { + basicClientAuth, + bearerAuth, + claimString, + formPost, + requestSsoProviderJson, + requireSsoOption, +} from "@/api/lib/sso-protocol"; +import { getRedirectUri } from "@/api/models/sso"; + +import { X_ICON } from "./icons"; + +const tokenSchema = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), +}); + +const userSchema = z.object({ + data: z.object({ + confirmed_email: z.string().optional(), + id: z.string().regex(/^\d{1,32}$/), + name: z.string().optional(), + profile_image_url: z.string().optional(), + username: z.string(), + }), +}); + +/** + * Sign in with X (formerly Twitter): an SSO adapter for the API config's + * `authorization.ssoAdapters`, using OAuth 2.0 with PKCE. Requests + * `tweet.read users.read` (both needed to read the signed-in account) plus + * `users.email`, and redirects back to `login/sso/twitter` under + * `VITNODE_WEB_URL`. + * + * The email arrives only when the X app has "Request email from users" + * switched on and the account has a confirmed address; without one a new + * member confirms an email in VitNode with a code. Supplies the avatar for + * profile sync. The provider id stays `twitter`. + * + * Server-only. + * + * @example + * ```ts + * import { TwitterSSOApiPlugin } from "@vitnode/core/api/adapters/sso/twitter"; + * + * buildApiConfig({ + * authorization: { + * ssoAdapters: [ + * TwitterSSOApiPlugin({ + * clientId: process.env.TWITTER_CLIENT_ID, + * clientSecret: process.env.TWITTER_CLIENT_SECRET, + * }), + * ], + * }, + * // ... + * }); + * ``` + */ +export const TwitterSSOApiPlugin = ({ + clientId, + clientSecret, + requestEmail = true, +}: { + /** The OAuth 2.0 Client ID of a confidential ("Web App") X app. `undefined` still registers the button, but signing in throws until it is set. */ + clientId: string | undefined; + /** The OAuth 2.0 Client Secret. Needed to exchange the code. */ + clientSecret: string | undefined; + /** + * Ask for the `users.email` scope and the account's `confirmed_email`. + * Turn it off when the X app is not allowed to request email - X rejects the + * scope otherwise. Defaults to `true`. + */ + requestEmail?: boolean; +}): SSOApiPlugin => { + const id = "twitter"; + const redirectUri = getRedirectUri(id); + const scopes = [ + "tweet.read", + "users.read", + ...(requestEmail ? ["users.email"] : []), + ]; + const userFields = [ + "profile_image_url", + ...(requestEmail ? ["confirmed_email"] : []), + ]; + + return { + brandColor: "#000000", + icon: X_ICON, + id, + name: "X", + profileFields: ["avatar"], + getUrl: ({ codeChallenge, codeChallengeMethod, state }) => { + const url = new URL("https://x.com/i/oauth2/authorize"); + url.searchParams.set("response_type", "code"); + url.searchParams.set( + "client_id", + requireSsoOption(clientId, "X", "client ID"), + ); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", codeChallengeMethod); + + return url.toString(); + }, + fetchToken: async (code, { codeVerifier }) => + await requestSsoProviderJson({ + init: formPost( + { + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }, + basicClientAuth( + requireSsoOption(clientId, "X", "client ID"), + requireSsoOption(clientSecret, "X", "client secret"), + ), + ), + schema: tokenSchema, + step: "token", + url: "https://api.x.com/2/oauth2/token", + }), + fetchUser: async ({ access_token }) => { + const url = new URL("https://api.x.com/2/users/me"); + url.searchParams.set("user.fields", userFields.join(",")); + const { data } = await requestSsoProviderJson({ + init: { headers: bearerAuth(access_token) }, + schema: userSchema, + step: "profile", + url, + }); + const email = claimString(data.confirmed_email); + + return { + // `_normal` is a 48px thumbnail; `_400x400` is the same picture, larger. + avatarUrl: + data.profile_image_url?.replace(/_normal(\.\w+)$/, "_400x400$1") ?? + null, + email, + emailVerified: email !== null, + id: data.id, + username: claimString(data.name) ?? data.username, + }; + }, + }; +}; diff --git a/packages/vitnode/src/api/lib/sso-protocol.test.ts b/packages/vitnode/src/api/lib/sso-protocol.test.ts new file mode 100644 index 0000000000..2aa219cecf --- /dev/null +++ b/packages/vitnode/src/api/lib/sso-protocol.test.ts @@ -0,0 +1,218 @@ +import { HTTPException } from "hono/http-exception"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { z } from "zod"; + +import { + basicClientAuth, + pkceChallengeS256, + remoteJwks, + requestSsoProviderJson, + verifySsoIdToken, +} from "./sso-protocol"; + +const JWKS_URL = "https://issuer.test/keys"; + +let keys: Awaited>; +let otherKeys: Awaited>; +let jwk: Record; + +beforeAll(async () => { + keys = await generateKeyPair("RS256"); + otherKeys = await generateKeyPair("RS256"); + jwk = { ...(await exportJWK(keys.publicKey)), alg: "RS256", kid: "k1" }; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const idToken = async ({ + alg = "RS256", + audience = "client", + expiresIn = "5m", + issuer = "https://issuer.test", + key = keys.privateKey, + nonce = "nonce-1", +}: { + alg?: string; + audience?: string; + expiresIn?: number | string; + issuer?: string; + key?: CryptoKey; + /** `null` leaves the claim out. */ + nonce?: null | string; +} = {}) => + await new SignJWT(nonce === null ? {} : { nonce }) + .setProtectedHeader({ alg, kid: "k1" }) + .setSubject("user-1") + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime(expiresIn) + .sign(key); + +const verify = async (token: string, nonce = "nonce-1") => + await verifySsoIdToken({ + audience: "client", + idToken: token, + issuer: "https://issuer.test", + keys: remoteJwks(JWKS_URL), + nonce, + }); + +const statusOf = async (promise: Promise) => { + try { + await promise; + } catch (error) { + return error instanceof HTTPException ? error.status : "not-http"; + } + + return "resolved"; +}; + +describe("validating an ID token", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Promise.resolve(Response.json({ keys: [jwk] }, { status: 200 })), + ), + ); + }); + + it("accepts a token signed by the provider's key, for this client, with this nonce", async () => { + expect(await verify(await idToken())).toMatchObject({ + nonce: "nonce-1", + sub: "user-1", + }); + }); + + it.each([ + ["another key's signature", () => ({ key: otherKeys.privateKey })], + ["another issuer", () => ({ issuer: "https://evil.test" })], + ["another audience", () => ({ audience: "someone-else" })], + [ + "an expired token", + () => ({ expiresIn: Math.floor(Date.now() / 1000) - 120 }), + ], + ])("refuses %s", async (_, options) => { + expect(await statusOf(verify(await idToken(options())))).toBe(400); + }); + + it("refuses a nonce from another attempt, or none at all", async () => { + expect(await statusOf(verify(await idToken(), "nonce-2"))).toBe(400); + expect(await statusOf(verify(await idToken({ nonce: null })))).toBe(400); + }); + + it("refuses an algorithm the provider does not sign with", async () => { + const secret = new TextEncoder().encode("a".repeat(32)); + const token = await new SignJWT({ nonce: "nonce-1" }) + .setProtectedHeader({ alg: "HS256", kid: "k1" }) + .setSubject("user-1") + .setIssuer("https://issuer.test") + .setAudience("client") + .setIssuedAt() + .setExpirationTime("5m") + .sign(secret); + + expect(await statusOf(verify(token))).toBe(400); + }); + + it("checks an issuer that depends on the token, such as Microsoft's tenant", async () => { + const token = await idToken({ issuer: "https://login.test/tenant-a/v2.0" }); + const check = async (allowed: string) => + await verifySsoIdToken({ + audience: "client", + idToken: token, + issuer: payload => payload.iss === allowed, + keys: remoteJwks(JWKS_URL), + nonce: "nonce-1", + }); + + expect(await statusOf(check("https://login.test/tenant-a/v2.0"))).toBe( + "resolved", + ); + expect(await statusOf(check("https://login.test/tenant-b/v2.0"))).toBe(400); + }); + + it("refuses a missing ID token", async () => { + expect(await statusOf(verify(undefined as never))).toBe(400); + }); +}); + +describe("calling a provider", () => { + const schema = z.object({ access_token: z.string() }); + + it.each([ + [ + "an error status", + async () => Promise.resolve(new Response("{}", { status: 401 })), + ], + [ + "a body that is not JSON", + async () => Promise.resolve(new Response("")), + ], + [ + "a body of the wrong shape", + async () => Promise.resolve(Response.json({ error: "invalid_grant" })), + ], + [ + "a timeout", + async () => Promise.reject(new DOMException("timeout", "TimeoutError")), + ], + ])( + "fails with a 502 on %s, without echoing the provider", + async (_, answer) => { + vi.stubGlobal("fetch", vi.fn(answer)); + + const failure = requestSsoProviderJson({ + schema, + step: "token", + url: "https://provider.test/token", + }); + + await expect(failure).rejects.toThrow( + /^(Provider token request failed|Invalid provider token response)$/, + ); + expect(await statusOf(failure)).toBe(502); + }, + ); + + it("bounds every request with a timeout", async () => { + const fetch = vi.fn(async (_url: unknown, _init?: RequestInit) => + Promise.resolve(Response.json({ access_token: "a" })), + ); + vi.stubGlobal("fetch", fetch); + + await requestSsoProviderJson({ + schema, + step: "token", + url: "https://provider.test/token", + }); + + expect(fetch.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal); + }); +}); + +describe("client authentication and PKCE", () => { + it("encodes the S256 challenge from RFC 7636's example", () => { + expect( + pkceChallengeS256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + ).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); + + it("form-encodes the client credentials of HTTP Basic", () => { + expect(basicClientAuth("id:1", "s&cret")).toEqual({ + Authorization: `Basic ${Buffer.from("id%3A1:s%26cret").toString("base64")}`, + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/sso-protocol.ts b/packages/vitnode/src/api/lib/sso-protocol.ts new file mode 100644 index 0000000000..6ff64e7d69 --- /dev/null +++ b/packages/vitnode/src/api/lib/sso-protocol.ts @@ -0,0 +1,238 @@ +import type { z } from "zod"; + +import { HTTPException } from "hono/http-exception"; +import { + createRemoteJWKSet, + errors as joseErrors, + type JWTPayload, + jwtVerify, + type JWTVerifyGetKey, +} from "jose"; +import crypto from "node:crypto"; + +/** How long core waits for a provider before failing the sign-in. */ +export const SSO_PROVIDER_TIMEOUT_MS = 10_000; + +/** A random, URL-safe secret: a PKCE verifier, a nonce or a state. */ +export const randomSsoSecret = (bytes = 32): string => + crypto.randomBytes(bytes).toString("base64url"); + +/** The `S256` PKCE challenge of a verifier (RFC 7636). */ +export const pkceChallengeS256 = (verifier: string): string => + crypto.createHash("sha256").update(verifier).digest("base64url"); + +/** SHA-256 of a secret as hex, for storing a lookup key instead of the secret. */ +export const hashSsoSecret = (secret: string): string => + crypto.createHash("sha256").update(secret).digest("hex"); + +/** Compares two strings without leaking where they differ. */ +export const timingSafeEqualText = (a: string, b: string): boolean => { + const left = Buffer.from(a); + const right = Buffer.from(b); + + return left.length === right.length && crypto.timingSafeEqual(left, right); +}; + +/** A `502` for a provider that answered, but not as it should. */ +export const ssoProviderFailure = (message: string) => + new HTTPException(502, { message }); + +/** + * Calls a provider endpoint and validates its JSON answer with `schema`. + * Bounded by {@link SSO_PROVIDER_TIMEOUT_MS}. A timeout, a non-2xx status, a + * body that is not JSON or one that fails the schema all throw a `502` + * `HTTPException` whose message never echoes the provider's body. + * + * Server-only. + */ +export const requestSsoProviderJson = async ({ + init, + schema, + step, + url, +}: { + init?: RequestInit; + schema: z.ZodType; + /** What the request is for, used in the error message (e.g. `"token"`). */ + step: string; + url: string | URL; +}): Promise => { + let response: Response; + try { + response = await fetch(url, { + ...init, + headers: { Accept: "application/json", ...init?.headers }, + redirect: "error", + signal: AbortSignal.timeout(SSO_PROVIDER_TIMEOUT_MS), + }); + } catch { + throw ssoProviderFailure(`Provider ${step} request failed`); + } + + if (!response.ok) { + throw ssoProviderFailure(`Provider ${step} request failed`); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw ssoProviderFailure(`Invalid provider ${step} response`); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + throw ssoProviderFailure(`Invalid provider ${step} response`); + } + + return parsed.data; +}; + +/** Form-encoded POST options for a token endpoint. */ +export const formPost = ( + body: Record, + headers: Record = {}, +): RequestInit => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(body)) { + if (value !== undefined) params.set(key, value); + } + + return { + body: params, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...headers, + }, + method: "POST", + }; +}; + +/** An HTTP Basic `Authorization` header for a client ID and secret (RFC 6749 §2.3.1). */ +export const basicClientAuth = ( + clientId: string, + clientSecret: string, +): Record => ({ + Authorization: `Basic ${Buffer.from( + `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`, + ).toString("base64")}`, +}); + +/** An OAuth bearer `Authorization` header (RFC 6750). */ +export const bearerAuth = (accessToken: string): Record => ({ + Authorization: `Bearer ${accessToken}`, +}); + +const jwksSets = new Map(); + +/** + * The provider's signing keys, fetched lazily and cached per URL by `jose` + * (refreshed when an unknown key ID shows up). + */ +export const remoteJwks = (url: string): JWTVerifyGetKey => { + let set = jwksSets.get(url); + if (!set) { + set = createRemoteJWKSet(new URL(url), { + timeoutDuration: SSO_PROVIDER_TIMEOUT_MS, + }); + jwksSets.set(url, set); + } + + return set; +}; + +/** + * Validates an OpenID Connect ID token with `jose`: the signature against the + * provider's keys, the allowed algorithms, `iss`, `aud`, `exp`/`iat` (with a + * minute of clock tolerance) and, when one was sent, the `nonce`. Any failure + * throws a `400` `HTTPException`. + * + * `issuer` is either the exact value or a predicate, for providers such as + * Microsoft whose issuer embeds the account's tenant. + * + * Server-only. + */ +export const verifySsoIdToken = async ({ + algorithms = ["RS256"], + audience, + idToken, + issuer, + keys, + nonce, +}: { + algorithms?: string[]; + audience: string; + idToken: unknown; + issuer: ((payload: JWTPayload) => boolean) | string; + keys: JWTVerifyGetKey | Parameters[1]; + /** The nonce sent with the authorization request. Required in the token when set. */ + nonce?: string; +}): Promise => { + if (typeof idToken !== "string" || idToken.length === 0) { + throw new HTTPException(400, { message: "Missing ID token" }); + } + + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(idToken, keys as JWTVerifyGetKey, { + algorithms, + audience, + clockTolerance: 60, + issuer: typeof issuer === "string" ? issuer : undefined, + requiredClaims: ["exp", "iat", "sub"], + })); + } catch (error) { + if ( + error instanceof joseErrors.JWKSTimeout || + (error instanceof joseErrors.JOSEError && + error.code === "ERR_JOSE_GENERIC") + ) { + throw ssoProviderFailure("Provider keys request failed"); + } + + throw new HTTPException(400, { message: "Invalid ID token" }); + } + + if (typeof issuer === "function" && !issuer(payload)) { + throw new HTTPException(400, { message: "Invalid ID token issuer" }); + } + + if ( + nonce !== undefined && + !( + typeof payload.nonce === "string" && + timingSafeEqualText(payload.nonce, nonce) + ) + ) { + throw new HTTPException(400, { message: "Invalid ID token nonce" }); + } + + return payload; +}; + +/** `true`/`"true"` as `true`, `false`/`"false"` as `false`, anything else as `undefined`. Apple sends booleans as strings. */ +export const claimBoolean = (value: unknown): boolean | undefined => { + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + + return undefined; +}; + +/** A non-empty string claim, or `null`. */ +export const claimString = (value: unknown): null | string => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + +/** + * Reads a configuration value that must be present to sign in. Throws a plain + * `Error` naming the missing option - the button still renders, and the error + * reaches the server log instead of the visitor. + */ +export const requireSsoOption = ( + value: string | undefined, + provider: string, + option: string, +): string => { + if (!value) throw new Error(`Missing ${provider} ${option}`); + + return value; +}; diff --git a/packages/vitnode/src/api/models/session.ts b/packages/vitnode/src/api/models/session.ts index 7bead5f734..8b4bebe985 100644 --- a/packages/vitnode/src/api/models/session.ts +++ b/packages/vitnode/src/api/models/session.ts @@ -93,6 +93,24 @@ export class SessionModel { deleteAuthCookie(this.c, this.c.get("core").authorization.cookieName); } + /** When this browser's session was created - its last sign-in. `null` without a session. */ + async getSessionCreatedAt(): Promise { + const token = getCookie( + this.c, + this.c.get("core").authorization.cookieName, + ); + if (!token) return null; + + const [row] = await this.c + .get("db") + .select({ createdAt: core_sessions.createdAt }) + .from(core_sessions) + .where(eq(core_sessions.token, await hashSessionToken(token))) + .limit(1); + + return row?.createdAt ?? null; + } + async getUser() { const token = getCookie( this.c, diff --git a/packages/vitnode/src/api/models/sso-connection-store.ts b/packages/vitnode/src/api/models/sso-connection-store.ts index 06ed928d2d..2d9e0af77f 100644 --- a/packages/vitnode/src/api/models/sso-connection-store.ts +++ b/packages/vitnode/src/api/models/sso-connection-store.ts @@ -39,9 +39,13 @@ export interface SsoProfileValues { } export interface SsoOperationRecord { + /** The PKCE verifier of the provider round trip this operation started. */ + codeVerifier?: null | string; expiresAt: Date; fields: SsoProfileField[]; intent: SsoOperationIntent; + /** The OpenID Connect nonce of that round trip. */ + nonce?: null | string; preview: null | SsoProfileValues; providerAccountId: null | string; providerId: string; @@ -149,9 +153,11 @@ type Db = Context["var"]["db"]; const toOperation = ( row: typeof core_users_sso_operations.$inferSelect, ): SsoOperationRecord => ({ + codeVerifier: row.codeVerifier, expiresAt: row.expiresAt, fields: row.fields, intent: row.intent, + nonce: row.nonce, preview: row.preview, providerAccountId: row.providerAccountId, providerId: row.providerId, @@ -418,9 +424,17 @@ export const drizzleSsoConnectionStore = (db: Db): SsoConnectionStore => ({ providerUsername, userId, }) => { + // A provider that leaves the email or username out this time has not + // taken it back - keep what the connection already shows. + const changes = { + ...(providerEmail === null ? {} : { providerEmail }), + ...(providerUsername === null ? {} : { providerUsername }), + }; + if (Object.keys(changes).length === 0) return; + await db .update(core_users_sso) - .set({ providerEmail, providerUsername }) + .set(changes) .where( and( eq(core_users_sso.userId, userId), diff --git a/packages/vitnode/src/api/models/sso-connection.ts b/packages/vitnode/src/api/models/sso-connection.ts index 5bb947d248..a908b3ba3f 100644 --- a/packages/vitnode/src/api/models/sso-connection.ts +++ b/packages/vitnode/src/api/models/sso-connection.ts @@ -18,6 +18,7 @@ import { restrictProfile, supportedProfileFields, } from "@/api/lib/sso-profile"; +import { pkceChallengeS256, randomSsoSecret } from "@/api/lib/sso-protocol"; import { resolveUserImagePolicy } from "@/api/lib/user-images"; import { CONFIG } from "@/lib/config"; import { normalizeEmailAddress } from "@/lib/email-canonical"; @@ -27,7 +28,7 @@ import { ssoConnectionIntentOfState, } from "@/lib/sso-profile"; -import type { SSOApiPlugin } from "./sso"; +import type { SSOApiPlugin, SSOCallbackContext } from "./sso"; import type { SsoAccountProfile, SsoConnectionRecord, @@ -36,11 +37,19 @@ import type { SsoSignInFacts, } from "./sso-connection-store"; +import { SessionModel } from "./session"; import { invalidateSessionCacheForUser } from "./session-revoke"; import { drizzleSsoConnectionStore } from "./sso-connection-store"; export const SSO_CONNECTION_STATE_TTL_MS = 10 * 60_000; +/** + * How recently the member must have signed in to connect a new account. A + * stolen session cookie should not be enough to add a sign-in method the + * thief controls. + */ +export const SSO_CONNECTION_RECENT_AUTH_MS = 60 * 60_000; + const SSO_IMPORT_PREVIEW_TTL_MS = 10 * 60_000; export type SsoConnectionErrorCode = @@ -55,9 +64,10 @@ export type SsoConnectionErrorCode = | "preview_not_found" | "provider_already_connected" | "provider_error" - | "provider_not_found"; + | "provider_not_found" + | "reauthentication_required"; -type SsoConnectionErrorStatus = 400 | 404 | 409 | 502; +type SsoConnectionErrorStatus = 400 | 403 | 404 | 409 | 502; export class SsoConnectionError extends Error { constructor(code: SsoConnectionErrorCode, status: SsoConnectionErrorStatus) { @@ -271,10 +281,14 @@ export class SsoConnectionModel { private async fetchIdentity( adapter: SSOApiPlugin, code: string, + context: SSOCallbackContext, ): Promise { let user: Awaited>; try { - user = await adapter.fetchUser(await adapter.fetchToken(code)); + user = await adapter.fetchUser( + await adapter.fetchToken(code, context), + context, + ); } catch (error) { this.warn("provider request", error); throw new SsoConnectionError("provider_error", 502); @@ -435,6 +449,9 @@ export class SsoConnectionModel { if (connection) { throw new SsoConnectionError("provider_already_connected", 409); } + if (!(await this.recentlyAuthenticated(now))) { + throw new SsoConnectionError("reauthentication_required", 403); + } } else if (intent === "sync") { if (!connection) throw new SsoConnectionError("not_connected", 404); @@ -456,10 +473,14 @@ export class SsoConnectionModel { } const state = randomState(intent); + const codeVerifier = randomSsoSecret(48); + const nonce = randomSsoSecret(); await this.store.saveOperation({ + codeVerifier, expiresAt: new Date(now.getTime() + SSO_CONNECTION_STATE_TTL_MS), fields: bound, intent, + nonce, preview: null, providerAccountId: connection?.providerAccountId ?? null, providerId, @@ -467,16 +488,25 @@ export class SsoConnectionModel { userId, }); - return { url: adapter.getUrl({ state }) }; + return { + url: adapter.getUrl({ + codeChallenge: pkceChallengeS256(codeVerifier), + codeChallengeMethod: "S256", + nonce, + state, + }), + }; } async callback({ code, + params = {}, providerId, state, user, }: { code: string; + params?: Readonly>; providerId: string; state: string; user: { email: string; id: number }; @@ -503,7 +533,14 @@ export class SsoConnectionModel { } const adapter = this.adapter(providerId); - const identity = await this.fetchIdentity(adapter, code); + if (!(operation.codeVerifier && operation.nonce)) { + throw new SsoConnectionError("invalid_state", 400); + } + const identity = await this.fetchIdentity(adapter, code, { + codeVerifier: operation.codeVerifier, + nonce: operation.nonce, + params, + }); if (intent === "link") { const outcome = await this.store.link({ @@ -752,6 +789,19 @@ export class SsoConnectionModel { }; } + /** + * Whether this browser's session began within + * {@link SSO_CONNECTION_RECENT_AUTH_MS}. + */ + async recentlyAuthenticated(now: Date): Promise { + const signedInAt = await new SessionModel(this.c).getSessionCreatedAt(); + + return ( + signedInAt !== null && + now.getTime() - signedInAt.getTime() <= SSO_CONNECTION_RECENT_AUTH_MS + ); + } + async refreshSessions(userId: number): Promise { await invalidateSessionCacheForUser(this.c, userId); } @@ -815,7 +865,7 @@ export const ssoIdentityOf = ( return { email: email ? normalizeEmailAddress(email) : null, - id: user.id, + id: boundedText(user.id) ?? "", profile: normalizeSsoProfile(adapter, user), username: boundedText(user.username), }; diff --git a/packages/vitnode/src/api/models/sso-email-completion.ts b/packages/vitnode/src/api/models/sso-email-completion.ts new file mode 100644 index 0000000000..a855affbfc --- /dev/null +++ b/packages/vitnode/src/api/models/sso-email-completion.ts @@ -0,0 +1,188 @@ +import type { Context } from "hono"; + +import { HTTPException } from "hono/http-exception"; +import crypto from "node:crypto"; +import { createTranslator } from "use-intl"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; + +import { hashSsoSecret, timingSafeEqualText } from "@/api/lib/sso-protocol"; +import SsoEmailCodeTemplate from "@/emails/sso-email-code"; +import { normalizeEmailAddress } from "@/lib/email-canonical"; + +import type { SSOCallbackOutcome } from "./sso"; + +import { SSOModel } from "./sso"; +import { SsoConnectionModel } from "./sso-connection"; + +export const SSO_EMAIL_CODE_TTL_MS = 10 * 60_000; +export const SSO_EMAIL_CODE_MAX_ATTEMPTS = 5; +export const SSO_EMAIL_CODE_MAX_SENDS = 5; +export const SSO_EMAIL_CODE_COOLDOWN_MS = 30_000; + +export type SsoEmailCompletionError = + | "code_expired" + | "invalid_code" + | "invalid_pending" + | "too_many_codes"; + +const failure = (status: 400 | 429, error: SsoEmailCompletionError) => + new HTTPException(status, { message: error }); + +const codeHashOf = (tokenHash: string, code: string) => + hashSsoSecret(`${tokenHash}:${code}`); + +/** + * The last step for a provider identity that brought no trustworthy email: + * the visitor names an address, proves it with an emailed six-digit code in + * the same browser, and only then is an account created - or the existing + * account's password asked for before linking. Server-only. + */ +export class SsoEmailCompletionModel { + constructor(c: Context) { + this.c = c; + this.sso = new SSOModel(c); + } + + private readonly c: Context; + private readonly sso: SSOModel; + + private pendingTokenHash(): string { + const token = this.sso.pendingToken; + if (!token) throw failure(400, "invalid_pending"); + + return hashSsoSecret(token); + } + + /** + * Emails a fresh code to `email` for this browser's pending identity. Says + * nothing about whether the address has an account. + * + * @throws `HTTPException(400)` without a live pending identity for + * `providerId`, `429` past five codes or within 30 seconds of the last one. + */ + async sendCode({ + email, + providerId, + }: { + email: string; + providerId: string; + }): Promise { + const provider = this.c + .get("core") + .authorization.ssoAdapters.find(one => one.id === providerId); + const tokenHash = this.pendingTokenHash(); + const now = new Date(); + const pending = await this.sso.flowStore.findPending({ now, tokenHash }); + if (!(pending && provider && pending.providerId === providerId)) { + throw failure(400, "invalid_pending"); + } + + const address = normalizeEmailAddress(email); + const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0"); + const stored = await this.sso.flowStore.setPendingCode({ + codeExpiresAt: new Date(now.getTime() + SSO_EMAIL_CODE_TTL_MS), + codeHash: codeHashOf(tokenHash, code), + cooldownMs: SSO_EMAIL_CODE_COOLDOWN_MS, + email: address, + maxSends: SSO_EMAIL_CODE_MAX_SENDS, + now, + tokenHash, + }); + if (!stored) throw failure(429, "too_many_codes"); + + await this.c.get("email").send({ + content: props => + SsoEmailCodeTemplate({ + ...props, + code, + expiryMinutes: SSO_EMAIL_CODE_TTL_MS / 60_000, + provider: provider.name, + userIpAddress: this.c.get("ipAddress"), + }), + locale: this.c.get("i18n").resolveLocale(), + subject: ({ i18n }) => + createTranslator(i18n)("core.auth.sso_email_code.email.subject", { + code, + }), + to: address, + }); + } + + /** + * Checks the code and, when it matches, consumes the pending identity once: + * signs in the member it is linked to by now, signs up a new member with the + * confirmed address, or returns the link offer for the account that already + * owns it. + * + * @throws `HTTPException(400)` for a wrong, expired or spent code, or with + * no pending identity. + */ + async verifyCode({ + code, + providerId, + }: { + code: string; + providerId: string; + }): Promise< + Extract + > { + const tokenHash = this.pendingTokenHash(); + const now = new Date(); + const counted = await this.sso.flowStore.countCodeAttempt({ + maxAttempts: SSO_EMAIL_CODE_MAX_ATTEMPTS, + now, + tokenHash, + }); + if (!counted?.codeHash || !counted.email) { + throw failure(400, "code_expired"); + } + if (counted.providerId !== providerId) { + throw failure(400, "invalid_pending"); + } + if (!timingSafeEqualText(counted.codeHash, codeHashOf(tokenHash, code))) { + throw failure(400, "invalid_code"); + } + + const pending = await this.sso.flowStore.consumePending({ + now, + tokenHash, + }); + this.sso.clearPendingCookie(); + if (!pending?.email) throw failure(400, "code_expired"); + + const provider = this.c + .get("core") + .authorization.ssoAdapters.find(one => one.id === providerId); + if (!provider) throw new HTTPException(404); + + const identity = { + email: null, + id: pending.providerAccountId, + profile: pending.profile ?? { + avatarUrl: null, + firstName: null, + lastName: null, + }, + username: pending.providerUsername, + }; + + const linked = await this.sso.findLinkedUser(providerId, identity.id); + if (linked) { + await new SsoConnectionModel(this.c).afterSignIn({ + identity, + providerId, + userId: linked.userId, + }); + + return { kind: "signed_in", userId: linked.userId }; + } + + return await this.sso.signUpOrOfferLink({ + email: pending.email, + identity, + provider, + providerEmail: null, + }); + } +} diff --git a/packages/vitnode/src/api/models/sso-flow-store.ts b/packages/vitnode/src/api/models/sso-flow-store.ts new file mode 100644 index 0000000000..4a176f1b37 --- /dev/null +++ b/packages/vitnode/src/api/models/sso-flow-store.ts @@ -0,0 +1,213 @@ +import type { Context } from "hono"; + +import { and, eq, gt, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"; + +import { core_sso_attempts, core_sso_pending } from "@/database/users"; + +import type { SsoProfileValues } from "./sso-connection-store"; + +export interface SsoAttemptRecord { + bindingHash: string; + codeVerifier: string; + expiresAt: Date; + nonce: string; + providerId: string; + stateHash: string; +} + +export interface SsoPendingRecord { + codeAttempts: number; + codeExpiresAt: Date | null; + codeHash: null | string; + codeSends: number; + codeSentAt: Date | null; + email: null | string; + expiresAt: Date; + profile: null | SsoProfileValues; + providerAccountId: string; + providerId: string; + providerUsername: null | string; + tokenHash: string; +} + +/** + * Server-side state of a sign-in round trip and of an identity waiting for a + * confirmed email. Every `consume*`/`take*` is a single atomic delete, so a + * record works once even under concurrent requests. + */ +export interface SsoFlowStore { + consumeAttempt: (stateHash: string) => Promise; + consumePending: (args: { + now: Date; + tokenHash: string; + }) => Promise; + /** Counts one code guess, if the code is live and `maxAttempts` is not spent. */ + countCodeAttempt: (args: { + maxAttempts: number; + now: Date; + tokenHash: string; + }) => Promise; + findPending: (args: { + now: Date; + tokenHash: string; + }) => Promise; + saveAttempt: (record: SsoAttemptRecord, now: Date) => Promise; + savePending: ( + record: Omit< + SsoPendingRecord, + | "codeAttempts" + | "codeExpiresAt" + | "codeHash" + | "codeSends" + | "codeSentAt" + | "email" + >, + now: Date, + ) => Promise; + /** Stores a fresh code for `email`, unless the send cap or cooldown says no. */ + setPendingCode: (args: { + codeExpiresAt: Date; + codeHash: string; + cooldownMs: number; + email: string; + maxSends: number; + now: Date; + tokenHash: string; + }) => Promise; +} + +type Db = Context["var"]["db"]; + +const toPending = ( + row: typeof core_sso_pending.$inferSelect, +): SsoPendingRecord => ({ + codeAttempts: row.codeAttempts, + codeExpiresAt: row.codeExpiresAt, + codeHash: row.codeHash, + codeSends: row.codeSends, + codeSentAt: row.codeSentAt, + email: row.email, + expiresAt: row.expiresAt, + profile: row.profile ?? null, + providerAccountId: row.providerAccountId, + providerId: row.providerId, + providerUsername: row.providerUsername, + tokenHash: row.tokenHash, +}); + +export const drizzleSsoFlowStore = (db: Db): SsoFlowStore => ({ + consumeAttempt: async stateHash => { + const [row] = await db + .delete(core_sso_attempts) + .where(eq(core_sso_attempts.stateHash, stateHash)) + .returning(); + + return row + ? { + bindingHash: row.bindingHash, + codeVerifier: row.codeVerifier, + expiresAt: row.expiresAt, + nonce: row.nonce, + providerId: row.providerId, + stateHash: row.stateHash, + } + : null; + }, + + consumePending: async ({ now, tokenHash }) => { + const [row] = await db + .delete(core_sso_pending) + .where( + and( + eq(core_sso_pending.tokenHash, tokenHash), + gt(core_sso_pending.expiresAt, now), + ), + ) + .returning(); + + return row ? toPending(row) : null; + }, + + countCodeAttempt: async ({ maxAttempts, now, tokenHash }) => { + const [row] = await db + .update(core_sso_pending) + .set({ codeAttempts: sql`${core_sso_pending.codeAttempts} + 1` }) + .where( + and( + eq(core_sso_pending.tokenHash, tokenHash), + gt(core_sso_pending.expiresAt, now), + isNotNull(core_sso_pending.codeHash), + gt(core_sso_pending.codeExpiresAt, now), + lt(core_sso_pending.codeAttempts, maxAttempts), + ), + ) + .returning(); + + return row ? toPending(row) : null; + }, + + findPending: async ({ now, tokenHash }) => { + const [row] = await db + .select() + .from(core_sso_pending) + .where( + and( + eq(core_sso_pending.tokenHash, tokenHash), + gt(core_sso_pending.expiresAt, now), + ), + ) + .limit(1); + + return row ? toPending(row) : null; + }, + + saveAttempt: async (record, now) => { + await db + .delete(core_sso_attempts) + .where(lte(core_sso_attempts.expiresAt, now)); + await db.insert(core_sso_attempts).values(record); + }, + + savePending: async (record, now) => { + await db + .delete(core_sso_pending) + .where(lte(core_sso_pending.expiresAt, now)); + await db.insert(core_sso_pending).values(record); + }, + + setPendingCode: async ({ + codeExpiresAt, + codeHash, + cooldownMs, + email, + maxSends, + now, + tokenHash, + }) => { + const cooledDown = new Date(now.getTime() - cooldownMs); + const updated = await db + .update(core_sso_pending) + .set({ + codeAttempts: 0, + codeExpiresAt, + codeHash, + codeSends: sql`${core_sso_pending.codeSends} + 1`, + codeSentAt: now, + email, + }) + .where( + and( + eq(core_sso_pending.tokenHash, tokenHash), + gt(core_sso_pending.expiresAt, now), + lt(core_sso_pending.codeSends, maxSends), + or( + isNull(core_sso_pending.codeSentAt), + lte(core_sso_pending.codeSentAt, cooledDown), + ), + ), + ) + .returning({ id: core_sso_pending.id }); + + return updated.length > 0; + }, +}); diff --git a/packages/vitnode/src/api/models/sso-link-token.test.ts b/packages/vitnode/src/api/models/sso-link-token.test.ts index 71ffed8a3c..4e88f4a0c1 100644 --- a/packages/vitnode/src/api/models/sso-link-token.test.ts +++ b/packages/vitnode/src/api/models/sso-link-token.test.ts @@ -11,6 +11,7 @@ const SECRET = "test-secret"; const offer = { email: "jan@example.com", providerAccountId: "fb-123", + providerEmail: "jan@example.com", providerId: "facebook", userId: 42, }; @@ -36,6 +37,23 @@ describe("an SSO link token", () => { ).toEqual(offer); }); + it("keeps an address confirmed by code apart from the provider's own", () => { + const { token } = createSsoLinkToken({ + now, + offer: { ...offer, providerEmail: null }, + secret: SECRET, + }); + + expect( + verifySsoLinkToken({ + now, + providerId: "facebook", + secret: SECRET, + token, + }), + ).toEqual({ ...offer, providerEmail: null }); + }); + it("is only good for the provider that produced it", () => { const { token } = createSsoLinkToken({ now, offer, secret: SECRET }); diff --git a/packages/vitnode/src/api/models/sso-link-token.ts b/packages/vitnode/src/api/models/sso-link-token.ts index 6e430bd37f..143b0694a8 100644 --- a/packages/vitnode/src/api/models/sso-link-token.ts +++ b/packages/vitnode/src/api/models/sso-link-token.ts @@ -11,13 +11,17 @@ const zodSsoLinkTokenPayload = z.object({ e: z.string().min(3), exp: z.number().int().positive(), p: z.string().min(1), + pe: z.string().nullable().optional(), s: z.string().min(1), u: z.number().int().positive(), }); export interface SsoLinkOffer { + /** The address the visitor proved they own - from the provider, or confirmed with a code. */ email: string; providerAccountId: string; + /** The address the provider itself shared, stored on the connection. `null` when it shared none. Defaults to `email`. */ + providerEmail?: null | string; providerId: string; userId: number; } @@ -40,6 +44,10 @@ export const createSsoLinkToken = ({ e: offer.email, exp: Math.floor(expiresAt.getTime() / 1000), p: offer.providerId, + ...(offer.providerEmail === undefined || + offer.providerEmail === offer.email + ? {} + : { pe: offer.providerEmail }), s: offer.providerAccountId, u: offer.userId, }), @@ -66,6 +74,7 @@ export const verifySsoLinkToken = ({ return { email: payload.e, providerAccountId: payload.s, + providerEmail: payload.pe === undefined ? payload.e : payload.pe, providerId: payload.p, userId: payload.u, }; diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts index 05db33cec8..f3b44a80bc 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -3,7 +3,6 @@ import type { Context } from "hono"; import { and, eq } from "drizzle-orm"; import { getCookie } from "hono/cookie"; import { HTTPException } from "hono/http-exception"; -import crypto from "node:crypto"; import type { EnvVitNode } from "@/api/middlewares/global.middleware"; import type { SsoProfileField } from "@/lib/sso-profile"; @@ -13,6 +12,12 @@ import { isUniqueViolation } from "@/api/lib/db-errors"; import { isPasswordSignInEnabled } from "@/api/lib/password-sign-in"; import { ensureServerSecret } from "@/api/lib/server-secret"; import { ssoConfirmsEmail } from "@/api/lib/sso-email-confirmation"; +import { + hashSsoSecret, + pkceChallengeS256, + randomSsoSecret, + timingSafeEqualText, +} from "@/api/lib/sso-protocol"; import { emailAliases, matchesEmail, @@ -20,71 +25,131 @@ import { } from "@/api/lib/user-email-lookup"; import { core_users, core_users_sso } from "@/database/users"; import { CONFIG } from "@/lib/config"; -import { normalizeEmailAddress } from "@/lib/email-canonical"; import { removeSpecialCharacters } from "@/lib/special-characters"; +import type { SsoFlowStore } from "./sso-flow-store"; + +import { isEmailVerificationRequired } from "./email-verification"; import { PasswordModel } from "./password"; -import { SsoConnectionModel, ssoIdentityOf } from "./sso-connection"; +import { + SsoConnectionModel, + ssoIdentityOf, + type SsoProviderIdentity, +} from "./sso-connection"; +import { drizzleSsoFlowStore } from "./sso-flow-store"; import { createSsoLinkToken, SSO_LINK_SECRET_NAME, + type SsoLinkOffer, verifySsoLinkToken, } from "./sso-link-token"; import { UserModel } from "./user"; /** The account an SSO provider returned, as an adapter's `fetchUser` maps it. */ export interface SSOProviderUser { - /** Profile picture URL, for profile sync. */ + /** Profile picture URL (HTTPS), for profile sync. `null` when the provider has none. */ avatarUrl?: null | string; - /** The account's email. Matched against existing VitNode accounts on first sign-in. */ - email: string; /** - * Whether the provider confirmed the user owns `email`. Core keys accounts on - * the email, so `false` refuses the sign-in (`400`) instead of creating, - * linking or verifying an account with it. Leave it unset only when the - * provider never returns an unconfirmed address. + * The account's email, or `null` when the provider shares none. Only an + * address core can trust (see `emailVerified`) is matched against existing + * accounts or stored as verified; anything else sends a new visitor through + * email confirmation instead. + */ + email?: null | string; + /** + * Whether the provider confirmed the user owns `email`. `false` means core + * never trusts the address: a returning member still signs in by `id`, and a + * new one confirms an address of their own with a code. Leave it unset only + * when the provider never returns an unconfirmed address. */ emailVerified?: boolean; firstName?: null | string; - /** The provider's stable account id, stored to recognise the account next time. */ + /** The provider's stable account id, stored to recognise the account next time. Up to 255 characters. */ id: string; lastName?: null | string; - /** Becomes the new user's name (special characters removed) on sign-up. */ - username: string; + /** Becomes the new user's name (special characters removed) on sign-up. A generic name is used when `null`. */ + username?: null | string; +} + +/** + * What a provider's token endpoint answered. Adapters may keep any extra + * field they need later in `fetchUser` (an `id_token`, TikTok's `open_id`...). + */ +export interface SSOTokenResponse { + [field: string]: unknown; + access_token: string; + /** The OpenID Connect ID token, when the provider issued one. */ + id_token?: string; + token_type: string; +} + +/** What core hands `getUrl` for one sign-in attempt. */ +export interface SSOAuthorizationRequest { + /** The PKCE `S256` challenge of {@link SSOCallbackContext.codeVerifier}. Send it when the provider supports PKCE. */ + codeChallenge: string; + codeChallengeMethod: "S256"; + /** A fresh OpenID Connect nonce. Send it when the provider echoes it in the ID token. */ + nonce: string; + /** Pass through unchanged. */ + state: string; } /** - * An SSO (OAuth) provider adapter, listed in the API config's + * The attempt's secrets, given back to `fetchToken` and `fetchUser`. They are + * stored server-side between the two legs and never reach the browser. + */ +export interface SSOCallbackContext { + /** The PKCE verifier matching the challenge `getUrl` received. */ + codeVerifier: string; + /** The nonce `getUrl` received; check it against the ID token's `nonce`. */ + nonce: string; + /** + * Extra parameters the provider sent to the callback besides `code` and + * `state` - Apple's `user` field on a first authorization, for instance. + * Untrusted input: validate before use. + */ + params: Readonly>; +} + +/** + * An SSO (OAuth / OpenID Connect) provider adapter, listed in the API config's * `authorization.ssoAdapters`. Core's built-in adapters live in * `@vitnode/core/api/adapters/sso/*`; a custom provider implements this shape. * - * Sign-in flow: `getUrl` sends the browser to the provider, which redirects to - * {@link getRedirectUri} with a `code`; core verifies the `state` cookie, then - * calls `fetchToken` and `fetchUser`. An unknown account signs up, a known one - * signs in, and an email that matches an existing account asks for that - * account's password before linking. Server-only. + * Sign-in flow: `getUrl` sends the browser to the provider with a state, + * PKCE challenge and nonce that core stores server-side, single use, for ten + * minutes, bound to the browser and the provider. The provider redirects to + * {@link getRedirectUri}; core consumes the attempt, then calls `fetchToken` + * and `fetchUser`. A known identity signs in by its `id`, whatever the email + * says. An unknown one with a trusted email signs up, or - when that email + * belongs to an account - links after that account's password. An unknown one + * without a trusted email confirms an address with an emailed code first. + * + * Server-only. */ export interface SSOApiPlugin { /** The sign-in button's background color, e.g. `"#5865f2"`; the text color is picked for contrast. */ brandColor?: string; - /** Exchanges the authorization code for an access token. Throw an `HTTPException` to fail the sign-in. */ + /** Exchanges the authorization code for tokens. Throw an `HTTPException` to fail the sign-in. */ fetchToken: ( code: string, - ) => Promise<{ access_token: string; token_type: string }>; + context: SSOCallbackContext, + ) => Promise; /** - * Reads the provider account with the token. Report an address the provider - * has not confirmed with `emailVerified: false` (or throw an `HTTPException` - * yourself) - an unconfirmed email must never reach core as a trusted one. + * Reads the provider account with the tokens. Validate an ID token here when + * the provider issues one. Report an address the provider has not confirmed + * with `emailVerified: false` - an unconfirmed email must never reach core + * as a trusted one. */ - fetchUser: (args: { - access_token: string; - token_type: string; - }) => Promise; + fetchUser: ( + token: SSOTokenResponse, + context: SSOCallbackContext, + ) => Promise; /** The provider's authorization URL. Must pass `state` through unchanged and use {@link getRedirectUri} as the redirect URI. */ - getUrl: (props: { state: string }) => string; + getUrl: (request: SSOAuthorizationRequest) => string; /** The button's icon, as SVG markup. */ icon?: string; - /** Unique provider id, used in URLs (`login/sso/{id}`) and to store linked accounts. */ + /** Unique provider id, used in URLs (`login/sso/{id}`) and to store linked accounts. Letters, digits, `_` and `-`. */ id: string; /** The provider's display name, e.g. `"Google"`. */ name: string; @@ -93,12 +158,14 @@ export interface SSOApiPlugin { } export type SSOCallbackOutcome = + | { email: null | string; kind: "email_required" } | { email: string; hasPassword: boolean; kind: "link_required"; linkToken: string; } + | { kind: "email_unavailable" } | { kind: "signed_in"; userId: number }; /** @@ -109,6 +176,26 @@ export type SSOCallbackOutcome = export const getRedirectUri = (code: string) => new URL(`${CONFIG.web.href}login/sso/${code}`).toString(); +/** How long a sign-in round trip may take, provider screens included. */ +export const SSO_ATTEMPT_TTL_MS = 10 * 60_000; + +/** How long an identity may wait for its email to be confirmed. */ +export const SSO_PENDING_TTL_MS = 30 * 60_000; + +const NAME_MAX_LENGTH = 32; + +const COOKIE_SECRET = /^[\w-]{43,128}$/; + +/** + * Hands the identity of an attempt on: a provider account the visitor just + * proved they own, plus how far core may trust its email. + */ +interface VerifiedIdentity { + identity: SsoProviderIdentity; + trustedEmail: null | string; + untrustedEmail: null | string; +} + export class SSOModel { constructor(c: Context) { this.c = c; @@ -118,197 +205,313 @@ export class SSOModel { private readonly c: Context; private readonly plugins: SSOApiPlugin[]; - private readonly signUpUser = async ({ - providerId, - user, - c, - }: { - c: Context; - providerId: string; - user: { - email: string; - id: string; - username: string; - }; - }) => { - const data = await new UserModel().signUp( - { - email: user.email, - emailVerified: true, - name: removeSpecialCharacters(user.username, false), - newsletter: false, - hashedPassword: undefined, - }, - c, - ); - await c - .get("db") - .insert(core_users_sso) - .values({ - userId: data.id, - providerId: providerId, - providerAccountId: user.id, - providerEmail: user.email, - providerUsername: user.username.slice(0, 255), - }); - - return { userId: data.id }; - }; + private cookieName(suffix: "binding" | "pending"): string { + return `${this.c.get("core").authorization.cookieName}--sso-${suffix}`; + } private async linkSecret(): Promise { return await ensureServerSecret(this.c.get("db"), SSO_LINK_SECRET_NAME); } - private async mintLinkToken(offer: { - email: string; - providerAccountId: string; - providerId: string; - userId: number; - }): Promise { + private async mintLinkToken(offer: SsoLinkOffer): Promise { return createSsoLinkToken({ offer, secret: await this.linkSecret() }).token; } - async callback({ + private provider(providerId: string): SSOApiPlugin { + const provider = this.plugins.find(p => p.id === providerId); + if (!provider) throw new HTTPException(404); + + return provider; + } + + private readCookieSecret(suffix: "binding" | "pending"): null | string { + const value = getCookie(this.c, this.cookieName(suffix)); + + return value && COOKIE_SECRET.test(value) ? value : null; + } + + /** + * Signs up a new member for a provider identity, linked to it. The name is + * the provider's username, or "{Provider} user", with a short suffix when + * another member already holds it. + */ + private async signUpLinked({ + email, + identity, + provider, + }: { + email: string; + identity: SsoProviderIdentity; + provider: SSOApiPlugin; + }): Promise { + const base = removeSpecialCharacters( + identity.username ?? `${provider.name} user`, + false, + ) + .slice(0, NAME_MAX_LENGTH) + .trim(); + const fallback = base.length >= 3 ? base : `${provider.name} user`; + + for (let attempt = 0; ; attempt++) { + const name = + attempt === 0 + ? fallback + : `${fallback.slice(0, NAME_MAX_LENGTH - 5).trim()} ${randomDigits(4)}`; + + try { + const userId = await this.createAccount({ email, name }); + await this.c.get("db").insert(core_users_sso).values({ + providerAccountId: identity.id, + providerEmail: identity.email, + providerId: provider.id, + providerUsername: identity.username, + userId, + }); + + return userId; + } catch (error) { + const nameTaken = + error instanceof HTTPException && + error.status === 409 && + error.message === "Name already exists"; + if (!nameTaken || attempt >= 3) throw error; + } + } + } + + private async verifyIdentity({ code, + params, providerId, state, }: { code: string; + params: Readonly>; providerId: string; state: string; - }): Promise { - await this.verifyState(state); - const provider = this.plugins.find(p => p.id === providerId); - if (!provider) { - throw new HTTPException(404); - } - - const ssoToken = await provider.fetchToken(code); - const userFromProvider = await provider.fetchUser(ssoToken); - if (userFromProvider.emailVerified === false) { - throw new HTTPException(400, { message: "Email not verified" }); - } - const userFromSSO = { - email: normalizeEmailAddress(userFromProvider.email), - id: userFromProvider.id, - username: userFromProvider.username, + }): Promise { + const attempt = await this.consumeAttempt({ providerId, state }); + const provider = this.provider(providerId); + const context = { + codeVerifier: attempt.codeVerifier, + nonce: attempt.nonce, + params, }; - const outcome = await this.c.get("db").transaction(async tx => { - const [dataSSOFromDb] = await tx - .select({ - userId: core_users_sso.userId, - email: core_users.email, - emailVerified: core_users.emailVerified, - }) - .from(core_users_sso) - .innerJoin(core_users, eq(core_users.id, core_users_sso.userId)) - .where( - and( - eq(core_users_sso.providerId, providerId), - eq(core_users_sso.providerAccountId, userFromSSO.id), - ), - ) - .limit(1); - - if (!dataSSOFromDb) { - const accountsWithEmail = await tx - .select({ - id: core_users.id, - email: core_users.email, - password: core_users.password, - }) - .from(core_users) - .where(matchesEmail(userFromSSO.email)) - .limit(2); - const userWithEmail = pickAccountForEmail( - accountsWithEmail, - userFromSSO.email, - ); - - if (!userWithEmail) { - const signUpUser = await this.signUpUser({ - providerId, - user: userFromSSO, - c: this.c, - }); + const token = await provider.fetchToken(code, context); + const user = await provider.fetchUser(token, context); + const identity = ssoIdentityOf(provider, user); + if (!identity.id) { + throw new HTTPException(502, { message: "Invalid provider account" }); + } - return { kind: "signed_in", userId: signUpUser.userId } as const; - } + return { + identity, + provider, + trustedEmail: identity.email, + untrustedEmail: + identity.email ?? + (typeof user.email === "string" ? user.email.slice(0, 255) : null), + }; + } - return { - email: userWithEmail.email, - hasPassword: - isPasswordSignInEnabled(this.c) && userWithEmail.password !== null, - kind: "link_required" as const, - linkToken: await this.mintLinkToken({ - email: userFromSSO.email, - providerAccountId: userFromSSO.id, - providerId, - userId: userWithEmail.id, - }), - }; - } + /** + * Finishes a provider round trip: sign in, sign up, ask to link, or ask for + * an email. Throws `400` for a state that is unknown, expired, already used, + * from another browser or for another provider. + */ + async callback({ + code, + params = {}, + providerId, + state, + }: { + code: string; + params?: Readonly>; + providerId: string; + state: string; + }): Promise { + const { identity, provider, trustedEmail, untrustedEmail } = + await this.verifyIdentity({ code, params, providerId, state }); + const linked = await this.findLinkedUser(providerId, identity.id); + if (linked) { if ( + trustedEmail && ssoConfirmsEmail({ - accountEmail: dataSSOFromDb.email, - emailVerified: dataSSOFromDb.emailVerified, - providerEmail: userFromSSO.email, + accountEmail: linked.email, + emailVerified: linked.emailVerified, + providerEmail: trustedEmail, }) ) { - await tx + await this.c + .get("db") .update(core_users) .set({ emailVerified: true }) - .where(eq(core_users.id, dataSSOFromDb.userId)); + .where(eq(core_users.id, linked.userId)); } - return { kind: "returning", userId: dataSSOFromDb.userId } as const; - }); - - if (outcome.kind === "returning") { await new SsoConnectionModel(this.c).afterSignIn({ - identity: ssoIdentityOf(provider, userFromProvider), + identity, providerId, - userId: outcome.userId, + userId: linked.userId, }); - return { kind: "signed_in", userId: outcome.userId }; + return { kind: "signed_in", userId: linked.userId }; } - return outcome; + if (trustedEmail) { + return await this.signUpOrOfferLink({ + email: trustedEmail, + identity, + provider, + }); + } + + if (!isEmailVerificationRequired(this.c)) { + return { kind: "email_unavailable" }; + } + + const token = randomSsoSecret(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SSO_PENDING_TTL_MS); + await this.flowStore.savePending( + { + expiresAt, + profile: identity.profile, + providerAccountId: identity.id, + providerId, + providerUsername: identity.username, + tokenHash: hashSsoSecret(token), + }, + now, + ); + setAuthCookie(this.c, this.cookieName("pending"), token, { + expires: expiresAt, + }); + + return { email: untrustedEmail, kind: "email_required" }; } - async encryptState() { - const state = crypto.randomBytes(8).toString("hex"); - const encryptedState = await new Promise((resolve, reject) => { - const salt = crypto.randomBytes(4).toString("hex"); + clearPendingCookie(): void { + deleteAuthCookie(this.c, this.cookieName("pending")); + } - crypto.scrypt(state, salt, 16, (err, derivedKey) => { - if (err) reject(err); + /** + * Consumes the attempt `state` names - once - and checks it belongs to this + * browser and this provider and has not expired. + * + * @throws `HTTPException(400)` otherwise. + */ + async consumeAttempt({ + providerId, + state, + }: { + providerId: string; + state: string; + }): Promise<{ codeVerifier: string; nonce: string }> { + const invalid = () => new HTTPException(400, { message: "Invalid state" }); + const binding = this.readCookieSecret("binding"); + if (!binding || state.length > 256) throw invalid(); + + const attempt = await this.flowStore.consumeAttempt(hashSsoSecret(state)); + if ( + !attempt || + attempt.expiresAt.getTime() <= Date.now() || + attempt.providerId !== providerId || + !timingSafeEqualText(attempt.bindingHash, hashSsoSecret(binding)) + ) { + throw invalid(); + } - resolve(`${salt}:${derivedKey.toString("hex")}`); - }); - }); + return { codeVerifier: attempt.codeVerifier, nonce: attempt.nonce }; + } - // No `expires`: the state is only good for the round trip to the provider - // and back, so it should not outlive the browser session. - setAuthCookie( + /** + * Creates a member whose email is already proven - by the provider, or by + * the emailed code. `409` when the name or the address is taken. + */ + async createAccount({ + email, + name, + }: { + email: string; + name: string; + }): Promise { + const user = await new UserModel().signUp( + { + email, + emailVerified: true, + hashedPassword: undefined, + name, + newsletter: false, + }, this.c, - `${this.c.get("core").authorization.cookieName}--state-sso`, - encryptedState, ); - return state; + return user.id; } + /** The member already linked to this provider account, if any. */ + async findLinkedUser( + providerId: string, + providerAccountId: string, + ): Promise { + const [row] = await this.c + .get("db") + .select({ + email: core_users.email, + emailVerified: core_users.emailVerified, + userId: core_users_sso.userId, + }) + .from(core_users_sso) + .innerJoin(core_users, eq(core_users.id, core_users_sso.userId)) + .where( + and( + eq(core_users_sso.providerId, providerId), + eq(core_users_sso.providerAccountId, providerAccountId), + ), + ) + .limit(1); + + return row ?? null; + } + + /** + * Starts a sign-in: stores a single-use attempt (state, PKCE verifier, + * nonce) bound to this browser through an HttpOnly cookie, and returns the + * provider URL. + */ async getUrl(providerId: string) { - const provider = this.plugins.find(p => p.id === providerId); - if (!provider) { - throw new HTTPException(404); - } + const provider = this.provider(providerId); + const binding = this.readCookieSecret("binding") ?? randomSsoSecret(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SSO_ATTEMPT_TTL_MS); + const state = randomSsoSecret(); + const codeVerifier = randomSsoSecret(48); + const nonce = randomSsoSecret(); + + await this.flowStore.saveAttempt( + { + bindingHash: hashSsoSecret(binding), + codeVerifier, + expiresAt, + nonce, + providerId, + stateHash: hashSsoSecret(state), + }, + now, + ); + setAuthCookie(this.c, this.cookieName("binding"), binding, { + expires: expiresAt, + }); - return provider.getUrl({ state: await this.encryptState() }); + return provider.getUrl({ + codeChallenge: pkceChallengeS256(codeVerifier), + codeChallengeMethod: "S256", + nonce, + state, + }); } async link({ @@ -398,7 +601,7 @@ export class SSOModel { userId: user.id, providerId, providerAccountId: offer.providerAccountId, - providerEmail: offer.email, + providerEmail: offer.providerEmail ?? null, }); } @@ -434,35 +637,73 @@ export class SSOModel { return { userId: user.id }; } - async verifyState(state: string) { - const storedState = getCookie( - this.c, - `${this.c.get("core").authorization.cookieName}--state-sso`, - ); - if (!storedState) { - throw new HTTPException(400, { - message: "Invalid state", + /** + * The new identity's next step once core holds an email it trusts - from + * the provider, or confirmed with a code: a fresh account, or the existing + * account's password before linking. + */ + async signUpOrOfferLink({ + email, + identity, + provider, + providerEmail = email, + }: { + email: string; + identity: SsoProviderIdentity; + provider: SSOApiPlugin; + /** The address to record as the provider's own; `null` when the provider shared none. */ + providerEmail?: null | string; + }): Promise< + Extract + > { + const accountsWithEmail = await this.c + .get("db") + .select({ + email: core_users.email, + id: core_users.id, + password: core_users.password, + }) + .from(core_users) + .where(matchesEmail(email)) + .limit(2); + const existing = pickAccountForEmail(accountsWithEmail, email); + + if (!existing) { + const userId = await this.signUpLinked({ + email, + identity: { ...identity, email: providerEmail }, + provider, }); - } - const isValid = await new Promise((resolve, reject) => { - const [salt, storedHash] = storedState.split(":"); + return { kind: "signed_in", userId }; + } - crypto.scrypt(state, salt, 16, (err, derivedKey) => { - if (err) reject(err); - resolve(storedHash === derivedKey.toString("hex")); - }); - }); + return { + email: existing.email, + hasPassword: + isPasswordSignInEnabled(this.c) && existing.password !== null, + kind: "link_required", + linkToken: await this.mintLinkToken({ + email, + providerAccountId: identity.id, + providerEmail, + providerId: provider.id, + userId: existing.id, + }), + }; + } - if (!isValid) { - throw new HTTPException(400, { - message: "Invalid state", - }); - } + get flowStore(): SsoFlowStore { + return drizzleSsoFlowStore(this.c.get("db")); + } - deleteAuthCookie( - this.c, - `${this.c.get("core").authorization.cookieName}--state-sso`, - ); + /** The pending identity this browser's cookie names, if it is still live. */ + get pendingToken(): null | string { + return this.readCookieSecret("pending"); } } + +const randomDigits = (length: number): string => + Array.from(crypto.getRandomValues(new Uint8Array(length)), byte => + (byte % 10).toString(), + ).join(""); diff --git a/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts b/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts index b7afe991c7..b970f99c45 100644 --- a/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/connections/connections.test.ts @@ -10,6 +10,7 @@ import type { import type { SSOApiPlugin, SSOProviderUser } from "@/api/models/sso"; import type { SsoProfilePolicy } from "@/api/models/sso-connection"; +import { pkceChallengeS256 } from "@/api/lib/sso-protocol"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; import { @@ -103,6 +104,7 @@ const harness = ({ const discord = provider("discord", "Discord", ["avatar"], profiles); let adapters: SSOApiPlugin[] = [google, discord]; let policy = OPEN_POLICY; + let signedInRecently = true; vi.spyOn(SsoConnectionModel.prototype, "store", "get").mockReturnValue( memory.store, @@ -113,6 +115,10 @@ const harness = ({ vi.spyOn(SsoConnectionModel.prototype, "refreshSessions").mockResolvedValue( undefined, ); + vi.spyOn( + SsoConnectionModel.prototype, + "recentlyAuthenticated", + ).mockImplementation(async () => Promise.resolve(signedInRecently)); const importAvatar = vi .spyOn(SsoConnectionModel.prototype, "importAvatar") .mockImplementation(async ({ url }) => @@ -214,6 +220,9 @@ const harness = ({ setPolicy: (next: SsoProfilePolicy) => { policy = next; }, + setSignedInRecently: (next: boolean) => { + signedInRecently = next; + }, signInAs: (user: MemorySsoAccount | null) => { viewer = user; }, @@ -275,6 +284,61 @@ describe("connecting an account from settings", () => { }); }); + it("asks for a recent sign-in before connecting a new account", async () => { + const h = harness(); + h.signInAs(ALICE); + h.setSignedInRecently(false); + + const response = await h.request( + "/google/authorize", + json({ intent: "link" }), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "reauthentication_required", + }); + expect(h.operations()).toEqual([]); + }); + + it("connects a provider account that shares no email, by its id alone", async () => { + const h = harness(); + h.signInAs(ALICE); + + const response = await connectGoogle(h, { + email: null, + emailVerified: false, + }); + + expect(response.status).toBe(200); + expect(h.connections).toEqual([ + expect.objectContaining({ + providerAccountId: "google-alice", + providerEmail: null, + userId: ALICE.id, + }), + ]); + }); + + it("hands the provider the PKCE verifier and nonce of its own round trip", async () => { + const h = harness(); + h.signInAs(ALICE); + const getUrl = vi.spyOn(h.google, "getUrl"); + + await connectGoogle(h); + + const [request] = getUrl.mock.calls[0] ?? []; + const [, context] = h.google.fetchToken.mock.calls[0] ?? []; + expect(context).toEqual({ + codeVerifier: expect.any(String), + nonce: request?.nonce, + params: {}, + }); + expect(pkceChallengeS256(String(context?.codeVerifier))).toBe( + request?.codeChallenge, + ); + }); + it("never signs anybody in, and never opens an AdminCP session", async () => { const h = harness(); h.signInAs(ALICE); diff --git a/packages/vitnode/src/api/modules/users/sso/connections/failure.ts b/packages/vitnode/src/api/modules/users/sso/connections/failure.ts index b170ce5af8..d98f19a8ff 100644 --- a/packages/vitnode/src/api/modules/users/sso/connections/failure.ts +++ b/packages/vitnode/src/api/modules/users/sso/connections/failure.ts @@ -10,6 +10,8 @@ export const ssoConnectionFailure = (c: Context, error: unknown) => { switch (error.status) { case 400: return c.json(body, 400); + case 403: + return c.json(body, 403); case 404: return c.json(body, 404); case 409: diff --git a/packages/vitnode/src/api/modules/users/sso/connections/routes/callback.route.ts b/packages/vitnode/src/api/modules/users/sso/connections/routes/callback.route.ts index 192f5ea3d2..c4d28c6202 100644 --- a/packages/vitnode/src/api/modules/users/sso/connections/routes/callback.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/connections/routes/callback.route.ts @@ -15,6 +15,7 @@ import { export const zodSsoConnectionCallbackSchema = z.object({ code: z.string().min(1).max(4096), state: z.string().min(16).max(256), + user: z.string().max(2048).optional(), }); export const ssoConnectionCallbackRoute = buildRoute({ @@ -52,12 +53,13 @@ export const ssoConnectionCallbackRoute = buildRoute({ handler: async c => { const user = requireSignedInUser(c.get("user")); const { providerId } = c.req.valid("param"); - const { code, state } = c.req.valid("json"); + const { code, state, user: providerUser } = c.req.valid("json"); try { return c.json( await new SsoConnectionModel(c).callback({ code, + params: providerUser === undefined ? {} : { user: providerUser }, providerId, state, user, diff --git a/packages/vitnode/src/api/modules/users/sso/connections/schema.ts b/packages/vitnode/src/api/modules/users/sso/connections/schema.ts index 210e4dfbfe..de025ba9e0 100644 --- a/packages/vitnode/src/api/modules/users/sso/connections/schema.ts +++ b/packages/vitnode/src/api/modules/users/sso/connections/schema.ts @@ -17,6 +17,7 @@ const ERROR_CODES = [ "provider_already_connected", "provider_error", "provider_not_found", + "reauthentication_required", ] as const satisfies readonly SsoConnectionErrorCode[]; export const zodSsoConnectionErrorSchema = z.object({ @@ -35,6 +36,9 @@ const errorResponse = (description: string) => ({ export const SSO_CONNECTION_ERROR_RESPONSES = { 400: errorResponse("The request or its state was rejected"), 401: { description: "Not signed in" }, + 403: errorResponse( + "Connecting a new account needs a recent sign-in - sign in again first", + ), 404: errorResponse("The provider, connection or import is unknown"), 409: errorResponse("The request conflicts with the account's connections"), 502: errorResponse("The provider did not answer as expected"), diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts index f855eeffe9..22e39c5cab 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.route.ts @@ -14,8 +14,12 @@ export const callbackRoute = buildRoute({ providerId: z.string(), }), query: z.object({ - code: z.string(), - state: z.string(), + code: z.string().min(1).max(4096), + state: z.string().min(1).max(256), + user: z.string().max(2048).optional().openapi({ + description: + "Extra profile data some providers post back on a first authorization (Apple's `user`). Untrusted.", + }), }), }, responses: { @@ -29,6 +33,22 @@ export const callbackRoute = buildRoute({ }, description: "URL", }, + 202: { + content: { + "application/json": { + schema: z.object({ + email: z.string().nullable(), + emailRequired: z.literal(true), + }), + }, + }, + description: + "The provider shared no verified email. The visitor confirms one with a code (`/pending/{providerId}/email`); `email` is the provider's unverified suggestion, if any", + }, + 400: { + description: + "The state is unknown, expired, already used, from another browser or for another provider", + }, 409: { content: { "application/json": { @@ -42,12 +62,37 @@ export const callbackRoute = buildRoute({ description: "An account with this email already exists; the body carries what the link step needs", }, + 422: { + content: { + "application/json": { + schema: z.object({ error: z.literal("email_unavailable") }), + }, + }, + description: + "The provider shared no verified email and this install cannot send one to confirm", + }, }, }, handler: async c => { const { providerId } = c.req.valid("param"); - const { code, state } = c.req.valid("query"); - const outcome = await new SSOModel(c).callback({ providerId, code, state }); + const { code, state, user } = c.req.valid("query"); + const outcome = await new SSOModel(c).callback({ + code, + params: user === undefined ? {} : { user }, + providerId, + state, + }); + + if (outcome.kind === "email_required") { + return c.json( + { email: outcome.email, emailRequired: true as const }, + 202, + ); + } + + if (outcome.kind === "email_unavailable") { + return c.json({ error: "email_unavailable" as const }, 422); + } if (outcome.kind === "link_required") { return c.json( diff --git a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts index c120c516ae..fd5986eb27 100644 --- a/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts +++ b/packages/vitnode/src/api/modules/users/sso/routes/callback.test.ts @@ -1,17 +1,30 @@ import type { Context } from "hono"; import { OpenAPIHono } from "@hono/zod-openapi"; +import { HTTPException } from "hono/http-exception"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { EnvVariablesVitNode } from "@/api/middlewares/global.middleware"; -import type { SSOApiPlugin, SSOProviderUser } from "@/api/models/sso"; +import type { + SSOApiPlugin, + SSOAuthorizationRequest, + SSOProviderUser, +} from "@/api/models/sso"; +import { pkceChallengeS256 } from "@/api/lib/sso-protocol"; import { SessionModel } from "@/api/models/session"; import { SessionAdminModel } from "@/api/models/session-admin"; +import { SSOModel } from "@/api/models/sso"; import { SsoConnectionModel } from "@/api/models/sso-connection"; +import { verifySsoLinkToken } from "@/api/models/sso-link-token"; +import { core_secrets } from "@/database/secrets"; +import { core_users, core_users_sso } from "@/database/users"; +import { createMemorySsoFlowStore } from "@/tests/sso-flow-store"; import { ssoUserModule } from "../sso.module"; +const LINK_SECRET = "link-secret"; + const PROVIDER_USER: SSOProviderUser = { avatarUrl: "https://lh3.googleusercontent.test/a/photo.png", email: "Alice@Example.test", @@ -21,46 +34,132 @@ const PROVIDER_USER: SSOProviderUser = { username: "Alice Smith", }; -const fakeDb = (rows: unknown[][]) => { - const queue = [...rows]; - const chain = { - from: () => chain, - innerJoin: () => chain, - limit: () => chain, - then: async (onFulfilled: (value: unknown) => unknown) => - await Promise.resolve(onFulfilled(queue.shift() ?? [])), - where: () => chain, +interface Account { + email: string; + emailVerified: boolean; + id: number; + password: null | string; +} + +/** Answers each `select` by the table it reads, never by call order. */ +const fakeDb = (accounts: Account[]) => { + const inserts: { table: unknown; values: unknown }[] = []; + const updates: unknown[] = []; + const select = () => { + let table: unknown; + const chain = { + from: (from: unknown) => { + table = from; + + return chain; + }, + innerJoin: () => chain, + limit: () => chain, + then: async (onFulfilled: (value: unknown) => unknown) => + await Promise.resolve( + onFulfilled( + table === core_secrets + ? [{ value: LINK_SECRET }] + : table === core_users + ? accounts.map(({ email, id, password }) => ({ + email, + id, + password, + })) + : [], + ), + ), + where: () => chain, + }; + + return chain; }; - const updates = vi.fn(); const db = { - select: () => chain, + insert: (table: unknown) => ({ + values: async (values: unknown) => { + inserts.push({ table, values }); + await Promise.resolve(); + }, + }), + select, transaction: async (run: (tx: unknown) => Promise) => await run(db), update: () => ({ set: (values: unknown) => ({ where: async () => { - updates(values); + updates.push(values); await Promise.resolve(); }, }), }), }; - return { db, updates }; + return { db, inserts, updates }; +}; + +const cookieHeader = (response: Response, jar: Map) => { + for (const line of response.headers.getSetCookie()) { + const [pair = ""] = line.split(";"); + const [name = "", value = ""] = pair.split("="); + if (value === "" || /max-age=0/i.test(line)) jar.delete(name); + else jar.set(name, value); + } + + return [...jar].map(([name, value]) => `${name}=${value}`).join("; "); }; -const harness = (rows: unknown[][]) => { - const google: SSOApiPlugin = { +const harness = ({ + accounts = [], + emailAdapter = true, + linked = new Map< + string, + { email: string; emailVerified: boolean; userId: number } + >(), + user = PROVIDER_USER, +}: { + accounts?: Account[]; + emailAdapter?: boolean; + linked?: Map< + string, + { email: string; emailVerified: boolean; userId: number } + >; + user?: SSOProviderUser; +} = {}) => { + const requests: SSOAuthorizationRequest[] = []; + const adapter = ( + id: string, + ): SSOApiPlugin & { + fetchToken: ReturnType; + fetchUser: ReturnType; + } => ({ fetchToken: vi.fn(async () => Promise.resolve({ access_token: "token", token_type: "Bearer" }), ), - fetchUser: vi.fn(async () => Promise.resolve(PROVIDER_USER)), - getUrl: ({ state }) => `https://google.example/oauth?state=${state}`, - id: "google", - name: "Google", + fetchUser: vi.fn(async () => Promise.resolve(user)), + getUrl: request => { + requests.push(request); + + return `https://${id}.example/oauth?state=${request.state}`; + }, + id, + name: id === "google" ? "Google" : "Discord", profileFields: ["avatar", "firstName", "lastName"], - }; - const { db, updates } = fakeDb(rows); + }); + const google = adapter("google"); + const discord = adapter("discord"); + const flow = createMemorySsoFlowStore(); + const { db, inserts, updates } = fakeDb(accounts); + const send = vi.fn(async (_args: unknown) => Promise.resolve()); + let nextUserId = 100; + + vi.spyOn(SSOModel.prototype, "flowStore", "get").mockReturnValue(flow.store); + vi.spyOn(SSOModel.prototype, "findLinkedUser").mockImplementation( + async (providerId, accountId) => + Promise.resolve(linked.get(`${providerId}:${accountId}`) ?? null), + ); + const createAccount = vi + .spyOn(SSOModel.prototype, "createAccount") + .mockImplementation(async () => Promise.resolve(nextUserId++)); const createSession = vi .spyOn(SessionModel.prototype, "createSessionByUserId") .mockResolvedValue({ token: "session-token" }); @@ -85,51 +184,113 @@ const harness = (rows: unknown[][]) => { deviceCookieName: "vitnode_device", passkeys: { enabled: false, problems: [] }, password: { enabled: true }, - ssoAdapters: [google], + ssoAdapters: [google, discord], }, + email: emailAdapter ? { adapter: {} } : undefined, } as unknown as EnvVariablesVitNode["core"]); c.set("user", null as Context["var"]["user"]); c.set("db", db as unknown as Context["var"]["db"]); + c.set("email", { send } as unknown as Context["var"]["email"]); + c.set("i18n", { + resolveLocale: () => "en", + } as unknown as Context["var"]["i18n"]); + c.set("ipAddress", "203.0.113.7"); + c.set("events", { + emit: vi.fn(async () => Promise.resolve()), + } as unknown as Context["var"]["events"]); await next(); }); app.route("/", ssoUserModule.hono); - const signInThroughGoogle = async () => { - const start = await app.request("/google", { method: "POST" }); - const { url } = (await start.json()) as { url: string }; - const state = new URL(url).searchParams.get("state") ?? ""; - const cookie = start.headers - .getSetCookie() - .map(line => line.split(";")[0]) - .join("; "); - - return await app.request( - `/google/callback?code=provider-code&state=${state}`, - { headers: { cookie } }, + const jar = new Map(); + const request = async (path: string, init: RequestInit = {}) => { + const response = await app.request(path, { + ...init, + headers: { ...init.headers, cookie: cookieHeader(new Response(), jar) }, + }); + cookieHeader(response, jar); + + return response; + }; + + const start = async (providerId = "google") => { + const response = await request(`/${providerId}`, { method: "POST" }); + const { url } = (await response.json()) as { url: string }; + + return new URL(url).searchParams.get("state") ?? ""; + }; + + const callback = async ( + state: string, + { extra = "", providerId = "google" } = {}, + ) => + await request( + `/${providerId}/callback?code=provider-code&state=${state}${extra}`, ); + + const postJson = async (path: string, body: unknown) => + await request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + + /** The code inside the last email: its subject template is `{code}`. */ + const lastCode = () => { + const args = send.mock.lastCall?.[0] as { + subject: (props: unknown) => string; + }; + + return args.subject({ + i18n: { + locale: "en", + messages: { + core: { auth: { sso_email_code: { email: { subject: "{code}" } } } }, + }, + }, + }); }; return { afterSignIn, app, + callback, + createAccount, createAdminSession, createSession, - signInThroughGoogle, + discord, + flow, + google, + inserts, + jar, + lastCode, + postJson, + request, + requests, + send, + signIn: async (extra = "") => await callback(await start(), { extra }), + start, updates, }; }; afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); +const RETURNING = new Map([ + [ + "google:google-alice", + { email: "alice@example.test", emailVerified: true, userId: 7 }, + ], +]); + describe("signing in through an SSO provider", () => { it("still signs a returning member in with a public session only", async () => { - const h = harness([ - [{ email: "alice@example.test", emailVerified: true, userId: 7 }], - ]); + const h = harness({ linked: RETURNING }); - const response = await h.signInThroughGoogle(); + const response = await h.signIn(); expect(response.status).toBe(200); expect(await response.json()).toEqual({ id: 7 }); @@ -141,11 +302,9 @@ describe("signing in through an SSO provider", () => { }); it("hands the normalized provider profile to the sign-in sync", async () => { - const h = harness([ - [{ email: "alice@example.test", emailVerified: true, userId: 7 }], - ]); + const h = harness({ linked: RETURNING }); - await h.signInThroughGoogle(); + await h.signIn(); expect(h.afterSignIn).toHaveBeenCalledWith({ identity: { @@ -164,23 +323,427 @@ describe("signing in through an SSO provider", () => { }); it("still confirms an unverified account whose email the provider vouches for", async () => { - const h = harness([ - [{ email: "alice@example.test", emailVerified: false, userId: 7 }], + const h = harness({ + linked: new Map([ + [ + "google:google-alice", + { email: "alice@example.test", emailVerified: false, userId: 7 }, + ], + ]), + }); + + await h.signIn(); + + expect(h.updates).toEqual([{ emailVerified: true }]); + }); + + it("signs a returning member in by their provider id when the email is missing or unverified", async () => { + for (const user of [ + { ...PROVIDER_USER, email: null, firstName: null, lastName: null }, + { ...PROVIDER_USER, emailVerified: false }, + ]) { + const h = harness({ + linked: new Map([ + [ + "google:google-alice", + { email: "alice@example.test", emailVerified: false, userId: 7 }, + ], + ]), + user, + }); + + const response = await h.signIn(); + + expect(response.status).toBe(200); + expect(h.createSession).toHaveBeenCalledWith(7); + // Nothing the provider could not vouch for confirms the stored address. + expect(h.updates).toEqual([]); + expect(h.afterSignIn).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ email: null }), + }), + ); + vi.restoreAllMocks(); + } + }); + + it("signs up a new member with the verified email the provider shared", async () => { + const h = harness(); + + const response = await h.signIn(); + + expect(response.status).toBe(200); + expect(h.createAccount).toHaveBeenCalledWith({ + email: "alice@example.test", + name: "Alice Smith", + }); + expect(h.inserts).toEqual([ + { + table: core_users_sso, + values: { + providerAccountId: "google-alice", + providerEmail: "alice@example.test", + providerId: "google", + providerUsername: "Alice Smith", + userId: 100, + }, + }, ]); + expect(h.createSession).toHaveBeenCalledWith(100); + }); - await h.signInThroughGoogle(); + it("asks for the existing account's password when the verified email is taken", async () => { + const h = harness({ + accounts: [ + { + email: "alice@example.test", + emailVerified: true, + id: 7, + password: "hash", + }, + ], + }); + + const response = await h.signIn(); + const body = (await response.json()) as { + email: string; + hasPassword: boolean; + linkToken: string; + }; - expect(h.updates).toHaveBeenCalledWith({ emailVerified: true }); + expect(response.status).toBe(409); + expect(body).toMatchObject({ + email: "alice@example.test", + hasPassword: true, + }); + expect( + verifySsoLinkToken({ + providerId: "google", + secret: LINK_SECRET, + token: body.linkToken, + }), + ).toMatchObject({ providerAccountId: "google-alice", userId: 7 }); + expect(h.createAccount).not.toHaveBeenCalled(); + expect(h.createSession).not.toHaveBeenCalled(); }); - it("rejects a callback without the browser's state cookie", async () => { - const h = harness([]); + it("retries a taken name with a short suffix instead of failing the sign-up", async () => { + const h = harness(); + h.createAccount + .mockRejectedValueOnce( + new HTTPException(409, { message: "Name already exists" }), + ) + .mockResolvedValueOnce(100); + + const response = await h.signIn(); - const response = await h.app.request( - "/google/callback?code=provider-code&state=0123456789abcdef", + expect(response.status).toBe(200); + expect(h.createAccount.mock.calls[1]?.[0].name).toMatch( + /^Alice Smith \d{4}$/, + ); + }); +}); + +describe("the state of a sign-in round trip", () => { + it("rejects a callback without the browser's binding cookie", async () => { + const h = harness({ linked: RETURNING }); + const state = await h.start(); + h.jar.clear(); + + const response = await h.callback(state); + + expect(response.status).toBe(400); + expect(h.google.fetchToken).not.toHaveBeenCalled(); + expect(h.createSession).not.toHaveBeenCalled(); + }); + + it("rejects a state started in another browser", async () => { + const h = harness({ linked: RETURNING }); + const state = await h.start(); + h.jar.set( + "vitnode_auth--sso-binding", + "another-browser-binding-secret-0123456789abcdefgh", ); + expect((await h.callback(state)).status).toBe(400); + expect(h.google.fetchToken).not.toHaveBeenCalled(); + }); + + it("works once", async () => { + const h = harness({ linked: RETURNING }); + const state = await h.start(); + + expect((await h.callback(state)).status).toBe(200); + expect((await h.callback(state)).status).toBe(400); + expect(h.google.fetchToken).toHaveBeenCalledTimes(1); + }); + + it("belongs to the provider it was started for", async () => { + const h = harness({ linked: RETURNING }); + const state = await h.start("google"); + + const response = await h.callback(state, { providerId: "discord" }); + expect(response.status).toBe(400); + expect(h.discord.fetchToken).not.toHaveBeenCalled(); + // Spent anyway: a state cannot be retried against the right provider. + expect((await h.callback(state)).status).toBe(400); + }); + + it("expires after ten minutes", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const h = harness({ linked: RETURNING }); + const state = await h.start(); + + vi.setSystemTime(Date.now() + 10 * 60_000 + 1); + + expect((await h.callback(state)).status).toBe(400); + }); + + it("is a server-side record: the state and verifier never sit in a cookie", async () => { + const h = harness({ linked: RETURNING }); + const state = await h.start(); + const [attempt] = h.flow.attempts(); + + expect(attempt?.stateHash).not.toBe(state); + expect([...h.jar.values()].join(";")).not.toContain(state); + expect([...h.jar.values()].join(";")).not.toContain( + attempt?.codeVerifier ?? "-", + ); + }); + + it("hands the adapter the PKCE verifier behind its challenge, the nonce and the extra callback fields", async () => { + const h = harness({ linked: RETURNING }); + const user = JSON.stringify({ name: { firstName: "Alice" } }); + + await h.signIn(`&user=${encodeURIComponent(user)}`); + + const [request] = h.requests; + const [code, context] = h.google.fetchToken.mock.calls[0] ?? []; + expect(code).toBe("provider-code"); + expect(context).toEqual({ + codeVerifier: expect.any(String), + nonce: request?.nonce, + params: { user }, + }); + expect(request?.codeChallengeMethod).toBe("S256"); + expect(pkceChallengeS256(String(context?.codeVerifier))).toBe( + request?.codeChallenge, + ); + expect(h.google.fetchUser.mock.calls[0]?.[1]).toEqual(context); + }); +}); + +describe("a new identity without a trustworthy email", () => { + const NO_EMAIL = { ...PROVIDER_USER, email: null }; + + it("waits server-side for a confirmed address instead of creating or linking anything", async () => { + for (const user of [NO_EMAIL, { ...PROVIDER_USER, emailVerified: false }]) { + const h = harness({ + // An account with the provider's unverified address must not matter. + accounts: [ + { + email: "alice@example.test", + emailVerified: true, + id: 7, + password: "hash", + }, + ], + user, + }); + + const response = await h.signIn(); + + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ + email: user.email ?? null, + emailRequired: true, + }); + expect(h.flow.pending()).toEqual([ + expect.objectContaining({ + email: null, + providerAccountId: "google-alice", + providerId: "google", + }), + ]); + expect(h.jar.get("vitnode_auth--sso-pending")).toMatch(/^[\w-]{43}$/); + expect(h.createAccount).not.toHaveBeenCalled(); + expect(h.inserts).toEqual([]); + expect(h.createSession).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + } + }); + + it("refuses when this install cannot send a confirmation email", async () => { + const h = harness({ emailAdapter: false, user: NO_EMAIL }); + + const response = await h.signIn(); + + expect(response.status).toBe(422); + expect(await response.json()).toEqual({ error: "email_unavailable" }); + expect(h.flow.pending()).toEqual([]); + }); +}); + +describe("confirming an email for a provider that shared none", () => { + const pendingHarness = async (accounts: Account[] = []) => { + const h = harness({ accounts, user: { ...PROVIDER_USER, email: null } }); + expect((await h.signIn()).status).toBe(202); + + return h; + }; + + it("emails a six-digit code to the typed address, and only there", async () => { + const h = await pendingHarness(); + + const response = await h.postJson("/pending/google/email", { + email: "Alice@Mail.test", + }); + + expect(response.status).toBe(201); + expect(h.send).toHaveBeenCalledWith( + expect.objectContaining({ locale: "en", to: "alice@mail.test" }), + ); + expect(h.lastCode()).toMatch(/^\d{6}$/); + expect(await response.text()).not.toContain(h.lastCode()); + expect(h.flow.pending()[0]?.codeHash).not.toContain(h.lastCode()); + }); + + it("needs the pending sign-in of this browser and this provider", async () => { + const h = await pendingHarness(); + + const otherProvider = await h.postJson("/pending/discord/email", { + email: "alice@mail.test", + }); + h.jar.delete("vitnode_auth--sso-pending"); + const noCookie = await h.postJson("/pending/google/email", { + email: "alice@mail.test", + }); + + expect(otherProvider.status).toBe(400); + expect(noCookie.status).toBe(400); + expect(await noCookie.json()).toEqual({ error: "invalid_pending" }); + expect(h.send).not.toHaveBeenCalled(); + }); + + it("creates a verified account with the confirmed address, once", async () => { + const h = await pendingHarness(); + await h.postJson("/pending/google/email", { email: "alice@mail.test" }); + const code = h.lastCode(); + + const response = await h.postJson("/pending/google/verify", { code }); + + expect(response.status).toBe(200); + expect(h.createAccount).toHaveBeenCalledWith({ + email: "alice@mail.test", + name: "Alice Smith", + }); + expect(h.inserts).toEqual([ + expect.objectContaining({ + values: expect.objectContaining({ + providerAccountId: "google-alice", + providerEmail: null, + }), + }), + ]); + expect(h.createSession).toHaveBeenCalledWith(100); + expect(h.flow.pending()).toEqual([]); + expect((await h.postJson("/pending/google/verify", { code })).status).toBe( + 400, + ); + }); + + it("asks for the existing account's password when the confirmed address has one", async () => { + const h = await pendingHarness([ + { + email: "alice@mail.test", + emailVerified: true, + id: 7, + password: "hash", + }, + ]); + await h.postJson("/pending/google/email", { email: "alice@mail.test" }); + + const response = await h.postJson("/pending/google/verify", { + code: h.lastCode(), + }); + const body = (await response.json()) as { linkToken: string }; + + expect(response.status).toBe(409); + expect( + verifySsoLinkToken({ + providerId: "google", + secret: LINK_SECRET, + token: body.linkToken, + }), + ).toEqual({ + email: "alice@mail.test", + providerAccountId: "google-alice", + providerEmail: null, + providerId: "google", + userId: 7, + }); + expect(h.createAccount).not.toHaveBeenCalled(); expect(h.createSession).not.toHaveBeenCalled(); }); + + it("refuses a wrong code, and any code after five wrong ones", async () => { + const h = await pendingHarness(); + await h.postJson("/pending/google/email", { email: "alice@mail.test" }); + const code = h.lastCode(); + const wrong = code === "000000" ? "000001" : "000000"; + + const first = await h.postJson("/pending/google/verify", { code: wrong }); + expect(first.status).toBe(400); + expect(await first.json()).toEqual({ error: "invalid_code" }); + for (let attempt = 0; attempt < 4; attempt++) { + await h.postJson("/pending/google/verify", { code: wrong }); + } + + const late = await h.postJson("/pending/google/verify", { code }); + expect(late.status).toBe(400); + expect(await late.json()).toEqual({ error: "code_expired" }); + expect(h.createAccount).not.toHaveBeenCalled(); + }); + + it("lets a code expire after ten minutes, and the whole sign-in after thirty", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const h = await pendingHarness(); + await h.postJson("/pending/google/email", { email: "alice@mail.test" }); + + vi.setSystemTime(Date.now() + 10 * 60_000 + 1); + const late = await h.postJson("/pending/google/verify", { + code: h.lastCode(), + }); + expect(await late.json()).toEqual({ error: "code_expired" }); + + vi.setSystemTime(Date.now() + 20 * 60_000); + const gone = await h.postJson("/pending/google/email", { + email: "alice@mail.test", + }); + expect(await gone.json()).toEqual({ error: "invalid_pending" }); + }); + + it("spaces out new codes and replaces the previous one", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const h = await pendingHarness(); + await h.postJson("/pending/google/email", { email: "alice@mail.test" }); + const first = h.lastCode(); + + const tooSoon = await h.postJson("/pending/google/email", { + email: "alice@mail.test", + }); + expect(tooSoon.status).toBe(429); + + vi.setSystemTime(Date.now() + 30_000); + expect( + (await h.postJson("/pending/google/email", { email: "alice@mail.test" })) + .status, + ).toBe(201); + + if (first !== h.lastCode()) { + const stale = await h.postJson("/pending/google/verify", { code: first }); + expect(await stale.json()).toEqual({ error: "invalid_code" }); + } + }); }); diff --git a/packages/vitnode/src/api/modules/users/sso/routes/pending.route.ts b/packages/vitnode/src/api/modules/users/sso/routes/pending.route.ts new file mode 100644 index 0000000000..f723795516 --- /dev/null +++ b/packages/vitnode/src/api/modules/users/sso/routes/pending.route.ts @@ -0,0 +1,167 @@ +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; + +import { buildRoute } from "@/api/builders"; +import { SessionModel } from "@/api/models/session"; +import { SsoEmailCompletionModel } from "@/api/models/sso-email-completion"; + +import { USER_EMAIL_MAX_LENGTH } from "../../credential-limits"; + +const zodPendingError = z.object({ + error: z.enum([ + "code_expired", + "invalid_code", + "invalid_pending", + "too_many_codes", + ]), +}); + +const pendingFailure = (error: unknown) => { + if ( + error instanceof HTTPException && + (error.status === 400 || error.status === 429) + ) { + const parsed = zodPendingError.shape.error.safeParse(error.message); + if (parsed.success) return { error: parsed.data, status: error.status }; + } + + throw error; +}; + +const providerParams = z.object({ + providerId: z + .string() + .min(1) + .max(255) + .regex(/^[\w-]+$/), +}); + +export const zodSsoPendingEmailSchema = z.object({ + email: z + .email() + .max(USER_EMAIL_MAX_LENGTH) + .toLowerCase() + .openapi({ example: "test@test.com" }), +}); + +export const ssoPendingEmailRoute = buildRoute({ + route: { + method: "post", + description: + "Email a six-digit code to the address the visitor wants to use, for the provider identity this browser is completing. Answers the same whether or not the address has an account.", + path: "/pending/{providerId}/email", + request: { + params: providerParams, + body: { + required: true, + content: { "application/json": { schema: zodSsoPendingEmailSchema } }, + }, + }, + responses: { + 201: { description: "Code sent" }, + 400: { + content: { "application/json": { schema: zodPendingError } }, + description: "No live pending sign-in in this browser", + }, + 429: { + content: { "application/json": { schema: zodPendingError } }, + description: "Too many codes, or the last one was sent moments ago", + }, + }, + }, + handler: async c => { + const { providerId } = c.req.valid("param"); + const { email } = c.req.valid("json"); + + try { + await new SsoEmailCompletionModel(c).sendCode({ email, providerId }); + } catch (error) { + const { error: code, status } = pendingFailure(error); + + return status === 429 + ? c.json({ error: code }, 429) + : c.json({ error: code }, 400); + } + + return c.body(null, 201); + }, +}); + +export const zodSsoPendingVerifySchema = z.object({ + code: z + .string() + .regex(/^\d{6}$/) + .openapi({ example: "123456" }), +}); + +export const ssoPendingVerifyRoute = buildRoute({ + route: { + method: "post", + description: + "Confirm the emailed code. Works once: signs up a new member with the confirmed address, or - when it belongs to an account - answers with the link offer that account's password unlocks.", + path: "/pending/{providerId}/verify", + request: { + params: providerParams, + body: { + required: true, + content: { + "application/json": { schema: zodSsoPendingVerifySchema }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: z.object({ id: z.number() }) }, + }, + description: "Signed in", + }, + 400: { + content: { "application/json": { schema: zodPendingError } }, + description: "Wrong, expired or spent code, or no pending sign-in", + }, + 409: { + content: { + "application/json": { + schema: z.object({ + email: z.string(), + hasPassword: z.boolean(), + linkToken: z.string(), + }), + }, + }, + description: + "The confirmed address belongs to an account; the body carries what the link step needs", + }, + }, + }, + handler: async c => { + const { providerId } = c.req.valid("param"); + const { code } = c.req.valid("json"); + + let outcome: Awaited>; + try { + outcome = await new SsoEmailCompletionModel(c).verifyCode({ + code, + providerId, + }); + } catch (error) { + return c.json({ error: pendingFailure(error).error }, 400); + } + + if (outcome.kind === "link_required") { + return c.json( + { + email: outcome.email, + hasPassword: outcome.hasPassword, + linkToken: outcome.linkToken, + }, + 409, + ); + } + + await new SessionModel(c).createSessionByUserId(outcome.userId); + + return c.json({ id: outcome.userId }, 200); + }, +}); diff --git a/packages/vitnode/src/api/modules/users/sso/sso.module.ts b/packages/vitnode/src/api/modules/users/sso/sso.module.ts index a1fdbf0ba2..2939c0c543 100644 --- a/packages/vitnode/src/api/modules/users/sso/sso.module.ts +++ b/packages/vitnode/src/api/modules/users/sso/sso.module.ts @@ -4,9 +4,19 @@ import { ssoConnectionsModule } from "./connections/connections.module"; import { callbackRoute } from "./routes/callback.route"; import { createUrlRoute } from "./routes/create-url.route"; import { linkRoute } from "./routes/link.route"; +import { + ssoPendingEmailRoute, + ssoPendingVerifyRoute, +} from "./routes/pending.route"; export const ssoUserModule = buildModule({ name: "sso", - routes: [callbackRoute, createUrlRoute, linkRoute], + routes: [ + callbackRoute, + createUrlRoute, + linkRoute, + ssoPendingEmailRoute, + ssoPendingVerifyRoute, + ], modules: [ssoConnectionsModule], }); diff --git a/packages/vitnode/src/database/users.ts b/packages/vitnode/src/database/users.ts index 49df296c77..8ed0eb21bf 100644 --- a/packages/vitnode/src/database/users.ts +++ b/packages/vitnode/src/database/users.ts @@ -179,6 +179,8 @@ export const core_users_sso_operations = camelCase.table.withRLS( firstName: null | string; lastName: null | string; }>(), + codeVerifier: t.varchar({ length: 128 }), + nonce: t.varchar({ length: 128 }), createdAt: t.timestamp().notNull().defaultNow(), expiresAt: t.timestamp().notNull(), }), @@ -195,6 +197,58 @@ export const core_users_sso_operations = camelCase.table.withRLS( ], ); +/** + * A sign-in round trip to a provider: single use, short-lived, bound to the + * browser that started it (`bindingHash` is the hash of an HttpOnly cookie) + * and to one provider. Holds the PKCE verifier and OIDC nonce until the + * callback consumes the row. + */ +export const core_sso_attempts = camelCase.table.withRLS( + "core_sso_attempts", + t => ({ + id: t.serial().primaryKey(), + stateHash: t.varchar({ length: 64 }).notNull().unique(), + bindingHash: t.varchar({ length: 64 }).notNull(), + providerId: t.varchar({ length: 255 }).notNull(), + codeVerifier: t.varchar({ length: 128 }).notNull(), + nonce: t.varchar({ length: 128 }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + expiresAt: t.timestamp().notNull(), + }), + t => [index("core_sso_attempts_expires_at_idx").on(t.expiresAt)], +); + +/** + * A provider identity that authenticated but brought no trustworthy email. + * Waits, server-side, for the visitor to prove an address with a code before + * an account is created or linked. Single use; the browser holds only the + * token whose hash is `tokenHash`, in an HttpOnly cookie. + */ +export const core_sso_pending = camelCase.table.withRLS( + "core_sso_pending", + t => ({ + id: t.serial().primaryKey(), + tokenHash: t.varchar({ length: 64 }).notNull().unique(), + providerId: t.varchar({ length: 255 }).notNull(), + providerAccountId: t.varchar({ length: 255 }).notNull(), + providerUsername: t.varchar({ length: 255 }), + profile: t.jsonb().$type<{ + avatarUrl: null | string; + firstName: null | string; + lastName: null | string; + }>(), + email: t.varchar({ length: 255 }), + codeHash: t.varchar({ length: 64 }), + codeExpiresAt: t.timestamp(), + codeAttempts: t.integer().notNull().default(0), + codeSends: t.integer().notNull().default(0), + codeSentAt: t.timestamp(), + createdAt: t.timestamp().notNull().defaultNow(), + expiresAt: t.timestamp().notNull(), + }), + t => [index("core_sso_pending_expires_at_idx").on(t.expiresAt)], +); + export const core_users_confirm_emails = camelCase.table.withRLS( "core_users_confirm_emails", t => ({ diff --git a/packages/vitnode/src/emails/sso-email-code.tsx b/packages/vitnode/src/emails/sso-email-code.tsx new file mode 100644 index 0000000000..d0203b280d --- /dev/null +++ b/packages/vitnode/src/emails/sso-email-code.tsx @@ -0,0 +1,113 @@ +import { Hr, Section, Text } from "react-email"; +import { createTranslator } from "use-intl"; + +import DefaultTemplateEmail, { + type DefaultTemplateEmailProps, +} from "./default-template"; +import { + EmailCard, + EmailCardContent, + EmailCardDescription, + EmailCardFooter, + EmailCardHeader, + EmailCardTitle, +} from "./ui/card"; + +interface SsoEmailCodeTemplateProps extends DefaultTemplateEmailProps { + code: string; + expiryMinutes: number; + provider: string; + userIpAddress?: string; +} + +SsoEmailCodeTemplate.PreviewProps = { + ...DefaultTemplateEmail.PreviewProps, + code: "482913", + expiryMinutes: 10, + provider: "Reddit", + userIpAddress: "192.168.1.1", + i18n: { + ...DefaultTemplateEmail.PreviewProps.i18n, + messages: { + core: { + auth: { + sso_email_code: { + email: { + subject: "Your sign-in code: {code}", + title: "Confirm your email address", + intro: + "Someone signed in with {provider} and asked to use this address. Enter this code on the sign-in page to confirm it's yours:", + expire_time: + "The code expires in {minutes, plural, one {# minute} other {# minutes}}.", + no_action: + "If you didn't sign in with {provider}, ignore this email - without the code nobody can use your address.", + security_note: + "For your security, this request was made from IP address: {ip}", + }, + }, + }, + }, + }, + }, +} satisfies SsoEmailCodeTemplateProps; + +/** The one-time code that proves a visitor owns the address they typed after signing in with a provider that shared no verified email. */ +export default function SsoEmailCodeTemplate({ + code, + expiryMinutes, + i18n, + provider, + userIpAddress, + ...props +}: SsoEmailCodeTemplateProps) { + const t = createTranslator({ + ...i18n, + namespace: "core.auth.sso_email_code.email", + }); + + return ( + + + + {t("title")} + + {t("intro", { provider })} + + + + +
    + + {code} + +
    + + + {t("expire_time", { minutes: expiryMinutes })} + +
    + + +
    + + + {t("no_action", { provider })} + + + {userIpAddress && ( + + {t("security_note", { ip: userIpAddress })} + + )} +
    +
    +
    + ); +} diff --git a/packages/vitnode/src/locales/api/en.json b/packages/vitnode/src/locales/api/en.json index 577258b850..5ff52ae1b7 100644 --- a/packages/vitnode/src/locales/api/en.json +++ b/packages/vitnode/src/locales/api/en.json @@ -26,6 +26,16 @@ "help": "If you're having trouble with the button above, copy and paste the URL below into your web browser:", "expire_time": "This link will expire on {date}" } + }, + "sso_email_code": { + "email": { + "subject": "Your sign-in code: {code}", + "title": "Confirm your email address", + "intro": "Someone signed in with {provider} and asked to use this address. Enter this code on the sign-in page to confirm it's yours:", + "expire_time": "The code expires in {minutes, plural, one {# minute} other {# minutes}}.", + "no_action": "If you didn't sign in with {provider}, ignore this email - without the code nobody can use your address.", + "security_note": "For your security, this request was made from IP address: {ip}" + } } }, "notifications": { diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 7edd445d22..98beaa11c8 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -947,6 +947,42 @@ }, "connection": { "pending": "Finishing up with {provider}…" + }, + "email_completion": { + "title": "Confirm your email", + "desc": " didn't share a verified email address. Tell us which one to use and we'll send it a code.", + "email": { + "label": "Email", + "desc": "We'll send a 6-digit code here. Nothing is created until you confirm it.", + "invalid": "Please enter a valid email address.", + "submit": "Send code" + }, + "code": { + "label": "Code", + "sent": "We sent a 6-digit code to . Enter it below - it works for ten minutes.", + "invalid": "Enter the 6 digits from the email.", + "submit": "Confirm and continue", + "resend": "Send a new code", + "resent": "A new code is on its way.", + "change_email": "Use a different email" + }, + "errors": { + "invalid_code": "That code doesn't match. Check the email and try again.", + "code_expired": "This code has expired or was tried too many times. Send a new one.", + "too_many_codes": { + "title": "Slow down a little", + "desc": "Wait half a minute before asking for another code - and check your spam folder in the meantime." + }, + "invalid_pending": { + "title": "This sign-in has expired", + "desc": "Sign in with again to start over. Each attempt works once, for thirty minutes." + } + }, + "start_again": "Back to sign in" + }, + "email_unavailable": { + "title": "We need a verified email", + "desc": " didn't share a verified email address, and this site can't send one a confirmation code right now. Try another way to sign in." } }, "sign_up": { @@ -1354,6 +1390,10 @@ "nothing_to_sync": { "title": "Nothing to sync", "desc": "Check at least one field to keep up to date from {provider}." + }, + "reauthentication_required": { + "title": "Please sign in again", + "desc": "For your security, connecting {provider} needs a recent sign-in. Sign out, sign back in, and connect it within the hour." } }, "value_not_set": "Not set", diff --git a/packages/vitnode/src/tanstack/auth/actions.ts b/packages/vitnode/src/tanstack/auth/actions.ts index 8ec67e54eb..900aeacbb6 100644 --- a/packages/vitnode/src/tanstack/auth/actions.ts +++ b/packages/vitnode/src/tanstack/auth/actions.ts @@ -8,6 +8,7 @@ import type { PasskeySignInSubmit } from "@/views/auth/sign-in/passkey/passkey-s import type { SignUpSubmit } from "@/views/auth/sign-up/form/sign-up-form-content"; import type { SSOSelectProvider } from "@/views/auth/sso/buttons/sso-buttons-content"; import type { SSOCallbackResult } from "@/views/auth/sso/callback/sso-callback-result"; +import type { SSOEmailCompletionActions } from "@/views/auth/sso/email/sso-email-completion"; import type { SSOLinkSubmit } from "@/views/auth/sso/link/use-sso-link-form"; import type { ConfirmEmailSubmit, @@ -15,6 +16,10 @@ import type { } from "@/views/auth/verify-email/schema"; import { getPasskeyInBrowser } from "@/views/auth/passkeys/webauthn"; +import { + sendSsoEmailCodeInBrowser, + verifySsoEmailCodeInBrowser, +} from "@/views/auth/sso/email/sso-email-mutations"; import type { SsoCallbackInput } from "./contract"; @@ -167,6 +172,38 @@ export const useLinkSsoAction = ({ }; }; +/** + * The email step of an SSO sign-in whose provider shared no verified address: + * send a code, then confirm it. A confirmed code that signs the visitor in + * crosses the same identity boundary as any other sign-in. + */ +export const useSsoEmailCompletionActions = ({ + onSignedIn, + providerId, +}: { + onSignedIn: () => void; + providerId: string; +}): SSOEmailCompletionActions => { + const queryClient = useQueryClient(); + + return { + sendCode: async email => + await sendSsoEmailCodeInBrowser({ email, providerId }), + verifyCode: async code => { + const result = await verifySsoEmailCodeInBrowser({ code, providerId }); + + if (result.ok) { + removeAdminIdentityQueries(queryClient); + removeUserIdentityQueries(queryClient); + await invalidateSession(queryClient); + onSignedIn(); + } + + return result; + }, + }; +}; + export const useSignOutAction = () => { const queryClient = useQueryClient(); const router = useRouter(); diff --git a/packages/vitnode/src/tanstack/auth/contract.test.ts b/packages/vitnode/src/tanstack/auth/contract.test.ts index f30c05d3b2..ae38cf06b2 100644 --- a/packages/vitnode/src/tanstack/auth/contract.test.ts +++ b/packages/vitnode/src/tanstack/auth/contract.test.ts @@ -159,11 +159,30 @@ describe("SSO callback results", () => { [400, "invalid_state"], [404, "unknown_provider"], [409, "email_exists"], + [422, "email_unavailable"], [429, "server_error"], [500, "server_error"], ] as const)("reads %i as %s", (status, reason) => { expect(completeSsoResultFromStatus(status)).toEqual({ ok: false, reason }); }); + + it("reads 202 as an email to confirm, keeping only a well-formed suggestion", () => { + expect( + completeSsoResultFromStatus(202, { + email: "alice@example.test", + emailRequired: true, + }), + ).toEqual({ + email: "alice@example.test", + ok: false, + reason: "email_required", + }); + expect(completeSsoResultFromStatus(202, { email: 42 })).toEqual({ + email: null, + ok: false, + reason: "email_required", + }); + }); }); describe("SSO link offers", () => { diff --git a/packages/vitnode/src/tanstack/auth/contract.ts b/packages/vitnode/src/tanstack/auth/contract.ts index ce427fa357..6eebc08c58 100644 --- a/packages/vitnode/src/tanstack/auth/contract.ts +++ b/packages/vitnode/src/tanstack/auth/contract.ts @@ -41,6 +41,8 @@ export const ssoCallbackInputSchema = z.object({ code: z.string().min(1).max(2048), providerId: providerIdSchema, state: z.string().min(1).max(2048), + /** The profile a `form_post` provider (Apple) sent on a first authorization. */ + user: z.string().max(2048).optional(), }); export type SsoCallbackInput = z.infer; @@ -103,13 +105,23 @@ export const ssoLinkOfferSchema = z.object({ export type SsoLinkOffer = z.infer; export type CompleteSsoResult = + | { email: null | string; ok: false; reason: "email_required" } | { offer?: SsoLinkOffer; ok: false; reason: "email_exists" } | { ok: false; - reason: "invalid_state" | "server_error" | "unknown_provider"; + reason: + | "email_unavailable" + | "invalid_state" + | "server_error" + | "unknown_provider"; } | { ok: true }; +const emailRequiredSchema = z.object({ + email: z.string().max(320).nullable(), + emailRequired: z.literal(true), +}); + export const ssoLinkInputSchema = z.object({ password: z.string().min(1).max(1024), providerId: providerIdSchema, @@ -242,8 +254,18 @@ export const completeSsoResultFromStatus = ( body?: unknown, ): CompleteSsoResult => { if (status === 200) return { ok: true }; + if (status === 202) { + const parsed = emailRequiredSchema.safeParse(body); + + return { + email: parsed.success ? parsed.data.email : null, + ok: false, + reason: "email_required", + }; + } if (status === 400) return { ok: false, reason: "invalid_state" }; if (status === 404) return { ok: false, reason: "unknown_provider" }; + if (status === 422) return { ok: false, reason: "email_unavailable" }; if (status === 409) { const offer = ssoLinkOfferSchema.safeParse(body); @@ -276,6 +298,7 @@ const ssoCallbackQuerySchema = z.object({ code: z.string().optional(), error: z.string().optional(), state: z.string().optional(), + user: z.string().optional(), }); /** `URLSearchParams` and a plain search object, as one shape to validate. */ @@ -290,7 +313,9 @@ export const parseSsoCallback = ({ query: unknown; }): ParsedSsoCallback => { const parsedQuery = ssoCallbackQuerySchema.safeParse(asQueryRecord(query)); - const { code, error, state } = parsedQuery.success ? parsedQuery.data : {}; + const { code, error, state, user } = parsedQuery.success + ? parsedQuery.data + : {}; if (error !== undefined && error !== "") { return { @@ -299,7 +324,12 @@ export const parseSsoCallback = ({ }; } - const params = ssoCallbackInputSchema.safeParse({ code, providerId, state }); + const params = ssoCallbackInputSchema.safeParse({ + code, + providerId, + state, + ...(user ? { user } : {}), + }); if (!params.success) return { ok: false, reason: "invalid_callback" }; return { ok: true, params: params.data }; diff --git a/packages/vitnode/src/tanstack/auth/default-transport.ts b/packages/vitnode/src/tanstack/auth/default-transport.ts index aa4a5b6f21..e73c2b0bd7 100644 --- a/packages/vitnode/src/tanstack/auth/default-transport.ts +++ b/packages/vitnode/src/tanstack/auth/default-transport.ts @@ -25,7 +25,11 @@ const operations = createAuthOperations({ plugin: manifest.id, args: { params: { providerId: data.providerId }, - query: { code: data.code, state: data.state }, + query: { + code: data.code, + state: data.state, + ...(data.user ? { user: data.user } : {}), + }, }, method: "get", module: "users/sso", diff --git a/packages/vitnode/src/tanstack/auth/route-search.ts b/packages/vitnode/src/tanstack/auth/route-search.ts index 7c5728db0e..a6d2271bf1 100644 --- a/packages/vitnode/src/tanstack/auth/route-search.ts +++ b/packages/vitnode/src/tanstack/auth/route-search.ts @@ -19,6 +19,8 @@ export interface SsoCallbackSearch { code?: string; error?: string; state?: string; + /** Apple's first-authorization profile, relayed by the `form_post` bridge. */ + user?: string; } export const normalizeSsoCallbackSearch = ( @@ -27,4 +29,5 @@ export const normalizeSsoCallbackSearch = ( ...keptString("code", input.code), ...keptString("error", input.error), ...keptString("state", input.state), + ...keptString("user", input.user), }); diff --git a/packages/vitnode/src/tanstack/auth/screens.ts b/packages/vitnode/src/tanstack/auth/screens.ts index 562fb071ca..ecf742f2fe 100644 --- a/packages/vitnode/src/tanstack/auth/screens.ts +++ b/packages/vitnode/src/tanstack/auth/screens.ts @@ -41,6 +41,12 @@ export const ssoCallbackResult = ( ): SSOCallbackResult => { if (result.ok) return {}; + if (result.reason === "email_required") { + return { emailSuggestion: result.email, failure: "email_required" }; + } + if (result.reason === "email_unavailable") { + return { failure: "email_unavailable" }; + } if (result.reason !== "email_exists") return { failure: "unknown" }; return result.offer diff --git a/packages/vitnode/src/tanstack/auth/server.ts b/packages/vitnode/src/tanstack/auth/server.ts index 3d5af771fa..c02865d33a 100644 --- a/packages/vitnode/src/tanstack/auth/server.ts +++ b/packages/vitnode/src/tanstack/auth/server.ts @@ -30,7 +30,11 @@ const operations = createAuthOperations({ allowSaveCookies: true, args: { params: { providerId: data.providerId }, - query: { code: data.code, state: data.state }, + query: { + code: data.code, + state: data.state, + ...(data.user ? { user: data.user } : {}), + }, }, method: "get", module: "users/sso", diff --git a/packages/vitnode/src/tanstack/auth/sso-screen.tsx b/packages/vitnode/src/tanstack/auth/sso-screen.tsx index 7925fa69b2..b9592605c7 100644 --- a/packages/vitnode/src/tanstack/auth/sso-screen.tsx +++ b/packages/vitnode/src/tanstack/auth/sso-screen.tsx @@ -13,7 +13,11 @@ import { useSSOCallback } from "@/views/auth/sso/callback/use-sso-callback"; import { RouteMessages } from "../i18n/route-messages"; import { ssoSettingsHref } from "../sso-connections/route-search"; -import { useCompleteSsoAction, useLinkSsoAction } from "./actions"; +import { + useCompleteSsoAction, + useLinkSsoAction, + useSsoEmailCompletionActions, +} from "./actions"; import { parseSsoCallback } from "./contract"; import { authMethodsOf, @@ -28,7 +32,7 @@ export interface SsoCallbackRouteProps { /** The "go back" / "go home" pair a host renders on a dead-end screen. */ errorActions: React.ReactNode; providerId: string; - search: { code?: string; error?: string; state?: string }; + search: { code?: string; error?: string; state?: string; user?: string }; } const SsoConnectionCallbackPending = ({ @@ -151,6 +155,10 @@ const SsoSignInCallbackRoute = ({ ); }; const linkSso = useLinkSsoAction({ onSignedIn, providerId }); + const emailCompletion = useSsoEmailCompletionActions({ + onSignedIn, + providerId, + }); const state = useSSOCallback({ code: parsed.ok ? parsed.params.code : "", @@ -163,6 +171,7 @@ const SsoSignInCallbackRoute = ({ return ( ( if (!response) return { ok: false, reason: "server_error" }; - if (response.status === 409) { - return completeSsoResultFromStatus(409, await readJson(response)); + if (response.status === 409 || response.status === 202) { + return completeSsoResultFromStatus( + response.status, + await readJson(response), + ); } return completeSsoResultFromStatus(response.status); diff --git a/packages/vitnode/src/tanstack/start/create-start.ts b/packages/vitnode/src/tanstack/start/create-start.ts index 77e8f91e22..7f7b42b18e 100644 --- a/packages/vitnode/src/tanstack/start/create-start.ts +++ b/packages/vitnode/src/tanstack/start/create-start.ts @@ -1,6 +1,10 @@ import type { AnyRequestMiddleware } from "@tanstack/react-start"; -import { createCsrfMiddleware, createStart } from "@tanstack/react-start"; +import { + createCsrfMiddleware, + createMiddleware, + createStart, +} from "@tanstack/react-start"; import type { LocaleConfig } from "@/lib/i18n/types"; import type { VitNodeConfig } from "@/vitnode.config"; @@ -11,6 +15,7 @@ import type { DocumentSecurityHeaders } from "./document-headers"; import { resolveDocumentSecurityHeaders } from "./document-headers"; import { createLocaleRequestMiddleware } from "./locale-middleware"; +import { runSsoFormPost } from "./sso-form-post"; export interface VitNodeStartOptions< AppLocales extends LocaleConfig[] = LocaleConfig[], @@ -30,7 +35,8 @@ export interface VitNodeStartOptions< /** * The app's TanStack Start instance with VitNode's request middleware: CSRF - * protection for server functions; locale redirects and the locale cookie; + * protection for server functions; a provider's `form_post` sign-in callback + * (Apple) turned into the `GET` the callback page reads; locale redirects and the locale cookie; * `private, no-store` caching and security headers on HTML documents (the * page cache sets its own cache headers on pages it serves); then * `requestMiddleware`. @@ -56,6 +62,7 @@ export const createVitNodeStart = ({ return createStart(() => ({ requestMiddleware: [ createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }), + createMiddleware().server(runSsoFormPost), createLocaleRequestMiddleware(localeRouting, documentHeaders), ...requestMiddleware, ], diff --git a/packages/vitnode/src/tanstack/start/sso-form-post.test.ts b/packages/vitnode/src/tanstack/start/sso-form-post.test.ts new file mode 100644 index 0000000000..1a89a51312 --- /dev/null +++ b/packages/vitnode/src/tanstack/start/sso-form-post.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +import { runSsoFormPost } from "./sso-form-post"; + +type Context = Parameters[0]; + +const post = async ( + path: string, + fields: Record, + { + contentType = "application/x-www-form-urlencoded", + handlerType = "router", + }: { contentType?: string; handlerType?: string } = {}, +) => { + const next = vi.fn(async () => + Promise.resolve({ response: new Response("page") }), + ); + const result = await runSsoFormPost({ + handlerType: handlerType as Context["handlerType"], + next: next as unknown as Context["next"], + request: new Request(`https://forum.example${path}`, { + body: new URLSearchParams(fields), + headers: { "content-type": contentType }, + method: "POST", + }), + }); + + return { next, response: result instanceof Response ? result : null }; +}; + +describe("Apple's form_post sign-in callback", () => { + it("becomes a same-URL GET carrying the code and state", async () => { + const { next, response } = await post("/login/sso/apple", { + code: "c-1", + id_token: "eyJ.secret.token", + state: "s-1", + }); + + expect(next).not.toHaveBeenCalled(); + expect(response?.status).toBe(303); + expect(response?.headers.get("location")).toBe( + "/login/sso/apple?code=c-1&state=s-1", + ); + expect(response?.headers.get("cache-control")).toBe("no-store"); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("keeps only the name from Apple's `user`, never the unsigned email", async () => { + const { response } = await post("/login/sso/apple", { + code: "c-1", + state: "s-1", + user: JSON.stringify({ + email: "attacker@example.test", + name: { firstName: "Alice", lastName: "Smith" }, + }), + }); + const user = new URL( + response?.headers.get("location") ?? "", + "https://forum.example", + ).searchParams.get("user"); + + expect(JSON.parse(user ?? "null")).toEqual({ + name: { firstName: "Alice", lastName: "Smith" }, + }); + }); + + it("drops a `user` that is not the JSON Apple sends", async () => { + const { response } = await post("/login/sso/apple", { + code: "c-1", + state: "s-1", + user: "