From af45b5626930385404287b4897553bab3719df00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:56:50 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(plugins):=20=E2=9C=A8=20daily=20update?= =?UTF-8?q?=20checks=20and=20security=20advisories=20from=20public=20JSON?= =?UTF-8?q?=20manifests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of plugin management. Core and every plugin that declares `vitnode.updates.url` in its package.json are checked once a day (and on demand) against public update manifests, never npm. Results appear in AdminCP → System → Plugins; nothing is installed or changed. - Versioned Zod contract for update manifests (stable/rc/canary channels, releases, advisories), shared by the server and the release tooling - `updates` carried through manifest.gen.ts and the frontend inventory - Server-only checker: one fetch per distinct source, HTTPS-only with SSRF guards on every hop and at connect time, timeout, streamed size limit, bounded concurrency, ETag/Last-Modified with 304 reuse - SemVer evaluation of updates, advisories (prereleases included), core compatibility and an older compatible target; API and web app versions evaluated separately - Persistence in core_update_sources / core_update_results with failure isolation, preserved findings and stale detection after redeploys - Atomic database lease shared by the daily cron job and the manual `POST /admin/plugins/updates/check` (plugins.can_check_updates), with expiry, owner-only release and a manual cooldown - AdminCP core card, update/compatibility/failure states, red security warnings, frontend inventory notice and a Check for updates button - Release workflow publishes updates.json to the `update-manifest` branch after npm publication; advisories come from security/advisories.json - Documentation under Development → Updates & Security Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01412WzAxDRk5MbUe4C2S2N1 --- .github/workflows/bump_publish.yml | 58 +- .github/workflows/publish-advisories.yml | 50 + .../migration.sql | 33 + .../snapshot.json | 20146 ++++++++++++++++ apps/web/content/docs/dev/meta.json | 1 + apps/web/content/docs/dev/plugins/catalog.mdx | 8 +- .../dev/updates/admin-plugin-updates.webp | Bin 0 -> 129954 bytes apps/web/content/docs/dev/updates/admin.mdx | 81 + apps/web/content/docs/dev/updates/index.mdx | 25 + .../web/content/docs/dev/updates/manifest.mdx | 86 + apps/web/content/docs/dev/updates/meta.json | 14 + .../docs/dev/updates/official-releases.mdx | 55 + .../docs/dev/updates/plugin-authors.mdx | 99 + .../docs/dev/updates/security-advisories.mdx | 50 + apps/web/src/locales/@vitnode/core/pl.json | 108 +- apps/web/src/plugin-inventory.gen.ts | 3 + package.json | 1 + packages/vitnode/package.json | 7 + .../vitnode/scripts/update-manifest/merge.ts | 257 + packages/vitnode/src/api/config.ts | 1 + packages/vitnode/src/api/lib/updates/check.ts | 350 + .../vitnode/src/api/lib/updates/components.ts | 65 + .../vitnode/src/api/lib/updates/config.ts | 24 + .../vitnode/src/api/lib/updates/context.ts | 21 + .../vitnode/src/api/lib/updates/evaluate.ts | 218 + .../src/api/lib/updates/fetch-source.ts | 315 + packages/vitnode/src/api/lib/updates/lock.ts | 152 + .../vitnode/src/api/lib/updates/status.ts | 151 + .../src/api/middlewares/global.middleware.ts | 26 + .../admin/plugins/plugins.admin.module.ts | 8 +- .../plugins/routes/check-updates.route.ts | 80 + .../admin/plugins/routes/updates.route.ts | 53 + .../admin/plugins/routes/updates.schema.ts | 73 + .../src/api/modules/cron/cron.module.ts | 3 +- .../modules/cron/cron/check-updates.cron.ts | 29 + packages/vitnode/src/api/plugin.ts | 5 +- packages/vitnode/src/database/updates.ts | 65 + .../framework/plugin-inventory/generate.ts | 18 + .../src/framework/plugin-manifest/generate.ts | 39 +- packages/vitnode/src/lib/plugin-manifest.ts | 20 +- packages/vitnode/src/lib/updates/manifest.ts | 212 + .../vitnode/src/lib/updates/source-url.ts | 36 + packages/vitnode/src/locales/en.json | 104 + packages/vitnode/src/manifest.ts | 3 + .../src/tanstack/admin/plugins/index.ts | 8 + .../src/tanstack/admin/plugins/route.tsx | 15 +- .../src/tanstack/admin/plugins/screen.tsx | 21 +- .../system/plugins/check-updates-button.tsx | 83 + .../core/system/plugins/core-update-card.tsx | 104 + .../system/plugins/plugin-update-status.tsx | 357 + .../system/plugins/plugin-updates-query.ts | 85 + .../core/system/plugins/plugins-content.tsx | 80 +- packages/vitnode/src/vitnode.config.ts | 16 + plugins/blog/package.json | 5 + plugins/blog/src/manifest.gen.ts | 4 +- plugins/example/src/manifest.gen.ts | 4 +- plugins/forum/src/manifest.gen.ts | 4 +- pnpm-lock.yaml | 11 + scripts/bump-version/version-manager.ts | 5 + .../publish-update-manifest.ts | 331 + security/README.md | 7 + security/advisories.json | 4 + 62 files changed, 24269 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/publish-advisories.yml create mode 100644 apps/api/migrations/20261015193600_add_core_update_checks/migration.sql create mode 100644 apps/api/migrations/20261015193600_add_core_update_checks/snapshot.json create mode 100644 apps/web/content/docs/dev/updates/admin-plugin-updates.webp create mode 100644 apps/web/content/docs/dev/updates/admin.mdx create mode 100644 apps/web/content/docs/dev/updates/index.mdx create mode 100644 apps/web/content/docs/dev/updates/manifest.mdx create mode 100644 apps/web/content/docs/dev/updates/meta.json create mode 100644 apps/web/content/docs/dev/updates/official-releases.mdx create mode 100644 apps/web/content/docs/dev/updates/plugin-authors.mdx create mode 100644 apps/web/content/docs/dev/updates/security-advisories.mdx create mode 100644 packages/vitnode/scripts/update-manifest/merge.ts create mode 100644 packages/vitnode/src/api/lib/updates/check.ts create mode 100644 packages/vitnode/src/api/lib/updates/components.ts create mode 100644 packages/vitnode/src/api/lib/updates/config.ts create mode 100644 packages/vitnode/src/api/lib/updates/context.ts create mode 100644 packages/vitnode/src/api/lib/updates/evaluate.ts create mode 100644 packages/vitnode/src/api/lib/updates/fetch-source.ts create mode 100644 packages/vitnode/src/api/lib/updates/lock.ts create mode 100644 packages/vitnode/src/api/lib/updates/status.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts create mode 100644 packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts create mode 100644 packages/vitnode/src/database/updates.ts create mode 100644 packages/vitnode/src/lib/updates/manifest.ts create mode 100644 packages/vitnode/src/lib/updates/source-url.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts create mode 100644 scripts/update-manifest/publish-update-manifest.ts create mode 100644 security/README.md create mode 100644 security/advisories.json diff --git a/.github/workflows/bump_publish.yml b/.github/workflows/bump_publish.yml index 2c87e3e84..b5633b9c1 100644 --- a/.github/workflows/bump_publish.yml +++ b/.github/workflows/bump_publish.yml @@ -32,6 +32,11 @@ on: required: false type: boolean +env: + # Every package this workflow publishes. The publish steps and the update + # manifest both read it, so the manifest never names a package npm did not get. + PUBLISHED_PACKAGES: "create-vitnode-app @vitnode/core @vitnode/config @vitnode/blog @vitnode/nodemailer @vitnode/resend @vitnode/node-cron" + jobs: bump-version: name: "Bump Version" @@ -39,6 +44,8 @@ jobs: permissions: contents: write id-token: write + outputs: + sha: ${{ steps.published.outputs.sha }} steps: - name: Checkout @@ -83,21 +90,21 @@ jobs: - name: Publish canary if: ${{ (github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish') && github.event.inputs.release == 'canary' }} - run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --filter @vitnode/nodemailer --filter @vitnode/resend --filter @vitnode/node-cron --tag canary --no-git-checks --access public + run: pnpm publish $(printf -- "--filter %s " $PUBLISHED_PACKAGES) --tag canary --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - name: Publish release candidate if: ${{ (github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish') && github.event.inputs.release == 'release-candidate' }} - run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --filter @vitnode/nodemailer --filter @vitnode/resend --filter @vitnode/node-cron --tag rc --no-git-checks --access public + run: pnpm publish $(printf -- "--filter %s " $PUBLISHED_PACKAGES) --tag rc --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - name: Publish stable if: ${{ (github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish') && github.event.inputs.release == 'stable' }} - run: pnpm publish --filter create-vitnode-app --filter @vitnode/core --filter @vitnode/config --filter @vitnode/blog --filter @vitnode/nodemailer --filter @vitnode/resend --filter @vitnode/node-cron --tag latest --no-git-checks --access public + run: pnpm publish $(printf -- "--filter %s " $PUBLISHED_PACKAGES) --tag latest --no-git-checks --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true @@ -113,3 +120,48 @@ jobs: run: gh release create ${{ steps.version-bump.outputs.newTag }} --generate-notes env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Remember the published commit + id: published + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + publish-update-manifest: + name: "Publish update manifest" + needs: bump-version + # Only after npm has the packages: a bump-only run publishes nothing, so it + # advertises nothing. + if: ${{ github.event.inputs.mode == 'bump_and_publish' || github.event.inputs.mode == 'publish' }} + runs-on: ubuntu-latest + permissions: + contents: write + # Shared with the advisories workflow: one writer of updates.json at a time. + concurrency: + group: vitnode-update-manifest + cancel-in-progress: false + + steps: + - name: Checkout the published commit + uses: actions/checkout@v7 + with: + ref: ${{ needs.bump-version.outputs.sha }} + + - uses: pnpm/action-setup@v6.1.0 + name: Install pnpm + with: + version: 11.9.0 + + - name: Install Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Add the published versions to updates.json + run: pnpm run release:update-manifest release + env: + RELEASE_TYPE: ${{ github.event.inputs.release }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-advisories.yml b/.github/workflows/publish-advisories.yml new file mode 100644 index 000000000..7f5b9410a --- /dev/null +++ b/.github/workflows/publish-advisories.yml @@ -0,0 +1,50 @@ +name: Publish security advisories + +on: + pull_request: + paths: + - "security/advisories.json" + push: + branches: + - canary + paths: + - "security/advisories.json" + workflow_dispatch: + +jobs: + advisories: + name: "Validate and publish advisories" + runs-on: ubuntu-latest + permissions: + contents: write + # Shared with the release workflow: one writer of updates.json at a time. + concurrency: + group: vitnode-update-manifest + cancel-in-progress: false + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6.1.0 + name: Install pnpm + with: + version: 11.9.0 + + - name: Install Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate security/advisories.json + run: pnpm run release:update-manifest check + + - name: Publish advisories to updates.json + if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/canary' }} + run: pnpm run release:update-manifest advisories + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/api/migrations/20261015193600_add_core_update_checks/migration.sql b/apps/api/migrations/20261015193600_add_core_update_checks/migration.sql new file mode 100644 index 000000000..4728618bb --- /dev/null +++ b/apps/api/migrations/20261015193600_add_core_update_checks/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE "core_update_lock" ( + "name" varchar(50) PRIMARY KEY, + "owner" varchar(64), + "expiresAt" timestamp, + "lastManualAt" timestamp +); +--> statement-breakpoint +ALTER TABLE "core_update_lock" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_update_results" ( + "componentId" varchar(214) PRIMARY KEY, + "sourceUrl" varchar(2048) NOT NULL, + "channel" varchar(20), + "versions" jsonb NOT NULL, + "hasUnknownVersion" boolean DEFAULT false NOT NULL, + "coreVersion" varchar(256), + "result" jsonb, + "lastAttemptAt" timestamp NOT NULL, + "lastSuccessAt" timestamp, + "lastError" jsonb +); +--> statement-breakpoint +ALTER TABLE "core_update_results" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "core_update_sources" ( + "url" varchar(2048) PRIMARY KEY, + "etag" varchar(512), + "lastModified" varchar(128), + "document" jsonb, + "lastAttemptAt" timestamp NOT NULL, + "lastSuccessAt" timestamp, + "lastError" jsonb +); +--> statement-breakpoint +ALTER TABLE "core_update_sources" ENABLE ROW LEVEL SECURITY; \ No newline at end of file diff --git a/apps/api/migrations/20261015193600_add_core_update_checks/snapshot.json b/apps/api/migrations/20261015193600_add_core_update_checks/snapshot.json new file mode 100644 index 000000000..ae418ac89 --- /dev/null +++ b/apps/api/migrations/20261015193600_add_core_update_checks/snapshot.json @@ -0,0 +1,20146 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "62c3a2be-f65f-40be-94cf-ae96f691a04c", + "prevIds": [ + "efc06477-7761-4b25-85d1-ede72f9306ce" + ], + "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_reports", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_moderators_permissions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": true, + "name": "core_navigation", + "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_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_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": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "parentId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'header'", + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(50)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pluginId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(120)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "presetId", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "href", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "icon", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isOpenInNewTab", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "position", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "createdAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updatedAt", + "entityType": "columns", + "schema": "public", + "table": "core_navigation" + }, + { + "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_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": "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": "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": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "excerpt", + "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": "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": "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 + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_files_user_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": "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": "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": "position", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_position_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "location", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_location_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "parentId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "core_navigation_parent_idx", + "entityType": "indexes", + "schema": "public", + "table": "core_navigation" + }, + { + "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": "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": "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": "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": "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_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": "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": [ + "parentId" + ], + "schemaTo": "public", + "tableTo": "core_navigation", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "core_navigation_parentId_core_navigation_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "core_navigation" + }, + { + "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_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_navigation_pkey", + "schema": "public", + "table": "core_navigation", + "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_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": [ + "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": [ + "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/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 842226225..f3583f4d6 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -42,6 +42,7 @@ "websocket", "advanced", "---Operate---", + "updates", "debugging", "swagger", "contribution", diff --git a/apps/web/content/docs/dev/plugins/catalog.mdx b/apps/web/content/docs/dev/plugins/catalog.mdx index fccafdf55..dcf775a53 100644 --- a/apps/web/content/docs/dev/plugins/catalog.mdx +++ b/apps/web/content/docs/dev/plugins/catalog.mdx @@ -16,7 +16,7 @@ import catalog from './admin-plugins-catalog.webp' withoutBackground /> -The catalog only reports what is installed. It does not check for updates, install or remove plugins, or switch them on and off. One thing at a time. +The catalog reports what is installed. It also shows available updates and security advisories for core and for plugins that declare an update source - see [Update checks](/docs/dev/updates). It never installs or removes plugins, or switches them on and off. ## Add your plugin to the catalog @@ -155,6 +155,10 @@ A side that reports no version is unknown, not a mismatch. A frontend inventory entry carrying the manifest of another package throws at startup: the API checks that `manifest.id` equals the entry's `id`. +## Update and security information + +Add `vitnode.updates.url` to your plugin's `package.json` and the catalog row also shows new releases and security advisories. Setup is covered in [Publish updates for your plugin](/docs/dev/updates/plugin-authors); what administrators see is in [Check for updates in the AdminCP](/docs/dev/updates/admin). + ## Who can see the catalog -The page and its endpoint, `GET /api/@vitnode/core/admin/plugins`, require the **Plugins → View installed plugins and their versions** permission (`plugins.can_view` in the `@vitnode/core` module). Staff without it don't see the link, get a not-found page on a direct visit, and a `403` from the API. Root administrators have it automatically. +The page and its endpoint, `GET /api/@vitnode/core/admin/plugins`, require the **Plugins → View installed plugins and their versions** permission (`plugins.can_view` in the `@vitnode/core` module). Staff without it don't see the link, get a not-found page on a direct visit, and a `403` from the API. Root administrators have it automatically. The **Check for updates** button additionally needs `plugins.can_check_updates`. diff --git a/apps/web/content/docs/dev/updates/admin-plugin-updates.webp b/apps/web/content/docs/dev/updates/admin-plugin-updates.webp new file mode 100644 index 0000000000000000000000000000000000000000..1ce3d25552fa1ded70ae46ccbc35247a100d1ec3 GIT binary patch literal 129954 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 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/updates/admin.mdx b/apps/web/content/docs/dev/updates/admin.mdx new file mode 100644 index 000000000..c5f200afd --- /dev/null +++ b/apps/web/content/docs/dev/updates/admin.mdx @@ -0,0 +1,81 @@ +--- +title: Check for updates in the AdminCP +description: Where VitNode shows available updates and security advisories, how the daily check and the Check for updates button work, and what each status means. +icon: ShieldCheck +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import pluginUpdates from './admin-plugin-updates.webp' + +**AdminCP → System → Plugins** shows, next to every installed version, whether a newer release is published and whether a security advisory affects it. Core gets its own card above the plugin list. + + + +The advisory in the screenshot is illustrative demo data. + +## Before you begin + +- A [cron adapter or external trigger](/docs/dev/cron) must be running. The daily check is a cron job; without a scheduler nothing is checked automatically. +- The API server needs outbound HTTPS (port 443) to the update manifests - for VitNode itself, `raw.githubusercontent.com`. Requests go out directly; HTTP proxy variables such as `HTTPS_PROXY` are not used. +- Run `vitnode db migrate` after upgrading. The check stores its results in three small tables: `core_update_sources`, `core_update_results` and `core_update_lock`. + +## The daily check + +The cron job `@vitnode/core:cron:check_updates` runs at **03:17 every day in the API server's time zone** (the `TZ` environment variable - UTC on most hosts and containers). A newly registered job has never run, so the first scheduler tick after you deploy runs the first check. Until then every row says _Not checked yet_. + +Each run fetches every distinct manifest once, even when several plugins share it, with a 10 second timeout, a 1 MB size limit and at most four sources at a time. Opening the catalog never contacts a publisher - it only reads stored results. + +## Check now + +Staff with **Plugins → Check for plugin and core updates now** (`plugins.can_check_updates`) see a **Check for updates** button. It runs the same check as the cron job and refreshes the page when it finishes. + +- Only one check runs at a time across all API instances. A second click while one runs says _A check is already running_. +- A manual check can run once every two minutes. A click inside that window says how many seconds to wait. +- The endpoint is `POST /api/@vitnode/core/admin/plugins/updates/check`; it answers `409` while a check runs and `429` during the cooldown. + +Viewing the results only needs **Plugins → View installed plugins and their versions** (`plugins.can_view`). + +## Read the results + +| Status | What it means | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Not checked yet | No check has covered this package yet. | +| Up to date | Every version you run is at or above the channel's newest release. | +| Update available: 2.1.3 | A newer release is on your channel. Release notes link to it when the publisher gave one. | +| Requires a newer core | The release declares a core range your core is outside. If an older release on the channel fits, it is named. | +| Compatibility unknown | The publisher did not say which core the release needs. VitNode does not guess. | +| Update checking unavailable | The package declares no update source. | +| Last check failed | The source could not be read. The last good result stays visible, with its date. | +| Not checked for this deployment yet | You redeployed, changed channel or upgraded core since the last check. Results are recomputed from the stored manifest until the next check confirms them. | + +When the web app and the API run different versions of a plugin, the existing mismatch warning stays, and both versions are checked. A vulnerable web app version is reported even if the API's version is fine. + +## Security warnings + +A red **Security advisory** box with a shield icon appears when a version you run is inside an advisory's affected range. It names the advisory, its severity, the affected version - with _(API)_ or _(web app)_ when they differ - and either _Fixed in 2.1.3_ or _No fixed version has been published yet_. **Advisory details** opens the publisher's page. + +Advisories are checked even when your channel has no newer release. A patch release on its own is never shown as a security fix; only a matching advisory is. + +## Choose a channel + +By default each package follows the channel of the version you run: `2.1.0` is **stable**, `2.1.0-rc.1` is **rc** and `2.1.0-canary.4` is **canary**. VitNode never moves you between channels or suggests a downgrade. To follow another channel on purpose, set it in the API config: + +```ts title="src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + updates: { + channel: 'stable', + }, + // ...the rest of your API config +}) +``` + +An install without outbound network access can switch checks off with `updates: { enabled: false }`. The catalog then says so instead of showing results. + +## Check the result + +Open **System → Plugins** and press **Check for updates**. A toast reports how many updates and advisories were found, and the core card shows _Last checked_ with the current time. diff --git a/apps/web/content/docs/dev/updates/index.mdx b/apps/web/content/docs/dev/updates/index.mdx new file mode 100644 index 000000000..4e6a1ec7b --- /dev/null +++ b/apps/web/content/docs/dev/updates/index.mdx @@ -0,0 +1,25 @@ +--- +title: Update checks +description: How VitNode tells administrators about new versions and security advisories for core and plugins, using public JSON manifests checked once a day. +icon: RefreshCw +--- + +VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security advisory. The results appear in **AdminCP → System → Plugins**. + +Each package says where its **update manifest** lives - a public JSON file on GitHub Raw or any other HTTPS host. VitNode reads those files from the server. It does not ask npm, and it does not install, download or change anything. + +## Pick a guide + +| You want to... | Read | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------- | +| See updates and security warnings, run a check, choose a channel | [Check for updates in the AdminCP](/docs/dev/updates/admin) | +| Make your own plugin report updates | [Publish updates for your plugin](/docs/dev/updates/plugin-authors) | +| Look up every field of the JSON format | [Update manifest reference](/docs/dev/updates/manifest) | +| Warn installations about a vulnerability | [Publish a security advisory](/docs/dev/updates/security-advisories) | +| Understand how VitNode's own release workflow updates the official feed | [Official release feed](/docs/dev/updates/official-releases) | + +## What the checks can and cannot tell you + +- **Informational only.** A check never installs an update or edits your dependencies. You upgrade the way you always do, then rebuild and redeploy. +- **Only what publishers declare.** A package without an update source shows _Update checking unavailable_. Private and local plugins work exactly as before. +- **Not a vulnerability scanner.** A security warning means a publisher listed your installed version in an advisory. No warning is not proof a version is secure, and dependencies of your plugins are not checked at all. Keep using `pnpm audit` or your usual scanner for those. diff --git a/apps/web/content/docs/dev/updates/manifest.mdx b/apps/web/content/docs/dev/updates/manifest.mdx new file mode 100644 index 000000000..e32ae24c6 --- /dev/null +++ b/apps/web/content/docs/dev/updates/manifest.mdx @@ -0,0 +1,86 @@ +--- +title: Update manifest reference +description: Every field of the VitNode update manifest JSON - packages, channels, releases and advisories - with validation rules and how versions are compared. +icon: FileCode +--- + +An update manifest is one public JSON file per publisher. It can describe any number of packages. VitNode validates it completely before using any of it; an invalid or unsupported file is reported as a failed check, and the last valid copy keeps being used. + +## Shape + +```json title="updates.json" +{ + "schemaVersion": 1, + "packages": { + "@acme/site-notes": { + "channels": { + "stable": "2.1.3", + "canary": "2.2.0-canary.4" + }, + "releases": { + "2.1.3": { + "publishedAt": "2026-10-15T12:00:00Z", + "requiresCore": ">=2.1.0 <3.0.0", + "url": "https://github.com/acme/site-notes/releases/tag/v2.1.3" + }, + "2.2.0-canary.4": {} + }, + "advisories": [ + { + "id": "NOTES-2026-001", + "severity": "high", + "affected": ">=2.0.0 <2.1.3", + "fixed": "2.1.3", + "url": "https://github.com/acme/site-notes/security/advisories/GHSA-xxxx-xxxx-xxxx", + "summary": "Stored XSS in note titles." + } + ] + } + } +} +``` + +The values above are illustrative, not a real release or vulnerability. + +## Top level + +| Name | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------- | +| `schemaVersion` | `1` | Yes | Format version. Any other value fails with _uses a format this VitNode does not support_. | +| `packages` | object | Yes | Keyed by npm package name - the plugin's manifest ID. At most 500 packages. Unknown keys anywhere are rejected. | + +## Package + +| Name | Type | Required | Default | Description | +| ------------ | ------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `channels` | object | No | `{}` | `stable`, `rc` and/or `canary`, each a version. Every one must have an entry in `releases`. `stable` cannot be a prerelease. A package does not have to publish every channel. | +| `releases` | object | No | `{}` | Keyed by exact SemVer version. At most 2000. | +| `advisories` | array | No | `[]` | Independent of channels and releases. At most 500, with unique `id`s. | + +## Release + +| Name | Type | Required | Description | +| -------------- | -------------- | -------- | ------------------------------------------------------------------------------------------- | +| `publishedAt` | ISO 8601 date | No | Shown as _released ..._. Informational only. | +| `requiresCore` | SemVer range | No | The `@vitnode/core` versions this release supports. Missing means compatibility is unknown. | +| `url` | `https://` URL | No | Shown as **Release notes**. | + +## Advisory + +| Name | Type | Required | Description | +| ---------- | ------------------------------------------- | -------- | ------------------------------------------------------------ | +| `id` | string | Yes | Letters, digits, `.`, `_`, `:` or `-`, up to 100 characters. | +| `severity` | `low` \| `moderate` \| `high` \| `critical` | Yes | Shown in the warning title. | +| `affected` | SemVer range | Yes | Installed versions inside this range get the red warning. | +| `fixed` | SemVer version | No | Leave it out when no fix exists yet. | +| `url` | `https://` URL | Yes | Shown as **Advisory details**. | +| `summary` | string, up to 500 characters | No | Shown as plain text under the warning. | + +Text and links are treated as untrusted: text is rendered as text, and only `https://` links become clickable. + +## How versions are compared + +- Comparisons use SemVer, not string order: `2.10.0` is newer than `2.9.0`, and `2.1.0-canary.4` is older than `2.1.0`. +- The selected channel is the installation's `updates.channel`, or else the channel of the installed version. An installed version newer than the channel's target is never offered a "downgrade". +- `requiresCore` is checked against the running core version. When the channel's newest release needs a newer core, VitNode names the newest release on the same channel that is newer than yours and explicitly declares support for your core, if one is listed. +- Advisory ranges include prereleases: see [how ranges match prereleases](/docs/dev/updates/security-advisories#how-affected-ranges-match-prereleases). diff --git a/apps/web/content/docs/dev/updates/meta.json b/apps/web/content/docs/dev/updates/meta.json new file mode 100644 index 000000000..1b95e09a0 --- /dev/null +++ b/apps/web/content/docs/dev/updates/meta.json @@ -0,0 +1,14 @@ +{ + "title": "Updates & Security", + "description": "Daily update checks and security advisories for VitNode core and plugins, read from public JSON manifests", + "icon": "RefreshCw", + "pages": [ + "index", + "admin", + "---Publish updates---", + "plugin-authors", + "manifest", + "security-advisories", + "official-releases" + ] +} diff --git a/apps/web/content/docs/dev/updates/official-releases.mdx b/apps/web/content/docs/dev/updates/official-releases.mdx new file mode 100644 index 000000000..f028a127c --- /dev/null +++ b/apps/web/content/docs/dev/updates/official-releases.mdx @@ -0,0 +1,55 @@ +--- +title: Official release feed +description: How VitNode's release workflow publishes the official update manifest after npm publication, where publishedAt comes from, and how reruns and advisories are handled. +icon: Workflow +--- + +VitNode core and the official plugins read the feed at `https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json`. A GitHub Actions job writes it after every successful npm publish. Maintainers never edit it by hand. + +## How a release updates the feed + +The **Bump and Publish npm packages** workflow has a **Publish update manifest** job. It runs only in the `bump_and_publish` and `publish` modes, and only after the publish job succeeds - a `bump` run publishes nothing, so it advertises nothing. + +1. It checks out the exact commit that was published. +2. From `PUBLISHED_PACKAGES` - the same list the publish steps use - it keeps the packages whose `package.json` declares this feed as `vitnode.updates.url` (today `@vitnode/core` and `@vitnode/blog`). +3. It reads each version from `package.json`. A plugin's `@vitnode/core` dependency becomes the release's `requiresCore`, resolving `workspace:*` the way pnpm does on publish. +4. It adds the releases and moves only the channel of this run: **canary**, **release-candidate** (`rc`) or **stable**. A channel never moves backwards. +5. It commits `updates.json` to the `update-manifest` branch. That branch exists only for the feed, so it outlives release branches. + +## Where publishedAt comes from + +`publishedAt` and the release `url` come from the GitHub Release tagged `v` (`gh release view`). Nobody types a date. When the run skipped release notes (`skip_release_github_patch_notes`) or the release is still a draft, both fields are left out - the date is never invented, and the time of the workflow run is never used instead. + +## Reruns and partial failures + +- **Reruns are idempotent.** An existing release entry keeps its `publishedAt` and `url`; a rerun only fills in fields that were missing, for example after the GitHub Release was created later. A run that changes nothing pushes nothing. +- **Concurrent runs** share the `vitnode-update-manifest` concurrency group, and every push is fetched, merged and retried if another run got there first. +- **If npm publishing fails**, the job does not run. Fix the cause and re-run the workflow in `publish` mode. +- **If only the manifest job fails**, use **Re-run failed jobs**: it publishes the same commit again. + +## Add an advisory to the official feed + +Advisories are not part of releases. Edit `security/advisories.json` in a pull request: + +```json title="security/advisories.json" +{ + "schemaVersion": 1, + "advisories": { + "@vitnode/blog": [ + { + "id": "VITNODE-BLOG-2026-001", + "severity": "high", + "affected": ">=2.0.0 <2.1.3", + "fixed": "2.1.3", + "url": "https://github.com/aXenDeveloper/vitnode/security/advisories/GHSA-xxxx-xxxx-xxxx" + } + ] + } +} +``` + +The **Publish security advisories** workflow validates the file on the pull request. After it merges into `canary`, the workflow replaces the advisories in `updates.json` with exactly what the file lists; releases and channels stay untouched. The example above is illustrative - only real advisories belong in that file. Writing ranges is covered in [Publish a security advisory](/docs/dev/updates/security-advisories). + +## Check the result + +After a release or an advisory change, open the feed URL above. Within a few minutes of GitHub's cache, it shows the new version under `channels` and its entry under `releases`. diff --git a/apps/web/content/docs/dev/updates/plugin-authors.mdx b/apps/web/content/docs/dev/updates/plugin-authors.mdx new file mode 100644 index 000000000..bfac59f57 --- /dev/null +++ b/apps/web/content/docs/dev/updates/plugin-authors.mdx @@ -0,0 +1,99 @@ +--- +title: Publish updates for your plugin +description: Declare an update source in your plugin's package.json and host a small JSON manifest so VitNode installations see your new releases and security advisories. +icon: Upload +--- + +Add one field to your plugin's `package.json` and publish a JSON file next to your code. Every installation of your plugin then shows your new releases and advisories in **AdminCP → System → Plugins**. + + + + +### Declare the update source + +Add `vitnode.updates.url` to the plugin's `package.json`: + +```json title="plugins/site-notes/package.json" +{ + "name": "@acme/site-notes", + "version": "1.2.0", + "vitnode": { + "updates": { + "url": "https://raw.githubusercontent.com/acme/site-notes/main/updates.json" + } + } +} +``` + +The URL has to be `https://`, without a user name, password, query string or fragment. It ends up in browser-visible metadata, so it must never carry a token. + + + + +### Rebuild the plugin + +Run `vitnode build` (or keep `vitnode dev` running). The URL is written into `src/manifest.gen.ts` as `updates`, next to the version, so both the API and the web app's plugin inventory know where to look. + + + + +### Publish the manifest + +Commit `updates.json` to the branch the URL points at. The smallest useful file names your package, a channel and that release: + +```json title="updates.json" +{ + "schemaVersion": 1, + "packages": { + "@acme/site-notes": { + "channels": { "stable": "1.2.0" }, + "releases": { + "1.2.0": { + "publishedAt": "2026-10-01T12:00:00Z", + "requiresCore": ">=2.0.0 <3.0.0", + "url": "https://github.com/acme/site-notes/releases/tag/v1.2.0" + } + } + } + } +} +``` + +The package key must equal the `name` in `package.json`. `publishedAt`, `requiresCore` and `url` are optional; without `requiresCore` installations see _Compatibility unknown_. Every field is described in the [manifest reference](/docs/dev/updates/manifest). + + + + +## Check the result + +Install the plugin in an app, open **System → Plugins** and press **Check for updates**. With the plugin at `1.1.0` the row says _Update available: 1.2.0_; at `1.2.0` it says _Up to date_. + +## Several packages, one manifest + +A publisher with several plugins can keep one file. Point every plugin at the same URL and list each package under `packages`. VitNode downloads a shared file once per check. + +```json title="updates.json" +{ + "schemaVersion": 1, + "packages": { + "@acme/site-notes": { + "channels": { "stable": "1.2.0" }, + "releases": { "1.2.0": {} } + }, + "@acme/polls": { + "channels": { "stable": "0.4.1", "canary": "0.5.0-canary.2" }, + "releases": { "0.4.1": {}, "0.5.0-canary.2": {} } + } + } +} +``` + +## Hosting on GitHub Raw + +`https://raw.githubusercontent.com////updates.json` works well: it is public, served over HTTPS and supports `ETag`, so an unchanged file costs a `304 Not Modified`. Raw files are cached for a few minutes, so a fresh release can take that long to appear. Keep the file on a branch that is never deleted. VitNode's own feed lives on a dedicated branch for that reason. + +Any other HTTPS host works too, as long as it is publicly reachable on port 443. Redirects are followed up to three times, only to other public HTTPS addresses. + +## Private and local plugins + +Leave `vitnode.updates` out. The plugin works as before, and the catalog shows _Update checking unavailable_ for it. Nothing is sent anywhere for a plugin without an update source. diff --git a/apps/web/content/docs/dev/updates/security-advisories.mdx b/apps/web/content/docs/dev/updates/security-advisories.mdx new file mode 100644 index 000000000..69f844c7b --- /dev/null +++ b/apps/web/content/docs/dev/updates/security-advisories.mdx @@ -0,0 +1,50 @@ +--- +title: Publish a security advisory +description: Add a security advisory to an update manifest so VitNode installations running an affected version see a red warning in the AdminCP, with or without a fixed version. +icon: ShieldAlert +--- + +A security advisory in your update manifest turns every affected installation's catalog row red within a day - or right away when an administrator presses **Check for updates**. VitNode compares each installed version, API and web app separately, with the advisory's `affected` range. + +## Add an advisory + +Add an entry to the package's `advisories` array: + +```json title="updates.json" +{ + "id": "NOTES-2026-001", + "severity": "high", + "affected": ">=2.0.0 <2.1.3", + "fixed": "2.1.3", + "url": "https://github.com/acme/site-notes/security/advisories/GHSA-xxxx-xxxx-xxxx", + "summary": "Stored XSS in note titles." +} +``` + +Write `id`, `severity`, `affected` and `url` every time. Add `fixed` once a release contains the fix; installations then read _Fixed in 2.1.3_. The example is illustrative, not a real vulnerability. + +Advisories do not depend on channels or releases. An installation is warned even when its channel has no newer release, and even when you no longer publish that channel at all. + +## Announce a vulnerability without a fix + +Publish the advisory without `fixed`. Affected installations see _No fixed version has been published yet_. Use `summary` and the advisory page for mitigation steps, such as disabling a feature. When the fix ships, add `fixed` - and widen or narrow `affected` if the release that fixes it changes the picture. + +## How affected ranges match prereleases + +Ranges are matched with prereleases included, by SemVer order: + +| Installed | `affected: ">=2.0.0 <2.1.3"` | Why | +| ---------------- | ---------------------------- | --------------------------------------------------- | +| `2.1.0` | affected | Inside the range. | +| `2.1.0-canary.4` | affected | `2.1.0-canary.4` sorts between `2.0.0` and `2.1.3`. | +| `2.1.3-canary.1` | affected | A prerelease of `2.1.3` sorts before `2.1.3`. | +| `2.1.3` | not affected | The upper bound is exclusive. | +| `2.0.0-rc.1` | not affected | Prereleases of `2.0.0` sort before `2.0.0`. | + +If the fix already landed in `2.1.3-canary.2`, say so in the range: `">=2.0.0 <2.1.3-canary.2"`. When in doubt, keep a prerelease inside the range - a false warning is cheaper than a missed one. + +## What a warning does not mean + +A warning means the publisher listed that version. No warning is not proof that a version is secure, and VitNode does not scan your plugins' own dependencies. A patch release without an advisory is not shown as a security release. + +For VitNode's own packages, advisories are maintained in `security/advisories.json` in the VitNode repository; see [Official release feed](/docs/dev/updates/official-releases#add-an-advisory-to-the-official-feed). diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 38441dbe7..d7b8f4479 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -873,7 +873,110 @@ }, "search": "Szukaj wtyczek...", "title": "Wtyczki", - "version_unknown": "Nieznana wersja" + "version_unknown": "Nieznana wersja", + "inventory_missing": { + "title": "Widoczne są tylko wtyczki API", + "desc": "API nie otrzymało inwentarza wtyczek aplikacji webowej, więc wtyczki działające tylko w przeglądarce nie są wymienione, a wersje frontendu nie są porównywane. Przekaż frontendPlugins do buildApiConfig, aby zobaczyć obie strony." + }, + "core": { + "title": "Rdzeń VitNode", + "version": "Zainstalowana wersja {version}" + }, + "updates": { + "check": { + "button": "Sprawdź aktualizacje", + "success": { + "title": "Sprawdzanie aktualizacji zakończone", + "desc": "Znaleziono {updates, plural, =0 {0 aktualizacji} one {# aktualizację} few {# aktualizacje} many {# aktualizacji} other {# aktualizacji}} i {advisories, plural, =0 {0 ostrzeżeń bezpieczeństwa} one {# ostrzeżenie bezpieczeństwa} few {# ostrzeżenia bezpieczeństwa} many {# ostrzeżeń bezpieczeństwa} other {# ostrzeżeń bezpieczeństwa}}." + }, + "partial": { + "title": "Nie udało się odczytać niektórych źródeł aktualizacji", + "desc": "Liczba nieudanych źródeł: {count}. Wcześniejsze wyniki dla nich zostały zachowane." + }, + "running": { + "title": "Sprawdzanie już trwa", + "desc": "Trwa inne sprawdzanie - codzienne albo uruchomione przez kogoś innego. Jego wyniki pojawią się tutaj po zakończeniu." + }, + "cooldown": { + "title": "Sprawdzono przed chwilą", + "desc": "Spróbuj ponownie za {seconds} s." + }, + "disabled": { + "title": "Sprawdzanie aktualizacji jest wyłączone", + "desc": "Ta instalacja ustawia updates.enabled na false w konfiguracji API." + }, + "error": { + "title": "Nie udało się rozpocząć sprawdzania", + "desc": "API nie odpowiedziało. Spróbuj ponownie za chwilę." + } + }, + "summary": { + "last_checked": "Ostatnio sprawdzono ", + "never": "Codzienne sprawdzanie uruchamia harmonogram cron. Kliknij Sprawdź aktualizacje, aby uruchomić je teraz.", + "disabled": "Sprawdzanie aktualizacji jest wyłączone w tej instalacji.", + "channel": "Kanał: {channel} (ustawiony w konfiguracji API)", + "info": "Informacje o aktualizacjach mają charakter wyłącznie informacyjny: nic nie jest instalowane ani zmieniane. Brak ostrzeżenia nie dowodzi, że wersja jest bezpieczna." + }, + "status": { + "not_checked": "Jeszcze nie sprawdzono", + "unavailable": "Sprawdzanie aktualizacji niedostępne", + "disabled": "Sprawdzanie aktualizacji jest wyłączone", + "up_to_date": "Aktualna", + "update_available": "Dostępna aktualizacja: {version}", + "package_missing": "Brak w manifeście aktualizacji wydawcy", + "channel_missing": "Brak opublikowanych wydań na kanale {channel}", + "unknown_channel": "Zainstalowana wersja nie należy do żadnego znanego kanału. Ustaw updates.channel w konfiguracji API, aby go wybrać.", + "version_unknown": "Nieznana zainstalowana wersja - nie można potwierdzić, że jest aktualna" + }, + "released": "wydana ", + "release_notes": "Informacje o wydaniu", + "compatibility": { + "compatible": "Wydawca deklaruje obsługę rdzenia {range}.", + "incompatible": "Wymaga nowszego rdzenia ({range}); ta instalacja używa {core}.", + "unknown": "Zgodność z Twoją wersją rdzenia jest nieznana - wydawca jej nie zadeklarował.", + "alternative": "{version} to najnowsze wydanie na tym kanale, które deklaruje obsługę Twojego rdzenia." + }, + "stale": "Jeszcze nie sprawdzono dla tego wdrożenia - wyniki pochodzą z danych wydawcy pobranych .", + "failed": "Ostatnie sprawdzanie nie powiodło się: {reason}", + "failed_with_previous": "Ostatnie sprawdzanie nie powiodło się: {reason} Widoczny jest ostatni udany wynik z .", + "checked_at": "Sprawdzono ", + "error": { + "blocked_address": "źródło aktualizacji wskazuje na adres prywatny lub lokalny.", + "http_error": "źródło aktualizacji odpowiedziało kodem HTTP {detail}.", + "invalid_json": "źródło aktualizacji nie jest poprawnym JSON-em.", + "invalid_manifest": "manifest aktualizacji jest nieprawidłowy ({detail}).", + "invalid_url": "adres źródła aktualizacji jest niedozwolony ({detail}).", + "network": "nie udało się połączyć ze źródłem aktualizacji.", + "timeout": "źródło aktualizacji nie odpowiedziało na czas.", + "too_large": "manifest aktualizacji jest większy niż 1 MB.", + "too_many_redirects": "źródło aktualizacji przekierowało zbyt wiele razy.", + "unsupported_schema": "manifest aktualizacji używa formatu nieobsługiwanego przez tę wersję VitNode ({detail})." + }, + "security": { + "label": "Ostrzeżenia bezpieczeństwa", + "title": "Ostrzeżenie bezpieczeństwa {id} - poziom {severity}", + "affects": "Dotyczy zainstalowanej wersji {versions} (zakres {range}).", + "fixed": "Naprawiono w {version}.", + "no_fix": "Poprawiona wersja nie została jeszcze opublikowana.", + "details": "Szczegóły ostrzeżenia" + }, + "severity": { + "low": "niski", + "moderate": "umiarkowany", + "high": "wysoki", + "critical": "krytyczny" + }, + "channel": { + "stable": "stabilny", + "rc": "kandydat do wydania", + "canary": "canary" + }, + "side": { + "api": "API", + "frontend": "aplikacja webowa" + }, + "version_on_side": "{version} ({side})" + } } }, "user": { @@ -2928,5 +3031,6 @@ "description": "Przeredagowuje zaznaczony tekst: skraca, poprawia, upraszcza albo zmienia ton." } } - } + }, + "@vitnode/core:plugins:can_check_updates": "Sprawdzanie aktualizacji wtyczek i rdzenia na żądanie" } diff --git a/apps/web/src/plugin-inventory.gen.ts b/apps/web/src/plugin-inventory.gen.ts index 51ed5ffbe..889784b26 100644 --- a/apps/web/src/plugin-inventory.gen.ts +++ b/apps/web/src/plugin-inventory.gen.ts @@ -31,6 +31,7 @@ export const frontendPlugins: FrontendPluginInventory = [ }, license: "MIT", version: "2.0.0-canary.13", + updates: { url: "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json" }, }, }, { @@ -47,6 +48,7 @@ export const frontendPlugins: FrontendPluginInventory = [ }, license: "MIT", version: "2.0.0-canary.13", + updates: null, }, }, { @@ -63,6 +65,7 @@ export const frontendPlugins: FrontendPluginInventory = [ }, license: "MIT", version: "2.0.0-canary.13", + updates: null, }, }, ] diff --git a/package.json b/package.json index a94db3dfb..f87f1db20 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "scripts": { "release": "tsx ./scripts/bump-version/bump-version.ts", + "release:update-manifest": "tsx ./scripts/update-manifest/publish-update-manifest.ts", "db:migrate": "turbo db:migrate", "db:prepare": "turbo db:prepare", "docker:dev": "turbo docker:dev", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 57a2051b7..9f011156c 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -8,6 +8,11 @@ "url": "https://vitnode.com" }, "license": "MIT", + "vitnode": { + "updates": { + "url": "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json" + } + }, "homepage": "https://vitnode.com", "repository": { "type": "git", @@ -65,6 +70,7 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@types/sanitize-html": "^2.16.2", + "@types/semver": "^7.8.0", "@vitejs/plugin-react": "^6.1.2", "@vitest/coverage-v8": "^5.0.3", "@vitnode/config": "workspace:*", @@ -289,6 +295,7 @@ "react-scan": "^0.5.7", "redis": "^6.3.0", "sanitize-html": "^2.18.0", + "semver": "^7.8.5", "shadcn": "^4.21.4", "sharp": "^0.35.5", "use-debounce": "^10.1.1", diff --git a/packages/vitnode/scripts/update-manifest/merge.ts b/packages/vitnode/scripts/update-manifest/merge.ts new file mode 100644 index 000000000..8548d6933 --- /dev/null +++ b/packages/vitnode/scripts/update-manifest/merge.ts @@ -0,0 +1,257 @@ +import { gt } from "semver"; +import { z } from "zod"; + +import type { + UpdateChannel, + UpdateManifest, + UpdatePackage, + UpdateRelease, +} from "../../src/lib/updates/manifest"; + +import { + channelOfVersion, + isPackageId, + isStrictVersion, + parseUpdateManifest, + UPDATE_MANIFEST_SCHEMA_VERSION, + updateAdvisorySchema, +} from "../../src/lib/updates/manifest"; + +/** + * The pure half of publishing VitNode's own update manifest: everything that + * decides what the JSON says, with no git, GitHub or npm in sight. The release + * workflow feeds it what it published; the advisories workflow feeds it the + * maintained advisory list. + */ + +const ERROR_PREFIX = "[update manifest]"; + +export const RELEASE_CHANNELS: Record = { + canary: "canary", + "release-candidate": "rc", + stable: "stable", +}; + +export const channelForReleaseType = (releaseType: string): UpdateChannel => { + const channel = RELEASE_CHANNELS[releaseType]; + if (channel === undefined) { + throw new Error( + `${ERROR_PREFIX} Unknown release type ${JSON.stringify(releaseType)}. Expected one of: ${Object.keys(RELEASE_CHANNELS).join(", ")}.`, + ); + } + + return channel; +}; + +export const emptyUpdateManifest = (): UpdateManifest => ({ + packages: {}, + schemaVersion: UPDATE_MANIFEST_SCHEMA_VERSION, +}); + +/** Reads the published file, or starts a new one. Anything invalid stops the run: it is never overwritten blindly. */ +export const readPublishedManifest = (text: null | string): UpdateManifest => { + if (text === null || text.trim() === "") return emptyUpdateManifest(); + + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error(`${ERROR_PREFIX} The published updates.json is not JSON.`); + } + + const parsed = parseUpdateManifest(value); + if (!parsed.ok) { + throw new Error( + `${ERROR_PREFIX} The published updates.json is invalid: ${parsed.error}`, + ); + } + + return parsed.manifest; +}; + +/** Stable key order, so a rerun that changes nothing writes the same bytes. */ +export const serializeUpdateManifest = (manifest: UpdateManifest): string => { + const sortKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(sortKeys); + if (typeof value !== "object" || value === null) return value; + + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, sortKeys((value as Record)[key])]), + ); + }; + + return `${JSON.stringify( + { + schemaVersion: manifest.schemaVersion, + packages: sortKeys(manifest.packages), + }, + null, + 2, + )}\n`; +}; + +const assertValid = (manifest: UpdateManifest): UpdateManifest => { + const parsed = parseUpdateManifest(JSON.parse(JSON.stringify(manifest))); + if (!parsed.ok) { + throw new Error( + `${ERROR_PREFIX} The merged manifest is invalid: ${parsed.error}`, + ); + } + + return parsed.manifest; +}; + +const emptyPackage = (): UpdatePackage => ({ + advisories: [], + channels: {}, + releases: {}, +}); + +export interface PublishedPackage { + id: string; + /** `requiresCore` for this release, when the package declares one. */ + requiresCore?: string; + version: string; +} + +export interface GitHubReleaseMetadata { + publishedAt: string; + url: string; +} + +/** + * Adds one workflow run's packages to the manifest. + * + * - Only the run's channel moves, and never backwards: rerunning an older + * release leaves a newer channel target alone. + * - A release entry that already exists keeps its `publishedAt` and `url`. A + * rerun after the GitHub Release was created fills in what was missing, and + * changes nothing else. + * - Without a GitHub Release, `publishedAt` and `url` are left out - never + * invented. + * - Other packages, channels, releases and every advisory are untouched. + */ +export const mergeRelease = ( + manifest: UpdateManifest, + { + channel, + githubRelease, + packages, + }: { + channel: UpdateChannel; + githubRelease: GitHubReleaseMetadata | null; + packages: readonly PublishedPackage[]; + }, +): UpdateManifest => { + const next: UpdateManifest = structuredClone(manifest); + + for (const published of packages) { + if (!isPackageId(published.id)) { + throw new Error( + `${ERROR_PREFIX} ${JSON.stringify(published.id)} is not a package name.`, + ); + } + if (!isStrictVersion(published.version)) { + throw new Error( + `${ERROR_PREFIX} ${published.id} has the version ${JSON.stringify(published.version)}, which is not SemVer.`, + ); + } + if (channelOfVersion(published.version) !== channel) { + throw new Error( + `${ERROR_PREFIX} ${published.id}@${published.version} does not belong on the ${channel} channel.`, + ); + } + + const entry = next.packages[published.id] ?? emptyPackage(); + const existing = entry.releases[published.version]; + const release: UpdateRelease = { ...existing }; + release.publishedAt ??= githubRelease?.publishedAt; + release.url ??= githubRelease?.url; + release.requiresCore ??= published.requiresCore; + entry.releases[published.version] = Object.fromEntries( + Object.entries(release).filter(([, value]) => value !== undefined), + ); + + const current = entry.channels[channel]; + if (current === undefined || gt(published.version, current)) { + entry.channels[channel] = published.version; + } + + next.packages[published.id] = entry; + } + + return assertValid(next); +}; + +export const advisoriesInputSchema = z.strictObject({ + schemaVersion: z.literal(UPDATE_MANIFEST_SCHEMA_VERSION), + advisories: z.record( + z.string().refine(isPackageId, { message: "Expected an npm package name" }), + z.array(updateAdvisorySchema), + ), +}); + +export type AdvisoriesInput = z.infer; + +export const parseAdvisoriesInput = (text: string): AdvisoriesInput => { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw new Error(`${ERROR_PREFIX} The advisories file is not JSON.`); + } + + const parsed = advisoriesInputSchema.safeParse(value); + if (!parsed.success) { + throw new Error( + `${ERROR_PREFIX} The advisories file is invalid: ${parsed.error.issues + .slice(0, 5) + .map(issue => `${issue.path.join(".")}: ${issue.message}`) + .join("; ")}`, + ); + } + + return parsed.data; +}; + +/** + * The maintained advisories file is the whole truth about advisories: each + * package gets exactly the list it declares, and a package it does not list has + * none. Releases and channels are untouched. + */ +export const applyAdvisories = ( + manifest: UpdateManifest, + input: AdvisoriesInput, +): UpdateManifest => { + const next: UpdateManifest = structuredClone(manifest); + + for (const entry of Object.values(next.packages)) entry.advisories = []; + for (const [id, advisories] of Object.entries(input.advisories)) { + const entry = next.packages[id] ?? emptyPackage(); + entry.advisories = advisories; + next.packages[id] = entry; + } + + return assertValid(next); +}; + +/** + * `workspace:*` becomes the exact version on publish, `workspace:^` a caret + * range and so on - the same rewrite pnpm does - so a plugin's declared + * dependency on core is what its release says it requires. + */ +export const requiresCoreFrom = ( + spec: string | undefined, + coreVersion: string, +): string | undefined => { + if (spec === undefined) return undefined; + if (!spec.startsWith("workspace:")) return spec; + + const rest = spec.slice("workspace:".length); + if (rest === "*" || rest === "") return coreVersion; + if (rest === "^" || rest === "~") return `${rest}${coreVersion}`; + + return rest; +}; diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index 6a4d84e02..b6f6907cf 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -199,6 +199,7 @@ export function VitNodeAPI({ notifications: vitNodeApiConfig.notifications, search: vitNodeApiConfig.search, storage: vitNodeApiConfig.storage, + updates: vitNodeApiConfig.updates, users: vitNodeApiConfig.users, plugins, cacheClient: redisClient, diff --git a/packages/vitnode/src/api/lib/updates/check.ts b/packages/vitnode/src/api/lib/updates/check.ts new file mode 100644 index 000000000..257bd14da --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/check.ts @@ -0,0 +1,350 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { inArray, notInArray } from "drizzle-orm"; + +import type { UpdateCheckError } from "@/database/updates"; +import type { UpdateChannel, UpdateManifest } from "@/lib/updates/manifest"; + +import { core_update_results, core_update_sources } from "@/database/updates"; +import { parseUpdateManifest } from "@/lib/updates/manifest"; + +import type { UpdateComponent } from "./components"; +import type { + FetchUpdateSourceOptions, + UpdateSourceFetchResult, +} from "./fetch-source"; + +import { evaluateUpdate } from "./evaluate"; +import { fetchUpdateSource, UpdateSourceError } from "./fetch-source"; +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, + type UpdateCheckLease, +} from "./lock"; + +type Db = ReturnType; + +const SOURCE_CONCURRENCY = 4; + +const MAX_ERROR_DETAIL = 300; + +export type FetchUpdateSource = ( + url: string, + options: Pick, +) => Promise; + +export interface UpdateCheckSummary { + advisories: number; + components: number; + failedSources: number; + sources: number; + updates: number; +} + +type SourceOutcome = + | { document: null | UpdateManifest; error: UpdateCheckError } + | { document: UpdateManifest; error: null }; + +const errorOf = (error: unknown): UpdateCheckError => { + if (error instanceof UpdateSourceError) { + return { code: error.reason, detail: error.detail }; + } + + return { code: "network", detail: null }; +}; + +/** Only the packages this install checks: the stored copy stays small. */ +const pick = ( + manifest: UpdateManifest, + ids: readonly string[], +): UpdateManifest => ({ + packages: Object.fromEntries( + ids.flatMap(id => { + const entry = manifest.packages[id]; + + return entry === undefined ? [] : [[id, entry] as const]; + }), + ), + schemaVersion: manifest.schemaVersion, +}); + +const mapLimited = async ( + items: readonly T[], + limit: number, + run: (item: T) => Promise, +): Promise => { + const results: R[] = Array.from({ length: items.length }); + let next = 0; + const worker = async () => { + while (next < items.length) { + const index = next++; + results[index] = await run(items[index]); + } + }; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker), + ); + + return results; +}; + +/** + * The one check behind the daily cron job and the AdminCP button. Fetches every + * distinct update source once, validates it, evaluates each component against + * it and stores the outcome. A source that fails keeps its last valid copy - + * and every finding computed from it - and only records the failure, so one + * unreachable publisher neither blocks the others nor turns into "up to date". + */ +export const checkForUpdates = async ({ + channelOverride, + components, + coreVersion, + db, + fetchSource = async (url, options) => await fetchUpdateSource(url, options), + now = new Date(), +}: { + channelOverride: null | UpdateChannel; + components: readonly UpdateComponent[]; + coreVersion: null | string; + db: Db; + fetchSource?: FetchUpdateSource; + now?: Date; +}): Promise => { + const checked = components.filter( + (component): component is UpdateComponent & { sourceUrl: string } => + component.sourceUrl !== null, + ); + const bySource = new Map(); + for (const component of checked) { + bySource.set(component.sourceUrl, [ + ...(bySource.get(component.sourceUrl) ?? []), + component.id, + ]); + } + const urls = [...bySource.keys()]; + + const stored = new Map( + urls.length === 0 + ? [] + : ( + await db + .select() + .from(core_update_sources) + .where(inArray(core_update_sources.url, urls)) + ).map(row => [row.url, row]), + ); + + const outcomes = new Map(); + await mapLimited(urls, SOURCE_CONCURRENCY, async url => { + const previous = stored.get(url); + const ids = bySource.get(url) ?? []; + // A 304 can only stand in for a copy that is stored and still covers every + // package asked about now; otherwise ask for the full document. + const reusable = + previous?.document !== null && + previous?.document !== undefined && + ids.every(id => Object.hasOwn(previous.document?.packages ?? {}, id)) + ? previous.document + : null; + + let outcome: SourceOutcome; + let validators: null | { + etag: null | string; + lastModified: null | string; + } = null; + try { + const response = await fetchSource(url, { + validators: + reusable === null + ? null + : { + etag: previous?.etag ?? null, + lastModified: previous?.lastModified ?? null, + }, + }); + + if (response.kind === "not_modified") { + outcome = + reusable === null + ? { + document: previous?.document ?? null, + error: { code: "http_error", detail: "unexpected 304" }, + } + : { document: reusable, error: null }; + validators = { + etag: previous?.etag ?? null, + lastModified: previous?.lastModified ?? null, + }; + } else { + const parsed = parseUpdateManifest(response.body); + if (parsed.ok) { + outcome = { + document: pick(parsed.manifest, ids), + error: null, + }; + validators = { + etag: response.etag, + lastModified: response.lastModified, + }; + } else { + outcome = { + document: previous?.document ?? null, + error: { + code: + parsed.reason === "unsupported_schema" + ? "unsupported_schema" + : "invalid_manifest", + detail: parsed.error.slice(0, MAX_ERROR_DETAIL), + }, + }; + } + } + } catch (error) { + outcome = { document: previous?.document ?? null, error: errorOf(error) }; + } + + outcomes.set(url, outcome); + + if (outcome.error === null) { + const values = { + document: outcome.document, + etag: validators?.etag ?? null, + lastAttemptAt: now, + lastError: null, + lastModified: validators?.lastModified ?? null, + lastSuccessAt: now, + }; + await db + .insert(core_update_sources) + .values({ url, ...values }) + .onConflictDoUpdate({ set: values, target: core_update_sources.url }); + } else { + await db + .insert(core_update_sources) + .values({ lastAttemptAt: now, lastError: outcome.error, url }) + .onConflictDoUpdate({ + set: { lastAttemptAt: now, lastError: outcome.error }, + target: core_update_sources.url, + }); + } + }); + + const summary: UpdateCheckSummary = { + advisories: 0, + components: checked.length, + failedSources: [...outcomes.values()].filter( + outcome => outcome.error !== null, + ).length, + sources: urls.length, + updates: 0, + }; + + for (const component of checked) { + const outcome = outcomes.get(component.sourceUrl); + if (outcome === undefined) continue; + + if (outcome.error !== null) { + // The failure is recorded; what was found before - and the versions it + // was found for - stays exactly as it was. + await db + .insert(core_update_results) + .values({ + channel: null, + componentId: component.id, + coreVersion, + hasUnknownVersion: component.hasUnknownVersion, + lastAttemptAt: now, + lastError: outcome.error, + result: null, + sourceUrl: component.sourceUrl, + versions: component.versions, + }) + .onConflictDoUpdate({ + set: { lastAttemptAt: now, lastError: outcome.error }, + target: core_update_results.componentId, + }); + continue; + } + + const result = evaluateUpdate({ + channelOverride, + coreVersion, + entry: outcome.document.packages[component.id], + installed: component, + isCore: component.kind === "core", + }); + summary.advisories += result.advisories.length; + if (result.status === "update_available") summary.updates += 1; + + const values = { + channel: result.channel, + coreVersion, + hasUnknownVersion: component.hasUnknownVersion, + lastAttemptAt: now, + lastError: null, + lastSuccessAt: now, + result, + sourceUrl: component.sourceUrl, + versions: component.versions, + }; + await db + .insert(core_update_results) + .values({ componentId: component.id, ...values }) + .onConflictDoUpdate({ + set: values, + target: core_update_results.componentId, + }); + } + + // Removed plugins, and plugins that dropped their update source, leave no + // result behind; neither does a source nothing points to any more. + const checkedIds = checked.map(component => component.id); + await db + .delete(core_update_results) + .where( + checkedIds.length === 0 + ? undefined + : notInArray(core_update_results.componentId, checkedIds), + ); + await db + .delete(core_update_sources) + .where( + urls.length === 0 ? undefined : notInArray(core_update_sources.url, urls), + ); + + return summary; +}; + +export type LockedUpdateCheck = + | { + reason: Extract["reason"]; + retryAfterSeconds: number; + status: "busy"; + } + | { status: "completed"; summary: UpdateCheckSummary }; + +/** Takes the shared lease, runs the check and always gives the lease back. */ +export const runLockedUpdateCheck = async ({ + manual, + ...options +}: Parameters[0] & { + manual: boolean; +}): Promise => { + const lease = await acquireUpdateCheckLease(options.db, { + manual, + now: options.now, + }); + if (!lease.acquired) { + return { + reason: lease.reason, + retryAfterSeconds: lease.retryAfterSeconds, + status: "busy", + }; + } + + try { + return { status: "completed", summary: await checkForUpdates(options) }; + } finally { + await releaseUpdateCheckLease(options.db, lease.owner); + } +}; diff --git a/packages/vitnode/src/api/lib/updates/components.ts b/packages/vitnode/src/api/lib/updates/components.ts new file mode 100644 index 000000000..81d718e5c --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/components.ts @@ -0,0 +1,65 @@ +import type { + FrontendPluginInventory, + PluginManifest, +} from "@/lib/plugin-manifest"; + +import type { InstalledPluginRegistry } from "../installed-plugins"; +import type { UpdateComponentVersions } from "./evaluate"; + +import { normalizeInstalledVersions } from "./evaluate"; + +export interface UpdateComponent extends UpdateComponentVersions { + id: string; + kind: "core" | "plugin"; + /** `null` when the package declares no update source: checking is unavailable. */ + sourceUrl: null | string; +} + +/** + * Core first, then every plugin the catalog lists. A plugin's versions are the + * ones its registrations report - API and web app - so a stale side is checked + * too. Its update source comes from the API manifest, else the web app's. + */ +export const collectUpdateComponents = ({ + api, + core, + frontend, + registry, +}: { + api: readonly { manifest?: PluginManifest; pluginId: string }[]; + core: Pick; + frontend: FrontendPluginInventory | undefined; + registry: InstalledPluginRegistry; +}): UpdateComponent[] => { + const sources = new Map(); + for (const entry of frontend ?? []) { + const url = entry.manifest?.updates?.url; + if (url) sources.set(entry.id, url); + } + for (const plugin of api) { + const url = plugin.manifest?.updates?.url; + if (url) sources.set(plugin.pluginId, url); + } + + return [ + { + id: core.id, + kind: "core", + sourceUrl: core.updates?.url ?? null, + ...normalizeInstalledVersions([core.version]), + }, + ...registry.plugins.map(plugin => { + const { api: fromApi, frontend: fromFrontend } = plugin.registrations; + const reported = [fromApi, fromFrontend].flatMap(registration => + registration === null ? [] : [registration.version], + ); + + return { + id: plugin.id, + kind: "plugin" as const, + sourceUrl: sources.get(plugin.id) ?? null, + ...normalizeInstalledVersions(reported), + }; + }), + ]; +}; diff --git a/packages/vitnode/src/api/lib/updates/config.ts b/packages/vitnode/src/api/lib/updates/config.ts new file mode 100644 index 000000000..1c06cd7d2 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/config.ts @@ -0,0 +1,24 @@ +import type { VitNodeApiConfig } from "@/vitnode.config"; + +import { UPDATE_CHANNELS, type UpdateChannel } from "@/lib/updates/manifest"; + +export interface ResolvedUpdatesConfig { + channelOverride: null | UpdateChannel; + enabled: boolean; +} + +export const resolveUpdatesConfig = ( + updates: VitNodeApiConfig["updates"], +): ResolvedUpdatesConfig => { + const channel = updates?.channel; + if (channel !== undefined && !UPDATE_CHANNELS.includes(channel)) { + throw new Error( + `[VitNode updates] \`updates.channel\` is ${JSON.stringify(channel)}. Use one of: ${UPDATE_CHANNELS.join(", ")}.`, + ); + } + + return { + channelOverride: channel ?? null, + enabled: updates?.enabled ?? true, + }; +}; diff --git a/packages/vitnode/src/api/lib/updates/context.ts b/packages/vitnode/src/api/lib/updates/context.ts new file mode 100644 index 000000000..b48dd82d9 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/context.ts @@ -0,0 +1,21 @@ +import type { Context } from "hono"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; + +import { runLockedUpdateCheck } from "./check"; + +/** The check as the cron job and the AdminCP button both run it: same lease, same service. */ +export const runUpdateCheckFor = async ( + c: Context, + { manual }: { manual: boolean }, +) => { + const { channelOverride, components, coreVersion } = c.get("core").updates; + + return await runLockedUpdateCheck({ + channelOverride, + components, + coreVersion, + db: c.get("db"), + manual, + }); +}; diff --git a/packages/vitnode/src/api/lib/updates/evaluate.ts b/packages/vitnode/src/api/lib/updates/evaluate.ts new file mode 100644 index 000000000..bebc259de --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/evaluate.ts @@ -0,0 +1,218 @@ +import { gt, lte, rcompare, satisfies, valid } from "semver"; + +import type { + AdvisorySeverity, + UpdateChannel, + UpdatePackage, + UpdateRelease, +} from "@/lib/updates/manifest"; + +import { channelOfVersion, versionInRange } from "@/lib/updates/manifest"; + +export type UpdateStatus = + /** The publisher has no entry for this package. */ + | "channel_missing" + /** The package exists, but publishes nothing on the selected channel. */ + | "package_missing" + /** The installed version is on no known channel, and no override names one. */ + | "unknown_channel" + | "up_to_date" + | "update_available" + /** No newer release, but a registration reports no valid version, so "up to date" would be a guess. */ + | "version_unknown"; + +export type UpdateCompatibility = "compatible" | "incompatible" | "unknown"; + +export interface UpdateReleaseInfo { + publishedAt: null | string; + requiresCore: null | string; + url: null | string; + version: string; +} + +export interface UpdateAdvisoryFinding { + affected: string; + fixed: null | string; + id: string; + severity: AdvisorySeverity; + summary: null | string; + url: string; + /** The installed versions inside `affected` - the API's, the web app's, or both. */ + versions: string[]; +} + +export interface UpdateEvaluation { + advisories: UpdateAdvisoryFinding[]; + /** The newest release on the channel that declares it works with the running core, when `latest` does not. */ + alternative: null | UpdateReleaseInfo; + channel: null | UpdateChannel; + /** Of `latest` with the running core. `null` for core itself and when there is no newer release. */ + compatibility: null | UpdateCompatibility; + latest: null | UpdateReleaseInfo; + status: UpdateStatus; +} + +export interface UpdateComponentVersions { + /** A registration reported no version, or one that is not SemVer. */ + hasUnknownVersion: boolean; + /** Distinct valid versions, newest first. */ + versions: string[]; +} + +/** Valid, distinct, newest first; anything else only flips `hasUnknownVersion`. */ +export const normalizeInstalledVersions = ( + reported: readonly (null | string)[], +): UpdateComponentVersions => { + const versions = new Set(); + let hasUnknownVersion = reported.length === 0; + + for (const value of reported) { + const normalized = value === null ? null : valid(value); + if (normalized === null) { + hasUnknownVersion = true; + } else { + versions.add(normalized); + } + } + + return { hasUnknownVersion, versions: [...versions].sort(rcompare) }; +}; + +const releaseInfo = ( + version: string, + release: undefined | UpdateRelease, +): UpdateReleaseInfo => ({ + publishedAt: release?.publishedAt ?? null, + requiresCore: release?.requiresCore ?? null, + url: release?.url ?? null, + version, +}); + +const compatibilityOf = ( + release: UpdateReleaseInfo, + coreVersion: null | string, +): UpdateCompatibility => { + if (release.requiresCore === null || coreVersion === null) return "unknown"; + + return satisfies(coreVersion, release.requiresCore, { + includePrerelease: true, + }) + ? "compatible" + : "incompatible"; +}; + +/** + * Only from releases the publisher listed, on the same channel, newer than what + * runs, no newer than the channel target, and explicitly declaring a + * `requiresCore` the running core satisfies. A release that says nothing about + * core is not offered as "compatible". + */ +const compatibleAlternative = ( + entry: UpdatePackage, + channel: UpdateChannel, + newestInstalled: string, + target: string, + coreVersion: null | string, +): null | UpdateReleaseInfo => { + if (coreVersion === null) return null; + + const candidates = Object.keys(entry.releases) + .filter( + version => + channelOfVersion(version) === channel && + gt(version, newestInstalled) && + lte(version, target), + ) + .sort(rcompare); + + for (const version of candidates) { + const info = releaseInfo(version, entry.releases[version]); + if (compatibilityOf(info, coreVersion) === "compatible") return info; + } + + return null; +}; + +const advisoryFindings = ( + entry: undefined | UpdatePackage, + versions: readonly string[], +): UpdateAdvisoryFinding[] => + (entry?.advisories ?? []).flatMap(advisory => { + const affected = versions.filter(version => + versionInRange(version, advisory.affected), + ); + if (affected.length === 0) return []; + + return [ + { + affected: advisory.affected, + fixed: advisory.fixed ?? null, + id: advisory.id, + severity: advisory.severity, + summary: advisory.summary ?? null, + url: advisory.url, + versions: affected, + }, + ]; + }); + +export const evaluateUpdate = ({ + channelOverride, + coreVersion, + entry, + installed, + isCore, +}: { + channelOverride: null | UpdateChannel; + /** The running core version, for `requiresCore`. */ + coreVersion: null | string; + /** This package's entry in its publisher's manifest; `undefined` when absent. */ + entry: undefined | UpdatePackage; + installed: UpdateComponentVersions; + isCore: boolean; +}): UpdateEvaluation => { + const [newest] = [...installed.versions].sort(rcompare); + const channel = + channelOverride ?? (newest === undefined ? null : channelOfVersion(newest)); + // Advisories never depend on channels or releases: a vulnerable build is + // reported even when its publisher stopped shipping that channel. + const advisories = advisoryFindings(entry, installed.versions); + const base = { + advisories, + alternative: null, + channel, + compatibility: null, + latest: null, + }; + + if (entry === undefined) return { ...base, status: "package_missing" }; + if (newest === undefined) return { ...base, status: "version_unknown" }; + if (channel === null) return { ...base, status: "unknown_channel" }; + + const target = entry.channels[channel]; + if (target === undefined) return { ...base, status: "channel_missing" }; + + const latest = releaseInfo(target, entry.releases[target]); + const outdated = installed.versions.some(version => gt(target, version)); + + if (!outdated) { + return { + ...base, + latest, + status: installed.hasUnknownVersion ? "version_unknown" : "up_to_date", + }; + } + + const compatibility = isCore ? null : compatibilityOf(latest, coreVersion); + + return { + ...base, + alternative: + compatibility === "incompatible" + ? compatibleAlternative(entry, channel, newest, target, coreVersion) + : null, + compatibility, + latest, + status: "update_available", + }; +}; diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.ts b/packages/vitnode/src/api/lib/updates/fetch-source.ts new file mode 100644 index 000000000..0fc1ec96f --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/fetch-source.ts @@ -0,0 +1,315 @@ +import type { LookupFunction } from "node:net"; + +import { lookup as dnsLookup } from "node:dns"; +import { request as httpsRequest } from "node:https"; +import { isIP } from "node:net"; + +import { isPublicIpAddress, publicOnlyLookup } from "../remote-image"; + +export const UPDATE_SOURCE_MAX_BYTES = 1024 * 1024; + +const DEFAULT_TIMEOUT_MS = 10_000; + +const DEFAULT_MAX_REDIRECTS = 3; + +const MAX_URL_LENGTH = 2048; + +export type UpdateSourceFailure = + | "blocked_address" + | "http_error" + | "invalid_json" + | "invalid_url" + | "network" + | "timeout" + | "too_large" + | "too_many_redirects"; + +export class UpdateSourceError extends Error { + constructor(reason: UpdateSourceFailure, detail?: string) { + super(`Update source rejected: ${reason}${detail ? ` (${detail})` : ""}`); + this.name = "UpdateSourceError"; + this.reason = reason; + this.detail = detail ?? null; + } + + readonly detail: null | string; + readonly reason: UpdateSourceFailure; +} + +export interface UpdateSourceTransportResponse { + body: AsyncIterable; + contentLength: null | number; + destroy: () => void; + etag: null | string; + lastModified: null | string; + location: null | string; + status: number; +} + +/** + * The network seam. The default sends a plain GET over HTTPS with only the + * headers it is given - never a cookie or an authorization header, since + * nothing of the request that triggered the check is passed down here. + */ +export type UpdateSourceTransport = ( + url: URL, + options: { + headers: Record; + lookup: LookupFunction; + signal: AbortSignal; + }, +) => Promise; + +export type ResolveUpdateHost = (hostname: string) => Promise; + +const header = (value: string | string[] | undefined): null | string => + (Array.isArray(value) ? value[0] : value) ?? null; + +export const httpsUpdateSourceTransport: UpdateSourceTransport = async ( + url, + { headers, lookup, signal }, +) => + await new Promise((resolve, reject) => { + const request = httpsRequest( + url, + { agent: false, headers, lookup, method: "GET", signal }, + response => { + const length = Number(response.headers["content-length"]); + signal.addEventListener("abort", () => response.destroy(), { + once: true, + }); + + resolve({ + body: response, + contentLength: Number.isFinite(length) ? length : null, + destroy: () => response.destroy(), + etag: header(response.headers.etag), + lastModified: header(response.headers["last-modified"]), + location: header(response.headers.location), + status: response.statusCode ?? 0, + }); + }, + ); + + request.on("error", reject); + request.end(); + }); + +const resolveHostAddresses: ResolveUpdateHost = async hostname => + await new Promise((resolve, reject) => { + dnsLookup(hostname, { all: true }, (error, addresses) => { + if (error) { + reject(new UpdateSourceError("network", "DNS lookup failed")); + + return; + } + + resolve(addresses.map(entry => entry.address)); + }); + }); + +const parseUrl = (raw: string, base?: URL): URL => { + if (raw.length > MAX_URL_LENGTH) throw new UpdateSourceError("invalid_url"); + + try { + return new URL(raw, base); + } catch { + throw new UpdateSourceError("invalid_url"); + } +}; + +/** + * HTTPS on the default port, a public host name, and every address it resolves + * to public too. The same check runs again at connect time through + * `publicOnlyLookup`, so a name that changes its answer in between still cannot + * reach loopback, a private network or a cloud metadata endpoint. + */ +const assertFetchableUrl = async ( + url: URL, + resolveHost: ResolveUpdateHost, +): Promise => { + if (url.protocol !== "https:") { + throw new UpdateSourceError("invalid_url", "not https"); + } + if (url.username || url.password) throw new UpdateSourceError("invalid_url"); + if (url.port && url.port !== "443") { + throw new UpdateSourceError("invalid_url", "only port 443 is allowed"); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + if (isIP(hostname) !== 0) { + if (!isPublicIpAddress(hostname)) { + throw new UpdateSourceError("blocked_address"); + } + + return; + } + if (!hostname.includes(".") || hostname.endsWith(".localhost")) { + throw new UpdateSourceError("blocked_address"); + } + + const addresses = await resolveHost(hostname); + if ( + addresses.length === 0 || + addresses.some(address => !isPublicIpAddress(address)) + ) { + throw new UpdateSourceError("blocked_address"); + } +}; + +const readCapped = async ( + body: AsyncIterable, + maxBytes: number, +): Promise => { + const chunks: Uint8Array[] = []; + let size = 0; + + for await (const chunk of body) { + size += chunk.byteLength; + if (size > maxBytes) throw new UpdateSourceError("too_large"); + chunks.push(chunk); + } + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + return bytes; +}; + +const transportFailure = ( + error: unknown, + signal: AbortSignal, +): UpdateSourceError => { + if (error instanceof UpdateSourceError) return error; + if (signal.aborted) return new UpdateSourceError("timeout"); + if ( + typeof error === "object" && + error !== null && + "reason" in error && + error.reason === "blocked_address" + ) { + return new UpdateSourceError("blocked_address"); + } + + return new UpdateSourceError("network"); +}; + +export interface UpdateSourceValidators { + etag: null | string; + lastModified: null | string; +} + +export type UpdateSourceFetchResult = + | (UpdateSourceValidators & { body: unknown; kind: "fetched" }) + | { kind: "not_modified" }; + +export interface FetchUpdateSourceOptions { + maxBytes?: number; + maxRedirects?: number; + resolveHost?: ResolveUpdateHost; + timeoutMs?: number; + transport?: UpdateSourceTransport; + /** Sent as `If-None-Match` / `If-Modified-Since`. Only pass them when a validated copy is stored. */ + validators?: null | UpdateSourceValidators; +} + +export const fetchUpdateSource = async ( + rawUrl: string, + { + maxBytes = UPDATE_SOURCE_MAX_BYTES, + maxRedirects = DEFAULT_MAX_REDIRECTS, + resolveHost = resolveHostAddresses, + timeoutMs = DEFAULT_TIMEOUT_MS, + transport = httpsUpdateSourceTransport, + validators = null, + }: FetchUpdateSourceOptions = {}, +): Promise => { + const signal = AbortSignal.timeout(timeoutMs); + const headers: Record = { + accept: "application/json", + "user-agent": "VitNode update check", + }; + if (validators?.etag) headers["if-none-match"] = validators.etag; + if (validators?.lastModified) { + headers["if-modified-since"] = validators.lastModified; + } + + let url = parseUrl(rawUrl); + + for (let hop = 0; hop <= maxRedirects; hop++) { + await assertFetchableUrl(url, resolveHost); + if (signal.aborted) throw new UpdateSourceError("timeout"); + + let response: UpdateSourceTransportResponse; + try { + response = await transport(url, { + headers, + lookup: publicOnlyLookup, + signal, + }); + } catch (error) { + throw transportFailure(error, signal); + } + + if (response.status === 304) { + response.destroy(); + if (!validators) { + throw new UpdateSourceError( + "http_error", + "304 without a request for it", + ); + } + + return { kind: "not_modified" }; + } + + if (response.status >= 300 && response.status < 400) { + response.destroy(); + if (!response.location) { + throw new UpdateSourceError("http_error", String(response.status)); + } + url = parseUrl(response.location, url); + continue; + } + + if (response.status !== 200) { + response.destroy(); + throw new UpdateSourceError("http_error", String(response.status)); + } + + if (response.contentLength !== null && response.contentLength > maxBytes) { + response.destroy(); + throw new UpdateSourceError("too_large"); + } + + let bytes: Uint8Array; + try { + bytes = await readCapped(response.body, maxBytes); + } catch (error) { + response.destroy(); + throw transportFailure(error, signal); + } + + let body: unknown; + try { + body = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + } catch { + throw new UpdateSourceError("invalid_json"); + } + + return { + body, + etag: response.etag, + kind: "fetched", + lastModified: response.lastModified, + }; + } + + throw new UpdateSourceError("too_many_redirects"); +}; diff --git a/packages/vitnode/src/api/lib/updates/lock.ts b/packages/vitnode/src/api/lib/updates/lock.ts new file mode 100644 index 000000000..83fe83696 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/lock.ts @@ -0,0 +1,152 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { and, eq, isNull, lte, or, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; + +import { core_update_lock } from "@/database/updates"; + +type Db = ReturnType; + +const LOCK_NAME = "check"; + +/** Longer than a check can take: every source has a 10 s timeout and they run four at a time. */ +export const UPDATE_CHECK_LEASE_MS = 5 * 60_000; + +export const MANUAL_UPDATE_CHECK_COOLDOWN_MS = 2 * 60_000; + +export type UpdateCheckLease = + | { acquired: false; reason: "cooldown"; retryAfterSeconds: number } + | { acquired: false; reason: "running"; retryAfterSeconds: number } + | { acquired: true; owner: string }; + +const secondsUntil = (date: Date | null, now: Date): number => + date === null + ? 1 + : Math.max(1, Math.ceil((date.getTime() - now.getTime()) / 1000)); + +/** + * One statement, so two instances racing for the lease cannot both win: the + * insert creates the row, and the conflict branch only takes it over when no + * one holds it or the holder's lease ran out. A manual check also has to be + * past the cooldown, and stamps it in the same statement. + */ +export const acquireUpdateCheckLease = async ( + db: Db, + { + cooldownMs = MANUAL_UPDATE_CHECK_COOLDOWN_MS, + leaseMs = UPDATE_CHECK_LEASE_MS, + manual, + now = new Date(), + owner = randomUUID(), + }: { + cooldownMs?: number; + leaseMs?: number; + manual: boolean; + now?: Date; + owner?: string; + }, +): Promise => { + const expiresAt = new Date(now.getTime() + leaseMs); + const free = or( + isNull(core_update_lock.owner), + isNull(core_update_lock.expiresAt), + lte(core_update_lock.expiresAt, now), + ); + const cooledDown = or( + isNull(core_update_lock.lastManualAt), + lte(core_update_lock.lastManualAt, new Date(now.getTime() - cooldownMs)), + ); + + const [taken] = await db + .insert(core_update_lock) + .values({ + expiresAt, + lastManualAt: manual ? now : null, + name: LOCK_NAME, + owner, + }) + .onConflictDoUpdate({ + set: { + expiresAt, + lastManualAt: manual ? now : sql`${core_update_lock.lastManualAt}`, + owner, + }, + setWhere: manual ? and(free, cooledDown) : free, + target: core_update_lock.name, + }) + .returning({ owner: core_update_lock.owner }); + + if (taken?.owner === owner) return { acquired: true, owner }; + + const [row] = await db + .select() + .from(core_update_lock) + .where(eq(core_update_lock.name, LOCK_NAME)); + + if ( + row?.owner !== null && + row?.owner !== undefined && + row.expiresAt !== null && + row.expiresAt > now + ) { + return { + acquired: false, + reason: "running", + retryAfterSeconds: secondsUntil(row.expiresAt, now), + }; + } + + return { + acquired: false, + reason: "cooldown", + retryAfterSeconds: secondsUntil( + row?.lastManualAt + ? new Date(row.lastManualAt.getTime() + cooldownMs) + : null, + now, + ), + }; +}; + +/** Only the instance that holds the lease can give it back. */ +export const releaseUpdateCheckLease = async ( + db: Db, + owner: string, +): Promise => { + const released = await db + .update(core_update_lock) + .set({ expiresAt: null, owner: null }) + .where( + and( + eq(core_update_lock.name, LOCK_NAME), + eq(core_update_lock.owner, owner), + ), + ) + .returning({ name: core_update_lock.name }); + + return released.length > 0; +}; + +export interface UpdateCheckLockState { + lastManualAt: Date | null; + running: boolean; +} + +export const readUpdateCheckLock = async ( + db: Db, + now: Date = new Date(), +): Promise => { + const [row] = await db + .select() + .from(core_update_lock) + .where(eq(core_update_lock.name, LOCK_NAME)); + + return { + lastManualAt: row?.lastManualAt ?? null, + running: + row?.owner !== null && + row?.owner !== undefined && + row.expiresAt !== null && + row.expiresAt > now, + }; +}; diff --git a/packages/vitnode/src/api/lib/updates/status.ts b/packages/vitnode/src/api/lib/updates/status.ts new file mode 100644 index 000000000..c8b1ac1d5 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/status.ts @@ -0,0 +1,151 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import type { UpdateCheckError } from "@/database/updates"; +import type { UpdateChannel } from "@/lib/updates/manifest"; + +import { core_update_results, core_update_sources } from "@/database/updates"; +import { channelOfVersion } from "@/lib/updates/manifest"; + +import type { UpdateComponent } from "./components"; +import type { UpdateEvaluation } from "./evaluate"; + +import { evaluateUpdate } from "./evaluate"; +import { readUpdateCheckLock } from "./lock"; + +type Db = ReturnType; + +export interface ComponentUpdateState { + id: string; + lastAttemptAt: Date | null; + /** The last attempt for this component failed. Whatever `result` holds is from before it. */ + lastError: null | UpdateCheckError; + lastSuccessAt: Date | null; + result: null | UpdateEvaluation; + sourceUrl: null | string; + /** + * `true` when the result was not checked for what runs now - a new version, + * channel or core. It is recomputed from the publisher data stored by the + * last successful check, so a security warning still shows, but no fresh + * check has confirmed it yet. + */ + stale: boolean; + state: "checked" | "not_checked" | "unavailable"; +} + +export interface UpdatesOverview { + channelOverride: null | UpdateChannel; + components: ComponentUpdateState[]; + enabled: boolean; + lastManualAt: Date | null; + running: boolean; +} + +const sameVersions = (a: readonly string[], b: readonly string[]): boolean => + a.length === b.length && a.every((version, index) => version === b[index]); + +/** + * What the catalog shows. Reads stored results only - it never reaches out to + * a publisher, so opening the page costs two small queries. + */ +export const loadUpdatesOverview = async ({ + channelOverride, + components, + coreVersion, + db, + enabled, + now = new Date(), +}: { + channelOverride: null | UpdateChannel; + components: readonly UpdateComponent[]; + coreVersion: null | string; + db: Db; + enabled: boolean; + now?: Date; +}): Promise => { + const lock = await readUpdateCheckLock(db, now); + const unavailable = (component: UpdateComponent): ComponentUpdateState => ({ + id: component.id, + lastAttemptAt: null, + lastError: null, + lastSuccessAt: null, + result: null, + sourceUrl: component.sourceUrl, + stale: false, + state: "unavailable", + }); + + if (!enabled) { + return { + channelOverride, + components: components.map(unavailable), + enabled, + lastManualAt: lock.lastManualAt, + running: lock.running, + }; + } + + const [results, sources] = await Promise.all([ + db.select().from(core_update_results), + db.select().from(core_update_sources), + ]); + const resultById = new Map(results.map(row => [row.componentId, row])); + const sourceByUrl = new Map(sources.map(row => [row.url, row])); + + return { + channelOverride, + components: components.map((component): ComponentUpdateState => { + if (component.sourceUrl === null) return unavailable(component); + + const row = resultById.get(component.id); + const notChecked: ComponentUpdateState = { + id: component.id, + lastAttemptAt: row?.lastAttemptAt ?? null, + lastError: row?.lastError ?? null, + lastSuccessAt: null, + result: null, + sourceUrl: component.sourceUrl, + stale: false, + state: "not_checked", + }; + // A result for another source answers a different question. + if (row?.sourceUrl !== component.sourceUrl || row.result === null) { + return notChecked; + } + + const [newest] = component.versions; + const channel = + channelOverride ?? + (newest === undefined ? null : channelOfVersion(newest)); + const current = + sameVersions(row.versions, component.versions) && + row.hasUnknownVersion === component.hasUnknownVersion && + row.coreVersion === coreVersion && + row.channel === channel; + const checked = { + ...notChecked, + lastSuccessAt: row.lastSuccessAt, + state: "checked" as const, + }; + + if (current) return { ...checked, result: row.result }; + + const document = sourceByUrl.get(component.sourceUrl)?.document; + if (!document) return notChecked; + + return { + ...checked, + result: evaluateUpdate({ + channelOverride, + coreVersion, + entry: document.packages[component.id], + installed: component, + isCore: component.kind === "core", + }), + stale: true, + }; + }), + enabled, + lastManualAt: lock.lastManualAt, + running: lock.running, + }; +}; diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 6bed7b1f9..4104ad083 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -8,6 +8,7 @@ import type { RegisteredContentModel } from "@/content/server/model"; import type { LocaleRouting } from "@/lib/i18n/locale-routing"; import type { LocaleConfig, MessagesSource } from "@/lib/i18n/types"; import type { NavigationPreset } from "@/lib/navigation"; +import type { UpdateChannel } from "@/lib/updates/manifest"; import type { PersonalInformationFields } from "@/lib/user-personal-information"; import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config"; import type { VitNodeRealtime } from "@/ws/registry"; @@ -44,6 +45,7 @@ import { collectLocaleCodes } from "@/lib/i18n/load-messages"; import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing"; import { buildApiMessagesSources } from "@/lib/i18n/sources"; import { resolvePersonalInformationFields } from "@/lib/user-personal-information"; +import { manifest as coreManifest } from "@/manifest"; import { realtime } from "@/ws/registry"; import type { AiActionRegistry } from "../lib/ai/registry"; @@ -57,6 +59,7 @@ import type { NotificationRegistry } from "../lib/notifications/registry"; import type { ResolvedPasskeysConfig } from "../lib/passkey-config"; import type { PermissionStaffCatalogEntry } from "../lib/permission-staff"; import type { BuildQueueTaskReturn } from "../lib/queue"; +import type { UpdateComponent } from "../lib/updates/components"; import type { WebSocketConfig } from "../lib/websocket"; import type { EventsApiPlugin } from "../models/events"; import type { @@ -85,6 +88,8 @@ import { resolveNotificationWorkerSettings } from "../lib/notifications/preferen import { createNotificationRegistry } from "../lib/notifications/registry"; import { resolvePasskeysConfig } from "../lib/passkey-config"; import { normalizePermissionStaffModules } from "../lib/permission-staff"; +import { collectUpdateComponents } from "../lib/updates/components"; +import { resolveUpdatesConfig } from "../lib/updates/config"; declare module "hono" { // oxlint-disable-next-line typescript/no-empty-object-type @@ -182,6 +187,13 @@ export interface EnvVariablesVitNode { search: { adapter: SearchProviderApiPlugin }; searchIndexers: SearchIndexerConfig[]; storage?: VitNodeApiConfig["storage"]; + /** Core and every catalog plugin, with the update source and versions to check. */ + updates: { + channelOverride: null | UpdateChannel; + components: UpdateComponent[]; + coreVersion: null | string; + enabled: boolean; + }; webSockets: WebSocketConfig[]; }; db: Pick["dbProvider"]; @@ -237,6 +249,7 @@ export const globalMiddleware = ({ notifications, search, storage, + updates, users, cacheClient, }: Pick< @@ -255,6 +268,7 @@ export const globalMiddleware = ({ | "plugins" | "search" | "storage" + | "updates" | "users" > & Pick & { @@ -268,6 +282,17 @@ export const globalMiddleware = ({ api: plugins, frontend: frontendPlugins, }); + const updatesConfig = resolveUpdatesConfig(updates); + const updatesMetadata = { + ...updatesConfig, + components: collectUpdateComponents({ + api: plugins, + core: coreManifest, + frontend: frontendPlugins, + registry: installedPlugins, + }), + coreVersion: coreManifest.version, + }; // Resolved once at boot: the packages installed here can't change per // request. With no `i18n` block the locale list is whatever the installed @@ -533,6 +558,7 @@ export const globalMiddleware = ({ hasCronAdapter: !!cron, plugins: pluginsMetadata, installedPlugins, + updates: updatesMetadata, cron: cronMetadata, editablePages: editablePagesMetadata, queue: queueMetadata, diff --git a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts index c31eadd91..837591c3d 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts @@ -1,8 +1,14 @@ import { buildModule } from "@/api/builders"; +import { checkPluginUpdatesAdminRoute } from "./routes/check-updates.route"; import { listPluginsAdminRoute } from "./routes/list.route"; +import { pluginUpdatesAdminRoute } from "./routes/updates.route"; export const pluginsAdminModule = buildModule({ name: "plugins", - routes: [listPluginsAdminRoute], + routes: [ + listPluginsAdminRoute, + pluginUpdatesAdminRoute, + checkPluginUpdatesAdminRoute, + ], }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts new file mode 100644 index 000000000..e8bd6ab9c --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/builders"; +import { runUpdateCheckFor } from "@/api/lib/updates/context"; + +const busySchema = z.object({ + status: z.enum(["cooldown", "disabled", "running"]), + retryAfterSeconds: z.number().nullable(), +}); + +export const checkPluginUpdatesAdminRoute = buildRoute({ + adminStaffPermission: { + module: "plugins", + permission: "can_check_updates", + }, + route: { + method: "post", + description: + "Check core and every installed plugin for updates and security advisories now. Shares its lease with the daily cron job.", + path: "/updates/check", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + status: z.literal("completed"), + sources: z.number(), + failedSources: z.number(), + components: z.number(), + updates: z.number(), + advisories: z.number(), + }), + }, + }, + description: "The check finished", + }, + 403: { description: "Access Denied" }, + 409: { + content: { "application/json": { schema: busySchema } }, + description: "A check is already running, or checks are turned off", + }, + 429: { + content: { "application/json": { schema: busySchema } }, + description: "A manual check ran moments ago", + }, + }, + }, + handler: async c => { + if (!c.get("core").updates.enabled) { + return c.json( + { retryAfterSeconds: null, status: "disabled" as const }, + 409, + ); + } + + const outcome = await runUpdateCheckFor(c, { manual: true }); + + if (outcome.status === "completed") { + return c.json({ ...outcome.summary, status: "completed" as const }, 200); + } + + c.header("Retry-After", String(outcome.retryAfterSeconds)); + + return outcome.reason === "running" + ? c.json( + { + retryAfterSeconds: outcome.retryAfterSeconds, + status: "running" as const, + }, + 409, + ) + : c.json( + { + retryAfterSeconds: outcome.retryAfterSeconds, + status: "cooldown" as const, + }, + 429, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts new file mode 100644 index 000000000..183588268 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts @@ -0,0 +1,53 @@ +import { buildRoute } from "@/api/builders"; +import { MANUAL_UPDATE_CHECK_COOLDOWN_MS } from "@/api/lib/updates/lock"; +import { loadUpdatesOverview } from "@/api/lib/updates/status"; + +import { + serializeComponentUpdate, + updatesOverviewSchema, +} from "./updates.schema"; + +export const pluginUpdatesAdminRoute = buildRoute({ + adminStaffPermission: { module: "plugins", permission: "can_view" }, + route: { + method: "get", + description: + "Stored update and security-advisory results for core and every installed plugin. Never contacts a publisher.", + path: "/updates", + responses: { + 200: { + content: { "application/json": { schema: updatesOverviewSchema } }, + description: "Update status", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: async c => { + const { channelOverride, components, coreVersion, enabled } = + c.get("core").updates; + const overview = await loadUpdatesOverview({ + channelOverride, + components, + coreVersion, + db: c.get("db"), + enabled, + }); + const [core, ...plugins] = overview.components.map( + serializeComponentUpdate, + ); + if (core === undefined) throw new Error("The core component is missing"); + + return c.json( + { + channelOverride: overview.channelOverride, + core, + enabled: overview.enabled, + lastManualAt: overview.lastManualAt?.toISOString() ?? null, + manualCooldownSeconds: MANUAL_UPDATE_CHECK_COOLDOWN_MS / 1000, + plugins, + running: overview.running, + }, + 200, + ); + }, +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts new file mode 100644 index 000000000..4a5d19877 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import type { ComponentUpdateState } from "@/api/lib/updates/status"; + +import { ADVISORY_SEVERITIES, UPDATE_CHANNELS } from "@/lib/updates/manifest"; + +const releaseSchema = z.object({ + version: z.string(), + publishedAt: z.string().nullable(), + requiresCore: z.string().nullable(), + url: z.string().nullable(), +}); + +export const componentUpdateSchema = z.object({ + id: z.string(), + sourceUrl: z.string().nullable(), + state: z.enum(["checked", "not_checked", "unavailable"]), + stale: z.boolean(), + lastAttemptAt: z.string().nullable(), + lastSuccessAt: z.string().nullable(), + lastError: z + .object({ code: z.string(), detail: z.string().nullable() }) + .nullable(), + result: z + .object({ + status: z.enum([ + "package_missing", + "channel_missing", + "unknown_channel", + "up_to_date", + "update_available", + "version_unknown", + ]), + channel: z.enum(UPDATE_CHANNELS).nullable(), + latest: releaseSchema.nullable(), + compatibility: z + .enum(["compatible", "incompatible", "unknown"]) + .nullable(), + alternative: releaseSchema.nullable(), + advisories: z.array( + z.object({ + id: z.string(), + severity: z.enum(ADVISORY_SEVERITIES), + affected: z.string(), + fixed: z.string().nullable(), + url: z.string(), + summary: z.string().nullable(), + versions: z.array(z.string()), + }), + ), + }) + .nullable(), +}); + +export const updatesOverviewSchema = z.object({ + enabled: z.boolean(), + channelOverride: z.enum(UPDATE_CHANNELS).nullable(), + running: z.boolean(), + lastManualAt: z.string().nullable(), + manualCooldownSeconds: z.number(), + core: componentUpdateSchema, + plugins: z.array(componentUpdateSchema), +}); + +const iso = (date: Date | null): null | string => date?.toISOString() ?? null; + +export const serializeComponentUpdate = ( + state: ComponentUpdateState, +): z.infer => ({ + ...state, + lastAttemptAt: iso(state.lastAttemptAt), + lastSuccessAt: iso(state.lastSuccessAt), +}); diff --git a/packages/vitnode/src/api/modules/cron/cron.module.ts b/packages/vitnode/src/api/modules/cron/cron.module.ts index f07ca418e..cc3dda7b7 100644 --- a/packages/vitnode/src/api/modules/cron/cron.module.ts +++ b/packages/vitnode/src/api/modules/cron/cron.module.ts @@ -1,10 +1,11 @@ import { buildModule } from "@/api/builders"; +import { checkUpdatesCron } from "./cron/check-updates.cron"; import { cleanCron } from "./cron/clean.cron"; import { runCronRoute } from "./routes/cron.route"; export const cronModule = buildModule({ name: "cron", routes: [runCronRoute], - cronJobs: [cleanCron], + cronJobs: [cleanCron, checkUpdatesCron], }); diff --git a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts new file mode 100644 index 000000000..d3d770493 --- /dev/null +++ b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts @@ -0,0 +1,29 @@ +import { buildCron } from "@/api/lib/cron"; +import { runUpdateCheckFor } from "@/api/lib/updates/context"; + +export const checkUpdatesCron = buildCron({ + name: "check_updates", + description: + "Check core and plugins for updates and security advisories in their public update manifests", + // Daily at 03:17 in the API server's time zone. A new job has never run, so + // the first scheduler tick after it is registered runs the first check. + schedule: "17 3 * * *", + handler: async c => { + if (!c.get("core").updates.enabled) return; + + const outcome = await runUpdateCheckFor(c, { manual: false }); + + if (outcome.status !== "completed") { + // Someone pressed "Check for updates" a moment ago; that run covers this one. + return; + } + + if (outcome.summary.failedSources > 0) { + await c + .get("log") + .warn( + `Update check: ${String(outcome.summary.failedSources)} of ${String(outcome.summary.sources)} update source(s) could not be read. The last valid results are kept.`, + ); + } + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index 1f51e4319..0293399a1 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -100,7 +100,10 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_delete", dependsOn: ["can_view"] }, ], queue: ["can_view"], - plugins: ["can_view"], + plugins: [ + "can_view", + { permission: "can_check_updates", dependsOn: ["can_view"] }, + ], notifications: [ "can_view", { permission: "can_edit", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/database/updates.ts b/packages/vitnode/src/database/updates.ts new file mode 100644 index 000000000..c4bb4dfe1 --- /dev/null +++ b/packages/vitnode/src/database/updates.ts @@ -0,0 +1,65 @@ +import { camelCase } from "drizzle-orm/pg-core"; + +import type { UpdateEvaluation } from "@/api/lib/updates/evaluate"; +import type { UpdateManifest } from "@/lib/updates/manifest"; + +export interface UpdateCheckError { + code: string; + detail: null | string; +} + +/** + * One row per update manifest URL in use. `document` is the last copy that + * passed validation, cut down to the packages this install checks - kept on a + * failed fetch so findings and security warnings survive it, and answering a + * `304 Not Modified`. + */ +export const core_update_sources = camelCase.table.withRLS( + "core_update_sources", + t => ({ + url: t.varchar({ length: 2048 }).primaryKey(), + etag: t.varchar({ length: 512 }), + lastModified: t.varchar({ length: 128 }), + document: t.jsonb().$type(), + lastAttemptAt: t.timestamp().notNull(), + lastSuccessAt: t.timestamp(), + lastError: t.jsonb().$type(), + }), +); + +/** + * One row per checked component (core or a plugin): the latest result only, + * never a history. `sourceUrl`, `channel`, `versions` and `coreVersion` are + * what the result was computed for, so a redeploy or a config change shows up + * as a stale result instead of passing for a fresh check. + */ +export const core_update_results = camelCase.table.withRLS( + "core_update_results", + t => ({ + componentId: t.varchar({ length: 214 }).primaryKey(), + sourceUrl: t.varchar({ length: 2048 }).notNull(), + channel: t.varchar({ length: 20 }), + versions: t.jsonb().$type().notNull(), + hasUnknownVersion: t.boolean().notNull().default(false), + coreVersion: t.varchar({ length: 256 }), + result: t.jsonb().$type(), + lastAttemptAt: t.timestamp().notNull(), + lastSuccessAt: t.timestamp(), + lastError: t.jsonb().$type(), + }), +); + +/** + * The lease every instance takes before checking, so a daily run and a manual + * one - or two API instances - never fetch at the same time. `expiresAt` bounds + * how long an instance that died mid-check keeps it. + */ +export const core_update_lock = camelCase.table.withRLS( + "core_update_lock", + t => ({ + name: t.varchar({ length: 50 }).primaryKey(), + owner: t.varchar({ length: 64 }), + expiresAt: t.timestamp(), + lastManualAt: t.timestamp(), + }), +); diff --git a/packages/vitnode/src/framework/plugin-inventory/generate.ts b/packages/vitnode/src/framework/plugin-inventory/generate.ts index 08ead04ff..16909638b 100644 --- a/packages/vitnode/src/framework/plugin-inventory/generate.ts +++ b/packages/vitnode/src/framework/plugin-inventory/generate.ts @@ -2,6 +2,7 @@ import type { FrontendPluginInventoryEntry, PluginAuthor, PluginManifest, + PluginUpdateSource, } from "../../lib/plugin-manifest.js"; import { assertPluginManifest } from "../../lib/plugin-manifest.js"; @@ -21,6 +22,9 @@ const isAuthor = (value: unknown): value is PluginAuthor => isNullableString(value.email) && isNullableString(value.url); +const isUpdateSource = (value: unknown): value is PluginUpdateSource => + isRecord(value) && typeof value.url === "string"; + const manifestFrom = ( value: unknown, pluginId: string, @@ -36,6 +40,11 @@ const manifestFrom = ( !isNullableString(value.description) || !isNullableString(value.version) || !(value.license === undefined || isNullableString(value.license)) || + !( + value.updates === undefined || + value.updates === null || + isUpdateSource(value.updates) + ) || !( value.author === undefined || value.author === null || @@ -61,6 +70,10 @@ const manifestFrom = ( id: value.id, license: value.license ?? null, title: value.title, + updates: + value.updates === undefined || value.updates === null + ? null + : { url: value.updates.url }, version: value.version, }; assertPluginManifest(pluginId, manifest, where); @@ -128,6 +141,11 @@ const entrySource = ({ id, manifest }: FrontendPluginInventoryEntry): string => }, license: ${literal(manifest.license)}, version: ${literal(manifest.version)}, + updates: ${ + manifest.updates === undefined || manifest.updates === null + ? "null" + : `{ url: ${literal(manifest.updates.url)} }` + }, }, }, `; diff --git a/packages/vitnode/src/framework/plugin-manifest/generate.ts b/packages/vitnode/src/framework/plugin-manifest/generate.ts index 20726e463..37970a9f1 100644 --- a/packages/vitnode/src/framework/plugin-manifest/generate.ts +++ b/packages/vitnode/src/framework/plugin-manifest/generate.ts @@ -1,9 +1,11 @@ import type { PluginAuthor, PluginManifest, + PluginUpdateSource, } from "../../lib/plugin-manifest.js"; import { PLUGIN_MANIFEST_ERROR_PREFIX as ERROR_PREFIX } from "../../lib/plugin-manifest.js"; +import { updateSourceUrlProblem } from "../../lib/updates/source-url.js"; export const PLUGIN_MANIFEST_PATH = "src/manifest.gen.ts"; @@ -12,7 +14,7 @@ export const PLUGIN_INFO_PATH = "src/plugin-info.ts"; export type PluginPackageManifest = Pick< PluginManifest, "author" | "description" | "id" | "license" | "version" ->; +> & { updates: null | PluginUpdateSource }; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -76,6 +78,36 @@ const authorFrom = (value: unknown, source: string): null | PluginAuthor => { }; }; +const updatesFrom = ( + value: unknown, + source: string, +): null | PluginUpdateSource => { + if (value === undefined || value === null) return null; + if (!isRecord(value)) { + throw new Error( + `${ERROR_PREFIX} \`vitnode\` in ${source} has to be an object.`, + ); + } + + const updates = value.updates; + if (updates === undefined || updates === null) return null; + if (!isRecord(updates) || typeof updates.url !== "string") { + throw new Error( + `${ERROR_PREFIX} \`vitnode.updates\` in ${source} has to be an object with a \`url\`, such as { "url": "https://raw.githubusercontent.com/acme/plugins/main/updates.json" }.`, + ); + } + + const url = updates.url.trim(); + const problem = updateSourceUrlProblem(url); + if (problem !== null) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.updates.url\` in ${source} ${problem}.`, + ); + } + + return { url }; +}; + export const pluginManifestFromPackageJson = ( manifest: unknown, source: string, @@ -96,6 +128,7 @@ export const pluginManifestFromPackageJson = ( description: optionalText(manifest.description, "description", source), id, license: optionalText(manifest.license, "license", source), + updates: updatesFrom(manifest.vitnode, source), version: optionalText(manifest.version, "version", source), }; }; @@ -120,7 +153,8 @@ export const generatePluginManifestSource = ( // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by \`vitnode build\` and \`vitnode dev\` from this package's -// package.json - \`name\`, \`version\`, \`description\`, \`author\` and \`license\` - and the title and +// package.json - \`name\`, \`version\`, \`description\`, \`author\`, \`license\` and +// \`vitnode.updates\` - and the title and // icon in \`src/plugin-info.ts\`. Pass \`manifest\` to \`buildPlugin\`, // \`buildApiPlugin\` and \`bindApiPlugin\` - it is where the plugin id lives. ${hasInfo ? `\nimport { pluginInfo } from "./plugin-info"\n` : ""} @@ -132,5 +166,6 @@ export const manifest = { author: ${authorLiteral(fields.author)}, license: ${literal(fields.license)}, version: ${literal(fields.version)}, + updates: ${fields.updates === null ? "null" : `{ url: ${literal(fields.updates.url)} }`}, } as const `; diff --git a/packages/vitnode/src/lib/plugin-manifest.ts b/packages/vitnode/src/lib/plugin-manifest.ts index ee7bfd1bd..82965a081 100644 --- a/packages/vitnode/src/lib/plugin-manifest.ts +++ b/packages/vitnode/src/lib/plugin-manifest.ts @@ -1,4 +1,5 @@ import { isLucideIconName } from "./emoji-icon"; +import { updateSourceUrlProblem } from "./updates/source-url"; export interface PluginInfo { icon?: string; @@ -11,6 +12,11 @@ export interface PluginAuthor { url: null | string; } +/** `vitnode.updates` in a plugin's package.json: where its public update manifest lives. */ +export interface PluginUpdateSource { + url: string; +} + export interface PluginManifest { author: null | PluginAuthor; description: null | string; @@ -18,6 +24,8 @@ export interface PluginManifest { id: Id; license: null | string; title: string; + /** Missing on manifests generated before update checks existed: same as `null`. */ + updates?: null | PluginUpdateSource; version: null | string; } @@ -25,7 +33,7 @@ export const PLUGIN_MANIFEST_ERROR_PREFIX = "[VitNode plugin manifest]"; export const assertPluginManifest = ( pluginId: string, - manifest: Pick | undefined, + manifest: Pick | undefined, where: string, ): void => { if (manifest === undefined) return; @@ -41,6 +49,16 @@ export const assertPluginManifest = ( `${PLUGIN_MANIFEST_ERROR_PREFIX} ${where} gives the plugin "${pluginId}" the icon ${JSON.stringify(manifest.icon)}. An icon is a Lucide icon name such as "newspaper" - set it in the plugin's \`src/plugin-info.ts\`.`, ); } + + const updatesUrl = manifest.updates?.url; + if (updatesUrl !== undefined) { + const problem = updateSourceUrlProblem(updatesUrl); + if (problem !== null) { + throw new Error( + `${PLUGIN_MANIFEST_ERROR_PREFIX} ${where} gives the plugin "${pluginId}" the update source ${JSON.stringify(updatesUrl)}, which ${problem}. Set \`vitnode.updates.url\` in the plugin's package.json.`, + ); + } + } }; export interface FrontendPluginInventoryEntry { diff --git a/packages/vitnode/src/lib/updates/manifest.ts b/packages/vitnode/src/lib/updates/manifest.ts new file mode 100644 index 000000000..d62e6e5f1 --- /dev/null +++ b/packages/vitnode/src/lib/updates/manifest.ts @@ -0,0 +1,212 @@ +import { parse, prerelease, satisfies, valid, validRange } from "semver"; +import { z } from "zod"; + +import { isHttpsUrl } from "./source-url"; + +export const UPDATE_MANIFEST_SCHEMA_VERSION = 1; + +export const UPDATE_CHANNELS = ["stable", "rc", "canary"] as const; +export type UpdateChannel = (typeof UPDATE_CHANNELS)[number]; + +export const ADVISORY_SEVERITIES = [ + "low", + "moderate", + "high", + "critical", +] as const; +export type AdvisorySeverity = (typeof ADVISORY_SEVERITIES)[number]; + +// npm package names: optional `@scope/`, lowercase, URL-safe, at most 214 chars. +const PACKAGE_ID = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/; + +const ADVISORY_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/; + +export const isPackageId = (value: string): boolean => + value.length <= 214 && PACKAGE_ID.test(value); + +/** A full SemVer version, written the way `semver` normalizes it. */ +export const isStrictVersion = (value: string): boolean => + valid(value) === value; + +export const isVersionRange = (value: string): boolean => + value.trim() !== "" && value.length <= 256 && validRange(value) !== null; + +const httpsUrl = z + .string() + .refine(isHttpsUrl, { message: "Expected an https:// URL" }); + +const version = z + .string() + .max(256) + .refine(isStrictVersion, { message: "Expected a SemVer version" }); + +const range = z + .string() + .refine(isVersionRange, { message: "Expected a SemVer range" }); + +export const updateReleaseSchema = z.strictObject({ + publishedAt: z.iso.datetime({ offset: true }).optional(), + requiresCore: range.optional(), + url: httpsUrl.optional(), +}); + +export const updateAdvisorySchema = z.strictObject({ + id: z.string().regex(ADVISORY_ID, { + message: "Expected letters, digits, '.', '_', ':' or '-' (max 100)", + }), + severity: z.enum(ADVISORY_SEVERITIES), + affected: range, + fixed: version.optional(), + url: httpsUrl, + summary: z.string().trim().min(1).max(500).optional(), +}); + +const MAX_RELEASES = 2000; +const MAX_ADVISORIES = 500; +const MAX_PACKAGES = 500; + +export const updatePackageSchema = z + .strictObject({ + channels: z.partialRecord(z.enum(UPDATE_CHANNELS), version).default({}), + releases: z + .record(version, updateReleaseSchema) + .default({}) + .refine(releases => Object.keys(releases).length <= MAX_RELEASES, { + message: `At most ${MAX_RELEASES} releases per package`, + }), + advisories: z.array(updateAdvisorySchema).max(MAX_ADVISORIES).default([]), + }) + .superRefine((entry, ctx) => { + for (const channel of UPDATE_CHANNELS) { + const target = entry.channels[channel]; + if (target === undefined) continue; + + if (!Object.hasOwn(entry.releases, target)) { + ctx.addIssue({ + code: "custom", + message: `Channel "${channel}" points to ${target}, which has no entry in "releases"`, + path: ["channels", channel], + }); + } + if (channel === "stable" && prerelease(target) !== null) { + ctx.addIssue({ + code: "custom", + message: `The stable channel cannot point to the prerelease ${target}`, + path: ["channels", channel], + }); + } + } + + const ids = new Set(); + entry.advisories.forEach((advisory, index) => { + if (ids.has(advisory.id)) { + ctx.addIssue({ + code: "custom", + message: `Advisory id "${advisory.id}" is listed twice`, + path: ["advisories", index, "id"], + }); + } + ids.add(advisory.id); + }); + }); + +export const updateManifestSchema = z.strictObject({ + schemaVersion: z.literal(UPDATE_MANIFEST_SCHEMA_VERSION), + packages: z + .record( + z + .string() + .refine(isPackageId, { message: "Expected an npm package name" }), + updatePackageSchema, + ) + .refine(packages => Object.keys(packages).length <= MAX_PACKAGES, { + message: `At most ${MAX_PACKAGES} packages per manifest`, + }), +}); + +export type UpdateRelease = z.infer; +export type UpdateAdvisory = z.infer; +export type UpdatePackage = z.infer; +export type UpdateManifest = z.infer; + +export type UpdateManifestParseResult = + | { error: string; ok: false; reason: "invalid" | "unsupported_schema" } + | { manifest: UpdateManifest; ok: true }; + +const describeIssues = (error: z.ZodError): string => + error.issues + .slice(0, 5) + .map(issue => { + const path = issue.path.map(String).join("."); + + return path === "" ? issue.message : `${path}: ${issue.message}`; + }) + .join("; "); + +/** + * Validates an update manifest. A `schemaVersion` this VitNode does not know is + * reported on its own, before anything else is read: a future format may look + * nothing like this one, and "unsupported" is more useful than fifty issues. + */ +export const parseUpdateManifest = ( + value: unknown, +): UpdateManifestParseResult => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { + error: "The manifest is not a JSON object", + ok: false, + reason: "invalid", + }; + } + + const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion; + if (schemaVersion !== UPDATE_MANIFEST_SCHEMA_VERSION) { + return { + error: + typeof schemaVersion === "number" + ? `Unsupported schemaVersion ${String(schemaVersion)}; this VitNode reads version ${String(UPDATE_MANIFEST_SCHEMA_VERSION)}` + : `Missing or invalid schemaVersion; this VitNode reads version ${String(UPDATE_MANIFEST_SCHEMA_VERSION)}`, + ok: false, + reason: "unsupported_schema", + }; + } + + const parsed = updateManifestSchema.safeParse(value); + if (!parsed.success) { + return { + error: describeIssues(parsed.error), + ok: false, + reason: "invalid", + }; + } + + return { manifest: parsed.data, ok: true }; +}; + +/** + * The channel an installed version is on, read from its prerelease tag: + * `2.1.0` is stable, `2.1.0-rc.1` is rc and `2.1.0-canary.4` is canary. Any + * other prerelease (`-beta.1`) is on no channel this format knows - the + * installation has to say which one it follows. + */ +export const channelOfVersion = (value: string): null | UpdateChannel => { + const parsed = parse(value); + if (parsed === null) return null; + + const [tag] = parsed.prerelease; + if (tag === undefined) return "stable"; + if (typeof tag !== "string") return null; + if (tag === "rc" || tag.startsWith("rc")) return "rc"; + if (tag === "canary" || tag.startsWith("canary")) return "canary"; + + return null; +}; + +/** + * Advisory ranges are matched with prereleases included: `2.1.3-canary.1` + * comes before `2.1.3`, so it is inside `<2.1.3`. Without this, `semver` + * would leave every prerelease out of an ordinary range and a canary build + * of a vulnerable line would never be warned about. + */ +export const versionInRange = (value: string, rangeValue: string): boolean => + satisfies(value, rangeValue, { includePrerelease: true }); diff --git a/packages/vitnode/src/lib/updates/source-url.ts b/packages/vitnode/src/lib/updates/source-url.ts new file mode 100644 index 000000000..3ff94acf0 --- /dev/null +++ b/packages/vitnode/src/lib/updates/source-url.ts @@ -0,0 +1,36 @@ +const MAX_URL_LENGTH = 2048; + +export const isHttpsUrl = (value: string): boolean => { + if (value.length > MAX_URL_LENGTH) return false; + + try { + const url = new URL(value); + + return ( + url.protocol === "https:" && + url.hostname !== "" && + url.username === "" && + url.password === "" + ); + } catch { + return false; + } +}; + +/** + * Where a package publishes its update manifest. Public by definition - it + * ends up in generated browser metadata - so nothing that looks like a + * credential is accepted: no user info, no query string, no fragment. + */ +export const updateSourceUrlProblem = (value: string): null | string => { + if (!isHttpsUrl(value)) { + return "has to be an absolute https:// URL without a user name or password"; + } + + const url = new URL(value); + if (url.search !== "" || url.hash !== "") { + return "cannot carry a query string or fragment - an update manifest is public, so it needs no token"; + } + + return null; +}; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 566ee76d0..75a53ba63 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -42,6 +42,7 @@ "@vitnode/core:queue:can_view": "View queue tasks", "@vitnode/core:plugins": "Plugins", "@vitnode/core:plugins:can_view": "View installed plugins and their versions", + "@vitnode/core:plugins:can_check_updates": "Check for plugin and core updates now", "@vitnode/core:notifications": "Notifications", "@vitnode/core:notifications:can_view": "View notification settings and delivery health", "@vitnode/core:notifications:can_edit": "Edit notification settings", @@ -3734,6 +3735,109 @@ "title": "No plugins match your search", "desc": "Try the plugin's name, its package ID or a word from its description.", "clear": "Clear search" + }, + "inventory_missing": { + "title": "Showing the API's plugins only", + "desc": "This API was not given the web app's plugin inventory, so plugins that run only in the browser are not listed and frontend versions are not compared. Pass frontendPlugins to buildApiConfig to see both." + }, + "core": { + "title": "VitNode core", + "version": "Installed version {version}" + }, + "updates": { + "check": { + "button": "Check for updates", + "success": { + "title": "Update check finished", + "desc": "{updates, plural, =0 {No updates} one {# update} other {# updates}} and {advisories, plural, =0 {no security advisories} one {# security advisory} other {# security advisories}} found." + }, + "partial": { + "title": "Some update sources could not be read", + "desc": "{count, plural, one {# source} other {# sources}} failed. Any earlier results for them are kept." + }, + "running": { + "title": "A check is already running", + "desc": "Another check - the daily one, or one started by someone else - is in progress. Its results appear here when it finishes." + }, + "cooldown": { + "title": "Checked moments ago", + "desc": "Try again in {seconds, plural, one {# second} other {# seconds}}." + }, + "disabled": { + "title": "Update checks are turned off", + "desc": "This installation sets updates.enabled to false in its API config." + }, + "error": { + "title": "The check could not be started", + "desc": "The API did not answer. Try again in a moment." + } + }, + "summary": { + "last_checked": "Last checked ", + "never": "The daily check runs with the cron scheduler. Press Check for updates to run it now.", + "disabled": "Update checks are turned off for this installation.", + "channel": "Channel: {channel} (set in the API config)", + "info": "Update information is informational only: nothing is installed or changed. No advisory listed is not proof that a version is secure." + }, + "status": { + "not_checked": "Not checked yet", + "unavailable": "Update checking unavailable", + "disabled": "Update checks are turned off", + "up_to_date": "Up to date", + "update_available": "Update available: {version}", + "package_missing": "Not listed in its publisher's update manifest", + "channel_missing": "No {channel} releases published", + "unknown_channel": "The installed version is on no known channel. Set updates.channel in the API config to choose one.", + "version_unknown": "Installed version unknown - it cannot be confirmed as up to date" + }, + "released": "released ", + "release_notes": "Release notes", + "compatibility": { + "compatible": "Its publisher lists core {range} as supported.", + "incompatible": "Requires a newer core ({range}); this installation runs {core}.", + "unknown": "Compatibility with your core version is unknown - the publisher did not declare it.", + "alternative": "{version} is the newest release on this channel that declares support for your core." + }, + "stale": "Not checked for this deployment yet - showing results from publisher data fetched .", + "failed": "Last check failed: {reason}", + "failed_with_previous": "Last check failed: {reason} Showing the last successful result, from .", + "checked_at": "Checked ", + "error": { + "blocked_address": "the update source points to a private or local address.", + "http_error": "the update source answered HTTP {detail}.", + "invalid_json": "the update source is not valid JSON.", + "invalid_manifest": "the update manifest is invalid ({detail}).", + "invalid_url": "the update source URL is not allowed ({detail}).", + "network": "the update source could not be reached.", + "timeout": "the update source did not answer in time.", + "too_large": "the update manifest is larger than 1 MB.", + "too_many_redirects": "the update source redirected too many times.", + "unsupported_schema": "the update manifest uses a format this VitNode does not support ({detail})." + }, + "security": { + "label": "Security advisories", + "title": "Security advisory {id} - {severity} severity", + "affects": "Affects the installed {versions} (range {range}).", + "fixed": "Fixed in {version}.", + "no_fix": "No fixed version has been published yet.", + "details": "Advisory details" + }, + "severity": { + "low": "low", + "moderate": "moderate", + "high": "high", + "critical": "critical" + }, + "channel": { + "stable": "stable", + "rc": "release candidate", + "canary": "canary" + }, + "side": { + "api": "API", + "frontend": "web app" + }, + "version_on_side": "{version} ({side})" } } }, diff --git a/packages/vitnode/src/manifest.ts b/packages/vitnode/src/manifest.ts index 98fad5894..ffe66a1e5 100644 --- a/packages/vitnode/src/manifest.ts +++ b/packages/vitnode/src/manifest.ts @@ -11,4 +11,7 @@ export const manifest = { }, license: "MIT", version: "2.0.0-canary.13", + updates: { + url: "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json", + }, } as const; diff --git a/packages/vitnode/src/tanstack/admin/plugins/index.ts b/packages/vitnode/src/tanstack/admin/plugins/index.ts index 2021a23bf..5971230fa 100644 --- a/packages/vitnode/src/tanstack/admin/plugins/index.ts +++ b/packages/vitnode/src/tanstack/admin/plugins/index.ts @@ -1,6 +1,14 @@ export type { AdminPluginsRouteData } from "./route"; export { ADMIN_PLUGINS_NAMESPACES, loadAdminPluginsRoute } from "./route"; export { AdminPluginsRouteContent } from "./screen"; +export type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; +export { + pluginUpdatesQueryKey, + pluginUpdatesQueryOptions, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; export type { AdminInstalledPlugin, AdminInstalledPlugins, diff --git a/packages/vitnode/src/tanstack/admin/plugins/route.tsx b/packages/vitnode/src/tanstack/admin/plugins/route.tsx index 0d2e2d1ca..b6b5f7479 100644 --- a/packages/vitnode/src/tanstack/admin/plugins/route.tsx +++ b/packages/vitnode/src/tanstack/admin/plugins/route.tsx @@ -1,5 +1,6 @@ import type { PluginRouteTranslator } from "@/routing"; +import { pluginUpdatesQueryOptions } from "@/views/admin/views/core/system/plugins/plugin-updates-query"; import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; import type { AdminScreenContext } from "../screen"; @@ -30,10 +31,16 @@ export const loadAdminPluginsRoute = async ({ }): Promise => { requireAdminPermission(adminAccess, PLUGINS_VIEW_PERMISSION); - await queryClient.query({ - ...installedPluginsQueryOptions(), - staleTime: "static", - }); + await Promise.all([ + queryClient.query({ + ...installedPluginsQueryOptions(), + staleTime: "static", + }), + // Warmed, not required: a failure leaves the catalog without update results. + queryClient + .query({ ...pluginUpdatesQueryOptions(), staleTime: "static" }) + .catch(() => undefined), + ]); return { description: t("admin.system.plugins.desc"), diff --git a/packages/vitnode/src/tanstack/admin/plugins/screen.tsx b/packages/vitnode/src/tanstack/admin/plugins/screen.tsx index 185d109b4..d2b142495 100644 --- a/packages/vitnode/src/tanstack/admin/plugins/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/plugins/screen.tsx @@ -1,6 +1,12 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; import { PageTitle } from "@/components/ui/page-title"; +import { CheckUpdatesButton } from "@/views/admin/views/core/system/plugins/check-updates-button"; +import { CoreUpdateCard } from "@/views/admin/views/core/system/plugins/core-update-card"; +import { + checkPluginUpdates, + pluginUpdatesQueryOptions, +} from "@/views/admin/views/core/system/plugins/plugin-updates-query"; import { PluginsContent } from "@/views/admin/views/core/system/plugins/plugins-content"; import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; @@ -14,13 +20,20 @@ export const AdminPluginsRouteContent = ({ title, }: AdminPluginsRouteData) => { const { data } = useSuspenseQuery(installedPluginsQueryOptions()); + // Not suspending: the catalog is useful without update results, and a + // failure here must not take the list of installed plugins down with it. + const { data: updates } = useQuery(pluginUpdatesQueryOptions()); return ( -
- +
+ + + - + + +
); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx new file mode 100644 index 000000000..2e02849e1 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/check-updates-button.tsx @@ -0,0 +1,83 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { RefreshCwIcon } from "lucide-react"; +import { useActionState } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "use-intl"; + +import { useAdminStaffPermission } from "@/components/staff-permission/provider"; +import { Button } from "@/components/ui/button"; +import { manifest } from "@/manifest"; + +import type { CheckPluginUpdates } from "./plugin-updates-query"; + +import { pluginUpdatesQueryKey } from "./plugin-updates-query"; + +export const CheckUpdatesButton = ({ + onCheck, +}: { + onCheck: CheckPluginUpdates; +}) => { + const t = useTranslations("admin.system.plugins.updates.check"); + const queryClient = useQueryClient(); + const canCheck = useAdminStaffPermission({ + plugin: manifest.id, + module: "plugins", + permission: "can_check_updates", + }); + const [, formAction, isPending] = useActionState(async () => { + const result = await onCheck(); + + switch (result.status) { + case "completed": + await queryClient.invalidateQueries({ + queryKey: pluginUpdatesQueryKey, + }); + if (result.failedSources > 0) { + toast.warning(t("partial.title"), { + description: t("partial.desc", { count: result.failedSources }), + }); + } else { + toast.success(t("success.title"), { + description: t("success.desc", { + advisories: result.advisories, + updates: result.updates, + }), + }); + } + + return; + case "cooldown": + toast.info(t("cooldown.title"), { + description: t("cooldown.desc", { + seconds: result.retryAfterSeconds ?? 60, + }), + }); + + return; + case "disabled": + toast.error(t("disabled.title"), { description: t("disabled.desc") }); + + return; + case "running": + await queryClient.invalidateQueries({ + queryKey: pluginUpdatesQueryKey, + }); + toast.info(t("running.title"), { description: t("running.desc") }); + + return; + case "error": + toast.error(t("error.title"), { description: t("error.desc") }); + } + }, null); + + if (!canCheck) return null; + + return ( +
+ +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx new file mode 100644 index 000000000..3b1cd13be --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx @@ -0,0 +1,104 @@ +import { PackageIcon } from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; + +import type { AdminPluginUpdates } from "./plugin-updates-query"; + +import { PluginUpdateStatus, SecurityAdvisories } from "./plugin-update-status"; + +const lastCheckedOf = (updates: AdminPluginUpdates): null | string => + [updates.core, ...updates.plugins] + .map(component => component.lastSuccessAt) + .reduce( + (latest, value) => + value !== null && (latest === null || value > latest) ? value : latest, + null, + ); + +const CheckSummary = ({ updates }: { updates: AdminPluginUpdates }) => { + const t = useTranslations("admin.system.plugins.updates"); + + if (!updates.enabled) return <>{t("summary.disabled")}; + + const lastChecked = lastCheckedOf(updates); + if (lastChecked === null) return <>{t("summary.never")}; + + return ( + <> + {t.rich("summary.last_checked", { + date: () => , + })} + + ); +}; + +/** + * Core's own row, kept apart from the plugins: its version, its update status + * and any advisory against it, plus when everything was last checked. + */ +export const CoreUpdateCard = ({ + updates, + version, +}: { + updates: AdminPluginUpdates | undefined; + version: string; +}) => { + const t = useTranslations("admin.system.plugins"); + + return ( +
+
+ +
+

+ {t("core.title")} +

+

+ {t.rich("core.version", { + version, + mono: chunks => ( + {chunks} + ), + })} +

+ +
+
+ + + + {updates === undefined ? null : ( +
+

+ +

+ {updates.channelOverride === null ? null : ( +

+ {t("updates.summary.channel", { + channel: t(`updates.channel.${updates.channelOverride}`), + })} +

+ )} +

{t("updates.summary.info")}

+
+ )} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx new file mode 100644 index 000000000..c9635a2d3 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.tsx @@ -0,0 +1,357 @@ +import { cn } from "cn"; +import { + ArrowUpCircleIcon, + CircleCheckIcon, + CircleDashedIcon, + CircleHelpIcon, + CircleOffIcon, + ClockAlertIcon, + ExternalLinkIcon, + ShieldAlertIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { useTranslations } from "use-intl"; + +import { DateFormat } from "@/components/date-format"; + +import type { AdminComponentUpdate } from "./plugin-updates-query"; + +/** + * Publisher data is untrusted: a link is only rendered when it is an absolute + * https:// URL, whatever the API already checked. + */ +export const safeHttpsHref = (value: null | string): null | string => { + if (value === null) return null; + + try { + return new URL(value).protocol === "https:" ? value : null; + } catch { + return null; + } +}; + +const ExternalLink = ({ + children, + href, +}: { + children: React.ReactNode; + href: null | string; +}) => { + const safe = safeHttpsHref(href); + if (safe === null) return null; + + return ( + + {children} + + + ); +}; + +/** Which registration runs each version, for "1.0.0 (web app)". */ +export type VersionSides = Record; + +export const SecurityAdvisories = ({ + sides, + update, +}: { + sides?: VersionSides; + update: AdminComponentUpdate | undefined; +}) => { + const t = useTranslations("admin.system.plugins"); + const advisories = update?.result?.advisories ?? []; + if (advisories.length === 0) return null; + + const describeVersions = (versions: readonly string[]): string => + versions + .map(version => { + const labels = sides?.[version]; + const [side] = labels ?? []; + if (labels?.length !== 1 || side === undefined) return version; + + return t("updates.version_on_side", { + side: t(`updates.side.${side}`), + version, + }); + }) + .join(", "); + + return ( +
    + {advisories.map(advisory => ( +
  • + +
    +

    + {t("updates.security.title", { + id: advisory.id, + severity: t(`updates.severity.${advisory.severity}`), + })} +

    +

    + {t("updates.security.affects", { + range: advisory.affected, + versions: describeVersions(advisory.versions), + })}{" "} + {advisory.fixed === null + ? t("updates.security.no_fix") + : t("updates.security.fixed", { version: advisory.fixed })} +

    + {advisory.summary === null ? null : ( +

    + {advisory.summary} +

    + )} + + {t("updates.security.details")} + +
    +
  • + ))} +
+ ); +}; + +const LINE_TONE = { + muted: "text-muted-foreground", + primary: "text-foreground", + success: "text-success", + warn: "text-warn", +} as const; + +const Line = ({ + children, + icon: Icon, + tone = "muted", +}: { + children: React.ReactNode; + icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; + tone?: keyof typeof LINE_TONE; +}) => ( +

+ + {children} +

+); + +const CHECK_ERRORS = [ + "blocked_address", + "http_error", + "invalid_json", + "invalid_manifest", + "invalid_url", + "network", + "timeout", + "too_large", + "too_many_redirects", + "unsupported_schema", +] as const; + +const errorCode = ( + error: NonNullable, +): (typeof CHECK_ERRORS)[number] => + CHECK_ERRORS.find(entry => entry === error.code) ?? "network"; + +const ResultLines = ({ + coreVersion, + update, +}: { + coreVersion: string; + update: AdminComponentUpdate; +}) => { + const t = useTranslations("admin.system.plugins"); + const result = update.result; + if (result === null) return null; + + switch (result.status) { + case "channel_missing": + return ( + + {t("updates.status.channel_missing", { + channel: t(`updates.channel.${result.channel ?? "stable"}`), + })} + + ); + case "package_missing": + return ( + {t("updates.status.package_missing")} + ); + case "unknown_channel": + return ( + + {t("updates.status.unknown_channel")} + + ); + case "up_to_date": + return ( + + {t("updates.status.up_to_date")} + + ); + case "update_available": { + const latest = result.latest; + if (latest === null) return null; + + return ( + <> + + + {t("updates.status.update_available", { + version: latest.version, + })} + + {latest.publishedAt === null ? null : ( + + {" "} + {t.rich("updates.released", { + date: () => ( + + ), + })} + + )}{" "} + + {t("updates.release_notes")} + + + {result.compatibility === "incompatible" ? ( + + {t("updates.compatibility.incompatible", { + core: coreVersion, + range: latest.requiresCore ?? "", + })} + + ) : null} + {result.compatibility === "unknown" ? ( + + {t("updates.compatibility.unknown")} + + ) : null} + {result.compatibility === "compatible" ? ( + + {t("updates.compatibility.compatible", { + range: latest.requiresCore ?? "", + })} + + ) : null} + {result.alternative === null ? null : ( + + {t("updates.compatibility.alternative", { + version: result.alternative.version, + })}{" "} + + {t("updates.release_notes")} + + + )} + + ); + } + case "version_unknown": + return ( + + {t("updates.status.version_unknown")} + + ); + } +}; + +/** + * Update availability, compatibility and check health - each its own line. + * Security findings are rendered apart, by `SecurityAdvisories`, and never + * folded into "update available". + */ +export const PluginUpdateStatus = ({ + coreVersion, + enabled, + showCheckedAt = true, + update, +}: { + coreVersion: string; + enabled: boolean; + /** Off where the same date is already shown nearby. */ + showCheckedAt?: boolean; + update: AdminComponentUpdate | undefined; +}) => { + const t = useTranslations("admin.system.plugins"); + + if (update === undefined) return null; + if (!enabled) { + return {t("updates.status.disabled")}; + } + if (update.state === "unavailable") { + return {t("updates.status.unavailable")}; + } + + const lastError = update.lastError; + const reason = + lastError === null + ? "" + : t(`updates.error.${errorCode(lastError)}`, { + detail: lastError.detail ?? "", + }); + + return ( +
+ {update.state === "not_checked" && lastError === null ? ( + {t("updates.status.not_checked")} + ) : null} + + + + {update.stale && update.lastSuccessAt !== null ? ( + + {t.rich("updates.stale", { + date: () => ( + + ), + })} + + ) : null} + + {lastError === null ? null : ( + + {update.state === "checked" && update.lastSuccessAt !== null + ? t.rich("updates.failed_with_previous", { + date: () => ( + + ), + reason, + }) + : t("updates.failed", { reason })} + + )} + + {showCheckedAt && + update.state === "checked" && + !update.stale && + lastError === null && + update.lastSuccessAt !== null ? ( +

+ {t.rich("updates.checked_at", { + date: () => , + })} +

+ ) : null} +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts new file mode 100644 index 000000000..b820e5a5d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-updates-query.ts @@ -0,0 +1,85 @@ +import type { z } from "zod"; + +import { queryOptions } from "@tanstack/react-query"; + +import type { + componentUpdateSchema, + updatesOverviewSchema, +} from "@/api/modules/admin/plugins/routes/updates.schema"; + +import { manifest } from "@/manifest"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; + +import { installedPluginsQueryKey } from "./plugins-query"; + +export type AdminPluginUpdates = z.infer; +export type AdminComponentUpdate = z.infer; + +export type PluginUpdatesFetcher = () => Promise; + +export const fetchPluginUpdates: PluginUpdatesFetcher = async () => { + const response = await fetcher({ + plugin: manifest.id, + method: "get", + module: "admin/plugins", + path: "/updates", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the plugin update status"); + } + + return await response.json(); +}; + +export const pluginUpdatesQueryKey = [ + ...installedPluginsQueryKey, + "updates", +] as const; + +export const pluginUpdatesQueryOptions = () => + queryOptions({ + queryFn: async () => await fetchPluginUpdates(), + queryKey: pluginUpdatesQueryKey, + retry: false, + staleTime: 60_000, + }); + +export type CheckPluginUpdatesResult = + | { + advisories: number; + failedSources: number; + status: "completed"; + updates: number; + } + | { + retryAfterSeconds: null | number; + status: "cooldown" | "disabled" | "running"; + } + | { status: "error" }; + +export type CheckPluginUpdates = () => Promise; + +export const checkPluginUpdates: CheckPluginUpdates = async () => { + try { + const response = await fetcher({ + plugin: manifest.id, + method: "post", + module: "admin/plugins", + path: "/updates/check", + }); + + if ( + response.status === 200 || + response.status === 409 || + response.status === 429 + ) { + return await response.json(); + } + + return { status: "error" }; + } catch { + return { status: "error" }; + } +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx index 077c1502a..24d635caa 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx @@ -12,8 +12,14 @@ import { readTableSearch, withTableSearch, } from "@/components/table/url-state"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import type { VersionSides } from "./plugin-update-status"; +import type { + AdminComponentUpdate, + AdminPluginUpdates, +} from "./plugin-updates-query"; import type { AdminInstalledPlugin, AdminInstalledPlugins, @@ -21,11 +27,28 @@ import type { import { PluginIcon } from "./plugin-icon"; import { matchesPluginSearch } from "./plugin-search"; +import { PluginUpdateStatus, SecurityAdvisories } from "./plugin-update-status"; export interface PluginsContentProps { data: AdminInstalledPlugins; + /** Stored update results. Left out, the catalog lists versions only. */ + updates?: AdminPluginUpdates; } +/** Only worth naming the side when the API and the web app run different versions. */ +const versionSidesOf = (plugin: AdminInstalledPlugin): VersionSides => { + if (!plugin.versionMismatch) return {}; + + const sides: VersionSides = {}; + const { api, frontend } = plugin.registrations; + if (api?.version) sides[api.version] = [...(sides[api.version] ?? []), "api"]; + if (frontend?.version) { + sides[frontend.version] = [...(sides[frontend.version] ?? []), "frontend"]; + } + + return sides; +}; + const PluginVersion = ({ version }: { version: null | string }) => { const t = useTranslations("admin.system.plugins"); @@ -40,7 +63,17 @@ const PluginVersion = ({ version }: { version: null | string }) => { return {version}; }; -const PluginCell = ({ plugin }: { plugin: AdminInstalledPlugin }) => { +const PluginCell = ({ + coreVersion, + enabled, + plugin, + update, +}: { + coreVersion: string; + enabled: boolean; + plugin: AdminInstalledPlugin; + update: AdminComponentUpdate | undefined; +}) => { const t = useTranslations("admin.system.plugins"); const { api, frontend } = plugin.registrations; @@ -69,12 +102,21 @@ const PluginCell = ({ plugin }: { plugin: AdminInstalledPlugin }) => { {t("mismatch", { api: api.version, frontend: frontend.version })}

) : null} + + {/* Narrow screens: the version column has no room for this. */} +
+ +
); }; -export const PluginsContent = ({ data }: PluginsContentProps) => { +export const PluginsContent = ({ data, updates }: PluginsContentProps) => { const t = useTranslations("admin.system.plugins"); const [searchParams, setSearchParams] = React.useState( () => new URLSearchParams(), @@ -100,21 +142,51 @@ export const PluginsContent = ({ data }: PluginsContentProps) => { (currentPage - 1) * pageSize, currentPage * pageSize, ); + const updateById = new Map( + (updates?.plugins ?? []).map(update => [update.id, update]), + ); return ( + {data.frontendInventory ? null : ( + + {t("inventory_missing.title")} + + {t("inventory_missing.desc")} + + + )} , + cell: ({ row }) => ( + + ), }, { id: "version", header: t("list.version"), - cell: ({ row }) => , + className: "whitespace-normal md:min-w-64", + cell: ({ row }) => ( +
+ +
+ +
+
+ ), }, ]} customNoResults={ diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index ac38276b8..1ec112f8e 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -26,6 +26,7 @@ import type { import type { VitNodeMetadata } from "./lib/metadata"; import type { BuildPluginReturn } from "./lib/plugin"; import type { FrontendPluginInventory } from "./lib/plugin-manifest"; +import type { UpdateChannel } from "./lib/updates/manifest"; import type { PersonalInformationFieldsConfig } from "./lib/user-personal-information"; import type { PageCacheConfig } from "./page-cache/config"; @@ -158,6 +159,21 @@ export interface VitNodeApiConfig { webp?: boolean; }; }; + /** + * Update and security-advisory checks for core and plugins, shown in + * AdminCP → System → Plugins. They read public JSON manifests once a day and + * never install anything. + */ + updates?: { + /** + * The release channel to compare against. Defaults to the channel of the + * installed version: `2.1.0` follows stable, `-rc.N` rc, `-canary.N` + * canary. Setting it is how an installation moves channels on purpose. + */ + channel?: UpdateChannel; + /** `false` turns checking off - for an install without outbound network access. Defaults to `true`. */ + enabled?: boolean; + }; users?: { /** * Which personal-information fields this install offers. Every field is on diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 62727e9fa..9ac9fca23 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -8,6 +8,11 @@ "url": "https://vitnode.com" }, "license": "MIT", + "vitnode": { + "updates": { + "url": "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json" + } + }, "homepage": "https://vitnode.com", "repository": { "type": "git", diff --git a/plugins/blog/src/manifest.gen.ts b/plugins/blog/src/manifest.gen.ts index f4b123381..2e0611625 100644 --- a/plugins/blog/src/manifest.gen.ts +++ b/plugins/blog/src/manifest.gen.ts @@ -3,7 +3,8 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json - `name`, `version`, `description`, `author` and `license` - and the title and +// package.json - `name`, `version`, `description`, `author`, `license` and +// `vitnode.updates` - and the title and // icon in `src/plugin-info.ts`. Pass `manifest` to `buildPlugin`, // `buildApiPlugin` and `bindApiPlugin` - it is where the plugin id lives. @@ -21,4 +22,5 @@ export const manifest = { }, license: "MIT", version: "2.0.0-canary.13", + updates: { url: "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json" }, } as const diff --git a/plugins/example/src/manifest.gen.ts b/plugins/example/src/manifest.gen.ts index cff9eb31d..706f54f17 100644 --- a/plugins/example/src/manifest.gen.ts +++ b/plugins/example/src/manifest.gen.ts @@ -3,7 +3,8 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json - `name`, `version`, `description`, `author` and `license` - and the title and +// package.json - `name`, `version`, `description`, `author`, `license` and +// `vitnode.updates` - and the title and // icon in `src/plugin-info.ts`. Pass `manifest` to `buildPlugin`, // `buildApiPlugin` and `bindApiPlugin` - it is where the plugin id lives. @@ -21,4 +22,5 @@ export const manifest = { }, license: "MIT", version: "2.0.0-canary.13", + updates: null, } as const diff --git a/plugins/forum/src/manifest.gen.ts b/plugins/forum/src/manifest.gen.ts index 7c648daf6..0d58a8c89 100644 --- a/plugins/forum/src/manifest.gen.ts +++ b/plugins/forum/src/manifest.gen.ts @@ -3,7 +3,8 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json - `name`, `version`, `description`, `author` and `license` - and the title and +// package.json - `name`, `version`, `description`, `author`, `license` and +// `vitnode.updates` - and the title and // icon in `src/plugin-info.ts`. Pass `manifest` to `buildPlugin`, // `buildApiPlugin` and `bindApiPlugin` - it is where the plugin id lives. @@ -21,4 +22,5 @@ export const manifest = { }, license: "MIT", version: "2.0.0-canary.13", + updates: null, } as const diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45b178f23..fb06dc916 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,6 +687,9 @@ importers: sanitize-html: specifier: ^2.18.0 version: 2.18.0 + semver: + specifier: ^7.8.5 + version: 7.8.5 shadcn: specifier: ^4.21.4 version: 4.21.4 @@ -748,6 +751,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.2 version: 2.16.2 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 '@vitejs/plugin-react': specifier: ^6.1.2 version: 6.1.2(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) @@ -4938,6 +4944,9 @@ packages: '@types/sanitize-html@2.16.2': resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -12860,6 +12869,8 @@ snapshots: dependencies: htmlparser2: 12.0.0 + '@types/semver@7.8.0': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} diff --git a/scripts/bump-version/version-manager.ts b/scripts/bump-version/version-manager.ts index 95a7984db..d709634c7 100644 --- a/scripts/bump-version/version-manager.ts +++ b/scripts/bump-version/version-manager.ts @@ -26,6 +26,11 @@ export class VersionManager { async init(): Promise { if (this.env.GITHUB_OPTION_MODE === "publish") { console.log("Skipping version bump in publish mode"); + // The release steps and the update manifest still need the tag of what + // is about to be published. + this.exposeNewVersion( + `${CONFIG.TAG_PREFIX}${this.getCurrentVersion()}${CONFIG.TAG_SUFFIX}`, + ); return; } diff --git a/scripts/update-manifest/publish-update-manifest.ts b/scripts/update-manifest/publish-update-manifest.ts new file mode 100644 index 000000000..d1819940f --- /dev/null +++ b/scripts/update-manifest/publish-update-manifest.ts @@ -0,0 +1,331 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { UpdateManifest } from "../../packages/vitnode/src/lib/updates/manifest.ts"; +import type { + GitHubReleaseMetadata, + PublishedPackage, +} from "../../packages/vitnode/scripts/update-manifest/merge.ts"; + +import { + applyAdvisories, + channelForReleaseType, + mergeRelease, + parseAdvisoriesInput, + readPublishedManifest, + requiresCoreFrom, + serializeUpdateManifest, +} from "../../packages/vitnode/scripts/update-manifest/merge.ts"; + +/** + * Publishes VitNode's official update manifest to the `update-manifest` branch, + * served at https://raw.githubusercontent.com//update-manifest/updates.json. + * + * release after a successful npm publish: adds the published versions + * advisories replaces the advisories with `security/advisories.json` + * check validates `security/advisories.json` and exits + * + * Every write fetches the branch, merges, and pushes; a push that loses a race + * is retried from a fresh fetch, so concurrent runs cannot overwrite each other. + */ + +const BRANCH = process.env.UPDATE_MANIFEST_BRANCH ?? "update-manifest"; +const FILE = "updates.json"; +const ADVISORIES_FILE = join("security", "advisories.json"); +const MAX_PUSH_ATTEMPTS = 5; +const CORE_ID = "@vitnode/core"; + +const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(); + +const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + + return value; +}; + +const run = ( + command: string, + args: string[], + options: { cwd?: string; allowFailure?: boolean } = {}, +): null | string => { + try { + return execFileSync(command, args, { + cwd: options.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + if (options.allowFailure) return null; + // The remote URL carries the token; never echo the arguments. + const stderr = + typeof error === "object" && error !== null && "stderr" in error + ? String(error.stderr).replaceAll(/x-access-token:[^@]+@/g, "***@") + : ""; + throw new Error(`\`${command} ${args[0] ?? ""}\` failed. ${stderr}`.trim()); + } +}; + +interface WorkspacePackage { + dependencies: Record; + name: string; + updatesUrl: null | string; + version: null | string; +} + +const readWorkspacePackages = (): Map => { + const packages = new Map(); + + for (const group of ["packages", "plugins", "apps"]) { + const root = join(workspace, group); + if (!existsSync(root)) continue; + + for (const dir of readdirSync(root)) { + const file = join(root, dir, "package.json"); + if (!existsSync(file)) continue; + + const json = JSON.parse(readFileSync(file, "utf8")) as { + dependencies?: Record; + name?: string; + peerDependencies?: Record; + version?: string; + vitnode?: { updates?: { url?: string } }; + }; + if (!json.name) continue; + + packages.set(json.name, { + dependencies: { ...json.peerDependencies, ...json.dependencies }, + name: json.name, + updatesUrl: json.vitnode?.updates?.url ?? null, + version: json.version ?? null, + }); + } + } + + return packages; +}; + +const githubReleaseFor = ( + repository: string, + tag: string, +): GitHubReleaseMetadata | null => { + const output = run( + "gh", + [ + "release", + "view", + tag, + "--repo", + repository, + "--json", + "publishedAt,url,isDraft", + ], + { allowFailure: true }, + ); + if (output === null) return null; + + const release = JSON.parse(output) as { + isDraft?: boolean; + publishedAt?: null | string; + url?: string; + }; + if (release.isDraft || !release.publishedAt || !release.url) return null; + + return { publishedAt: release.publishedAt, url: release.url }; +}; + +/** + * The packages this run published that point at this feed. A package that is + * published but declares another update source - or none - is not ours to + * advertise here. + */ +const publishedPackages = (feedUrl: string): PublishedPackage[] => { + const names = requireEnv("PUBLISHED_PACKAGES").split(/\s+/).filter(Boolean); + const workspacePackages = readWorkspacePackages(); + const coreVersion = workspacePackages.get(CORE_ID)?.version ?? null; + + return names.flatMap(name => { + const pkg = workspacePackages.get(name); + if (!pkg) throw new Error(`${name} is not a package in this workspace.`); + if (pkg.updatesUrl?.toLowerCase() !== feedUrl.toLowerCase()) return []; + if (!pkg.version) throw new Error(`${name} has no version.`); + + const requiresCore = + name === CORE_ID || coreVersion === null + ? undefined + : requiresCoreFrom(pkg.dependencies[CORE_ID], coreVersion); + + return [{ id: name, requiresCore, version: pkg.version }]; + }); +}; + +const publishToBranch = ({ + message, + repository, + token, + update, +}: { + message: string; + repository: string; + token: string; + update: (manifest: UpdateManifest) => UpdateManifest; +}): void => { + const remote = + process.env.UPDATE_MANIFEST_REMOTE ?? + `https://x-access-token:${token}@github.com/${repository}.git`; + + for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) { + const dir = mkdtempSync(join(tmpdir(), "vitnode-update-manifest-")); + try { + run("git", ["init", "--quiet"], { cwd: dir }); + run("git", ["remote", "add", "origin", remote], { cwd: dir }); + run("git", ["config", "user.name", "github-actions[bot]"], { cwd: dir }); + run( + "git", + [ + "config", + "user.email", + "41898282+github-actions[bot]@users.noreply.github.com", + ], + { cwd: dir }, + ); + + const fetched = + run("git", ["fetch", "--quiet", "--depth", "1", "origin", BRANCH], { + allowFailure: true, + cwd: dir, + }) !== null; + if (fetched) { + run("git", ["checkout", "--quiet", "-B", BRANCH, "FETCH_HEAD"], { + cwd: dir, + }); + } else { + // First publication: a branch with nothing but the manifest on it. + run("git", ["checkout", "--quiet", "--orphan", BRANCH], { cwd: dir }); + } + + const path = join(dir, FILE); + const before = existsSync(path) ? readFileSync(path, "utf8") : null; + const after = serializeUpdateManifest( + update(readPublishedManifest(before)), + ); + + if (before === after) { + console.log("updates.json is already up to date - nothing to push."); + + return; + } + + writeFileSync(path, after); + run("git", ["add", FILE], { cwd: dir }); + run("git", ["commit", "--quiet", "-m", message], { cwd: dir }); + + const pushed = + run("git", ["push", "--quiet", "origin", `HEAD:${BRANCH}`], { + allowFailure: true, + cwd: dir, + }) !== null; + if (pushed) { + console.log(`Published ${FILE} to ${BRANCH}.`); + + return; + } + + console.warn( + `Push attempt ${String(attempt)} was rejected - another run published first. Merging again.`, + ); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + } + + throw new Error( + `Could not publish ${FILE} after ${String(MAX_PUSH_ATTEMPTS)} attempts.`, + ); +}; + +const readAdvisories = () => + parseAdvisoriesInput(readFileSync(join(workspace, ADVISORIES_FILE), "utf8")); + +const main = () => { + const mode = process.argv[2]; + + if (mode === "check") { + const input = readAdvisories(); + const count = Object.values(input.advisories).flat().length; + console.log(`${ADVISORIES_FILE} is valid (${String(count)} advisories).`); + + return; + } + + const repository = requireEnv("GITHUB_REPOSITORY"); + const token = requireEnv("GITHUB_TOKEN"); + const feedUrl = `https://raw.githubusercontent.com/${repository}/${BRANCH}/${FILE}`; + + if (mode === "advisories") { + const input = readAdvisories(); + publishToBranch({ + message: "chore: update security advisories", + repository, + token, + update: manifest => applyAdvisories(manifest, input), + }); + + return; + } + + if (mode === "release") { + const channel = channelForReleaseType(requireEnv("RELEASE_TYPE")); + const packages = publishedPackages(feedUrl); + if (packages.length === 0) { + console.log("No published package points at this update manifest."); + + return; + } + + const coreVersion = + packages.find(pkg => pkg.id === CORE_ID)?.version ?? packages[0]?.version; + // Releases are tagged v by the bump script; a run that skipped the + // GitHub Release simply finds none, and the entry goes out without a date. + const githubRelease = coreVersion + ? githubReleaseFor(repository, `v${coreVersion}`) + : null; + if (githubRelease === null) { + console.log( + "No published GitHub Release for this version - publishedAt and the release URL are left out.", + ); + } + + publishToBranch({ + message: `chore: publish ${packages + .map(pkg => `${pkg.id}@${pkg.version}`) + .join(", ")} to ${channel}`, + repository, + token, + update: manifest => + mergeRelease(manifest, { channel, githubRelease, packages }), + }); + + return; + } + + throw new Error( + "Usage: publish-update-manifest.ts ", + ); +}; + +try { + main(); +} catch (error) { + console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} diff --git a/security/README.md b/security/README.md new file mode 100644 index 000000000..a839b06ac --- /dev/null +++ b/security/README.md @@ -0,0 +1,7 @@ +# Security advisories + +`advisories.json` is the maintained list of security advisories for the packages VitNode publishes. The **Publish security advisories** workflow validates it on every pull request and, once it lands on `canary`, copies it into the public update manifest on the `update-manifest` branch - the one every VitNode installation reads once a day. + +How to write an entry, how ranges match prereleases, and how to announce a vulnerability without a fix: https://vitnode.com/docs/dev/updates/security-advisories + +Only real advisories belong here. Examples live in the documentation. diff --git a/security/advisories.json b/security/advisories.json new file mode 100644 index 000000000..f407294ca --- /dev/null +++ b/security/advisories.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "advisories": {} +} From 7edc42f58e0d875eea37496c98fbdefaf4228605 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:22:56 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(plugins):=20=E2=9C=85=20cover=20update?= =?UTF-8?q?=20checks,=20advisories,=20lease=20and=20release=20feed=20mergi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression tests for the update checker: manifest contract, SemVer and channel evaluation, prerelease advisory matching, API/frontend mismatch, compatibility, safe fetching, shared sources, failure preservation, 304 reuse, stale results, the database lease and cooldown, route permissions, release-manifest merging and the catalog's update states. Generator tests now expect the `updates` field. Also only accept a 304 when a conditional header was actually sent, and fix two swapped comments on UpdateStatus. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01412WzAxDRk5MbUe4C2S2N1 --- .../scripts/update-manifest/merge.test.ts | 258 ++++++++++++ .../scripts/write-plugin-manifest.test.ts | 24 ++ .../vitnode/src/api/lib/updates/check.test.ts | 374 ++++++++++++++++++ packages/vitnode/src/api/lib/updates/check.ts | 2 +- .../src/api/lib/updates/components.test.ts | 121 ++++++ .../src/api/lib/updates/evaluate.test.ts | 271 +++++++++++++ .../vitnode/src/api/lib/updates/evaluate.ts | 4 +- .../src/api/lib/updates/fetch-source.test.ts | 314 +++++++++++++++ .../src/api/lib/updates/fetch-source.ts | 2 +- .../vitnode/src/api/lib/updates/lock.test.ts | 160 ++++++++ .../src/api/lib/updates/status.test.ts | 225 +++++++++++ .../routes/check-updates.route.test.ts | 136 +++++++ .../plugins/routes/updates.route.test.ts | 114 ++++++ .../plugin-inventory/generate.test.ts | 40 +- .../plugin-manifest/generate.test.ts | 31 ++ .../vitnode/src/lib/updates/manifest.test.ts | 158 ++++++++ .../src/lib/updates/source-url.test.ts | 28 ++ .../plugins/plugin-update-status.test.tsx | 198 ++++++++++ 18 files changed, 2453 insertions(+), 7 deletions(-) create mode 100644 packages/vitnode/scripts/update-manifest/merge.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/check.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/components.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/evaluate.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/fetch-source.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/lock.test.ts create mode 100644 packages/vitnode/src/api/lib/updates/status.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts create mode 100644 packages/vitnode/src/lib/updates/manifest.test.ts create mode 100644 packages/vitnode/src/lib/updates/source-url.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx diff --git a/packages/vitnode/scripts/update-manifest/merge.test.ts b/packages/vitnode/scripts/update-manifest/merge.test.ts new file mode 100644 index 000000000..84bda9b26 --- /dev/null +++ b/packages/vitnode/scripts/update-manifest/merge.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; + +import type { UpdateManifest } from "../../src/lib/updates/manifest"; + +import { + applyAdvisories, + emptyUpdateManifest, + mergeRelease, + parseAdvisoriesInput, + readPublishedManifest, + requiresCoreFrom, + serializeUpdateManifest, +} from "./merge"; + +const RELEASE = { + publishedAt: "2026-10-15T10:00:00Z", + url: "https://github.com/acme/vitnode/releases/tag/v2.1.0", +}; + +const ADVISORY = { + affected: "<2.1.0", + fixed: "2.1.0", + id: "VN-2026-001", + severity: "high" as const, + url: "https://github.com/acme/vitnode/security/advisories/1", +}; + +const stable = ( + manifest: UpdateManifest, + packages: { id: string; requiresCore?: string; version: string }[], + githubRelease: null | typeof RELEASE = RELEASE, +) => mergeRelease(manifest, { channel: "stable", githubRelease, packages }); + +describe("mergeRelease", () => { + it("adds the published packages, with the GitHub Release metadata", () => { + expect( + stable(emptyUpdateManifest(), [ + { id: "@vitnode/core", version: "2.1.0" }, + { id: "@vitnode/blog", requiresCore: "^2.1.0", version: "2.1.0" }, + ]).packages, + ).toEqual({ + "@vitnode/blog": { + advisories: [], + channels: { stable: "2.1.0" }, + releases: { "2.1.0": { ...RELEASE, requiresCore: "^2.1.0" } }, + }, + "@vitnode/core": { + advisories: [], + channels: { stable: "2.1.0" }, + releases: { "2.1.0": RELEASE }, + }, + }); + }); + + it("leaves publishedAt and url out without a GitHub Release", () => { + expect( + stable( + emptyUpdateManifest(), + [{ id: "@vitnode/core", version: "2.1.0" }], + null, + ).packages["@vitnode/core"]?.releases, + ).toEqual({ "2.1.0": {} }); + }); + + it("writes the same bytes when a run is repeated", () => { + const once = stable(emptyUpdateManifest(), [ + { id: "@vitnode/core", version: "2.1.0" }, + ]); + + expect( + serializeUpdateManifest( + stable(once, [{ id: "@vitnode/core", version: "2.1.0" }], { + publishedAt: "2026-10-16T08:00:00Z", + url: "https://github.com/acme/vitnode/releases/tag/rerun", + }), + ), + ).toBe(serializeUpdateManifest(once)); + }); + + it("fills in what a rerun after the GitHub Release learned", () => { + const withoutRelease = stable( + emptyUpdateManifest(), + [{ id: "@vitnode/core", version: "2.1.0" }], + null, + ); + + expect( + stable(withoutRelease, [{ id: "@vitnode/core", version: "2.1.0" }]) + .packages["@vitnode/core"]?.releases["2.1.0"], + ).toEqual(RELEASE); + }); + + it("never moves a channel backwards when an older release is rerun", () => { + const newer = stable(emptyUpdateManifest(), [ + { id: "@vitnode/core", version: "2.1.0" }, + ]); + + const merged = stable(newer, [{ id: "@vitnode/core", version: "2.0.5" }]); + + expect(merged.packages["@vitnode/core"]).toMatchObject({ + channels: { stable: "2.1.0" }, + releases: { "2.0.5": {}, "2.1.0": {} }, + }); + }); + + it("leaves other packages, channels and advisories untouched", () => { + const before: UpdateManifest = { + packages: { + "@acme/other": { + advisories: [], + channels: { stable: "1.0.0" }, + releases: { "1.0.0": {} }, + }, + "@vitnode/core": { + advisories: [ADVISORY], + channels: { canary: "2.1.0-canary.1", stable: "2.0.0" }, + releases: { "2.0.0": {}, "2.1.0-canary.1": {} }, + }, + }, + schemaVersion: 1, + }; + + const merged = mergeRelease(before, { + channel: "canary", + githubRelease: null, + packages: [{ id: "@vitnode/core", version: "2.1.0-canary.2" }], + }); + + expect(merged.packages["@acme/other"]).toEqual( + before.packages["@acme/other"], + ); + expect(merged.packages["@vitnode/core"]).toMatchObject({ + advisories: [ADVISORY], + channels: { canary: "2.1.0-canary.2", stable: "2.0.0" }, + }); + }); + + it.each([ + [ + "a prerelease on the stable channel", + "stable", + "2.1.0-rc.1", + /does not belong on the stable channel/, + ], + [ + "a stable version on the canary channel", + "canary", + "2.1.0", + /does not belong on the canary channel/, + ], + ["a version that is not SemVer", "stable", "v2.1", /not SemVer/], + ] as const)("rejects %s", (_, channel, version, error) => { + expect(() => + mergeRelease(emptyUpdateManifest(), { + channel, + githubRelease: null, + packages: [{ id: "@vitnode/core", version }], + }), + ).toThrow(error); + }); +}); + +describe("readPublishedManifest", () => { + it.each([null, "", " \n"])("starts a new manifest from %j", text => { + expect(readPublishedManifest(text)).toEqual(emptyUpdateManifest()); + }); + + it.each([ + ["text that is not JSON", "{", /not JSON/], + ["an invalid manifest", '{"schemaVersion":1,"packages":[]}', /invalid/], + ])("refuses to overwrite %s", (_, text, error) => { + expect(() => readPublishedManifest(text)).toThrow(error); + }); +}); + +describe("applyAdvisories", () => { + it("gives each package exactly the advisories the file lists, and keeps releases", () => { + const published = stable(emptyUpdateManifest(), [ + { id: "@vitnode/blog", version: "2.1.0" }, + { id: "@vitnode/core", version: "2.1.0" }, + ]); + const withOld = applyAdvisories(published, { + advisories: { "@vitnode/blog": [ADVISORY] }, + schemaVersion: 1, + }); + + const next = applyAdvisories(withOld, { + advisories: { "@vitnode/core": [ADVISORY] }, + schemaVersion: 1, + }); + + expect(next.packages["@vitnode/blog"]).toMatchObject({ + advisories: [], + channels: { stable: "2.1.0" }, + }); + expect(next.packages["@vitnode/core"]?.advisories).toEqual([ADVISORY]); + }); +}); + +describe("parseAdvisoriesInput", () => { + it("reads the maintained advisories file", () => { + expect( + parseAdvisoriesInput( + JSON.stringify({ + advisories: { "@vitnode/core": [ADVISORY] }, + schemaVersion: 1, + }), + ), + ).toEqual({ + advisories: { "@vitnode/core": [ADVISORY] }, + schemaVersion: 1, + }); + }); + + it.each([ + ["text that is not JSON", "[", /not JSON/], + [ + "an advisory linking over http", + JSON.stringify({ + advisories: { "@vitnode/core": [{ ...ADVISORY, url: "http://x.io" }] }, + schemaVersion: 1, + }), + /invalid: advisories\.@vitnode\/core\.0\.url/, + ], + ])("rejects %s", (_, text, error) => { + expect(() => parseAdvisoriesInput(text)).toThrow(error); + }); +}); + +describe("requiresCoreFrom", () => { + it.each([ + [undefined, undefined], + ["workspace:*", "2.1.0"], + ["workspace:", "2.1.0"], + ["workspace:^", "^2.1.0"], + ["workspace:~", "~2.1.0"], + ["workspace:^2.0.0", "^2.0.0"], + [">=2.0.0", ">=2.0.0"], + ])("turns %s into %s", (spec, expected) => { + expect(requiresCoreFrom(spec, "2.1.0")).toBe(expected); + }); +}); + +describe("serializeUpdateManifest", () => { + it("writes the same bytes whatever order the keys were added in", () => { + const a = stable(emptyUpdateManifest(), [ + { id: "@vitnode/blog", version: "2.1.0" }, + { id: "@vitnode/core", version: "2.1.0" }, + ]); + const b = stable(emptyUpdateManifest(), [ + { id: "@vitnode/core", version: "2.1.0" }, + { id: "@vitnode/blog", version: "2.1.0" }, + ]); + + expect(serializeUpdateManifest(a)).toBe(serializeUpdateManifest(b)); + expect(readPublishedManifest(serializeUpdateManifest(a))).toEqual(a); + }); +}); diff --git a/packages/vitnode/scripts/write-plugin-manifest.test.ts b/packages/vitnode/scripts/write-plugin-manifest.test.ts index 82ae3d46f..4ffba7451 100644 --- a/packages/vitnode/scripts/write-plugin-manifest.test.ts +++ b/packages/vitnode/scripts/write-plugin-manifest.test.ts @@ -99,10 +99,24 @@ describe("writePluginManifest", () => { id: "@acme/blog", license: null, title, + updates: null, version: "1.0.0", }); }); + it("writes the update source declared in package.json", async () => { + const root = pluginAt({ + ...BLOG, + vitnode: { updates: { url: "https://example.com/updates.json" } }, + }); + + writePluginManifest(root); + + expect(await generated(root)).toMatchObject({ + updates: { url: "https://example.com/updates.json" }, + }); + }); + it.each([ [ "a new dependency", @@ -125,6 +139,16 @@ describe("writePluginManifest", () => { }, true, ], + [ + "a new update source", + (root: string) => { + writeManifest(root, { + ...BLOG, + vitnode: { updates: { url: "https://example.com/updates.json" } }, + }); + }, + true, + ], [ "src/plugin-info.ts disappearing", (root: string) => { diff --git a/packages/vitnode/src/api/lib/updates/check.test.ts b/packages/vitnode/src/api/lib/updates/check.test.ts new file mode 100644 index 000000000..9b8539252 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/check.test.ts @@ -0,0 +1,374 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { describe, expect, it, vi } from "vitest"; + +import type { UpdateManifest } from "@/lib/updates/manifest"; + +import { core_update_results, core_update_sources } from "@/database/updates"; +import { createMemoryDb } from "@/tests/memory-db"; + +import type { FetchUpdateSource } from "./check"; +import type { UpdateComponent } from "./components"; + +import { checkForUpdates, runLockedUpdateCheck } from "./check"; +import { UpdateSourceError } from "./fetch-source"; +import { acquireUpdateCheckLease, readUpdateCheckLock } from "./lock"; + +type Db = ReturnType; + +const ACME = "https://acme.example.com/updates.json"; +const CORE = "https://vitnode.example.com/updates.json"; + +const T1 = new Date("2026-10-14T03:00:00Z"); +const T2 = new Date("2026-10-15T03:00:00Z"); + +const component = ( + id: string, + sourceUrl: null | string, + versions: string[] = ["1.0.0"], +): UpdateComponent => ({ + hasUnknownVersion: false, + id, + kind: id === "@vitnode/core" ? "core" : "plugin", + sourceUrl, + versions, +}); + +const ADVISORY = { + affected: "<1.1.0", + fixed: "1.1.0", + id: "ACME-1", + severity: "high" as const, + url: "https://acme.example.com/advisories/1", +}; + +const acmeManifest = ( + ids: string[] = ["@acme/blog", "@acme/forum"], +): UpdateManifest => ({ + packages: Object.fromEntries( + ids.map(id => [ + id, + { + advisories: [ADVISORY], + channels: { stable: "1.1.0" }, + releases: { "1.1.0": {} }, + }, + ]), + ), + schemaVersion: 1, +}); + +const coreManifest: UpdateManifest = { + packages: { + "@vitnode/core": { + advisories: [], + channels: { stable: "2.0.0" }, + releases: { "2.0.0": {} }, + }, + }, + schemaVersion: 1, +}; + +const fetched = (body: unknown, etag: null | string = null) => ({ + body, + etag, + kind: "fetched" as const, + lastModified: null, +}); + +/** Answers per URL; a function answer can look at what was asked. */ +const sources = ( + answers: Record< + string, + | (( + options: Parameters[1], + ) => Awaited>) + | Awaited> + | Error + >, +) => + vi.fn(async (url, options) => { + const answer = answers[url]; + if (answer === undefined) throw new Error(`unexpected fetch of ${url}`); + if (answer instanceof Error) throw answer; + + return await Promise.resolve( + typeof answer === "function" ? answer(options) : answer, + ); + }); + +const setup = () => { + const memory = createMemoryDb(); + const db = memory.db as unknown as Db; + const check = async ( + components: UpdateComponent[], + fetchSource: FetchUpdateSource, + now = T1, + ) => + await checkForUpdates({ + channelOverride: null, + components, + coreVersion: "2.0.0", + db, + fetchSource, + now, + }); + const result = (id: string) => + memory.rows(core_update_results).find(row => row.componentId === id); + const source = (url: string) => + memory.rows(core_update_sources).find(row => row.url === url); + + return { check, db, memory, result, source }; +}; + +const BLOG = component("@acme/blog", ACME); +const FORUM = component("@acme/forum", ACME, ["1.1.0"]); + +describe("checkForUpdates", () => { + it("fetches a source shared by several plugins once, and evaluates each", async () => { + const { check, result } = setup(); + const fetchSource = sources({ [ACME]: fetched(acmeManifest()) }); + + const summary = await check([BLOG, FORUM], fetchSource); + + expect(fetchSource).toHaveBeenCalledTimes(1); + expect(summary).toEqual({ + advisories: 1, + components: 2, + failedSources: 0, + sources: 1, + updates: 1, + }); + expect(result("@acme/blog")).toMatchObject({ + lastError: null, + lastSuccessAt: T1, + result: { advisories: [{ id: "ACME-1" }], status: "update_available" }, + sourceUrl: ACME, + versions: ["1.0.0"], + }); + expect(result("@acme/forum")).toMatchObject({ + result: { advisories: [], status: "up_to_date" }, + }); + }); + + it("stores only the packages this install checks", async () => { + const { check, source } = setup(); + + await check([BLOG], sources({ [ACME]: fetched(acmeManifest(), '"v1"') })); + + expect(source(ACME)).toMatchObject({ etag: '"v1"', lastError: null }); + expect(source(ACME)?.document).toEqual({ + packages: { "@acme/blog": expect.any(Object) as unknown }, + schemaVersion: 1, + }); + }); + + it("skips a component without an update source", async () => { + const { check, result } = setup(); + const fetchSource = sources({}); + + await expect( + check([component("@acme/local", null)], fetchSource), + ).resolves.toMatchObject({ components: 0, sources: 0 }); + expect(fetchSource).not.toHaveBeenCalled(); + expect(result("@acme/local")).toBeUndefined(); + }); + + it("keeps one failing source from affecting the others", async () => { + const { check, result } = setup(); + const core = component("@vitnode/core", CORE, ["1.9.0"]); + + const summary = await check( + [core, BLOG], + sources({ + [ACME]: new UpdateSourceError("timeout"), + [CORE]: fetched(coreManifest), + }), + ); + + expect(summary).toMatchObject({ failedSources: 1, sources: 2, updates: 1 }); + expect(result("@vitnode/core")).toMatchObject({ + lastError: null, + result: { status: "update_available" }, + }); + expect(result("@acme/blog")).toMatchObject({ + lastError: { code: "timeout", detail: null }, + result: null, + }); + }); + + it.each([ + [ + "an unreachable source", + new UpdateSourceError("http_error", "503"), + { code: "http_error", detail: "503" }, + ], + [ + "a crash in the transport", + new Error("socket hang up"), + { code: "network", detail: null }, + ], + [ + "an invalid manifest", + fetched({ packages: [], schemaVersion: 1 }), + { code: "invalid_manifest" }, + ], + [ + "a manifest from a newer VitNode", + fetched({ schemaVersion: 2 }), + { code: "unsupported_schema" }, + ], + ])("keeps the previous findings through %s", async (_, answer, error) => { + const { check, result, source } = setup(); + await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) }), T1); + + await check([BLOG], sources({ [ACME]: answer }), T2); + + expect(result("@acme/blog")).toMatchObject({ + lastAttemptAt: T2, + lastError: error, + lastSuccessAt: T1, + result: { advisories: [{ id: "ACME-1" }], status: "update_available" }, + }); + expect(source(ACME)).toMatchObject({ + document: { packages: { "@acme/blog": {} } }, + lastAttemptAt: T2, + lastError: error, + lastSuccessAt: T1, + }); + }); + + it("answers a 304 from the stored copy, evaluated for what runs now", async () => { + const { check, result } = setup(); + await check( + [BLOG], + sources({ [ACME]: fetched(acmeManifest(), '"v1"') }), + T1, + ); + const fetchSource = sources({ [ACME]: { kind: "not_modified" } }); + + await check([component("@acme/blog", ACME, ["1.1.0"])], fetchSource, T2); + + expect(fetchSource).toHaveBeenCalledWith(ACME, { + validators: { etag: '"v1"', lastModified: null }, + }); + expect(result("@acme/blog")).toMatchObject({ + lastError: null, + lastSuccessAt: T2, + result: { advisories: [], status: "up_to_date" }, + versions: ["1.1.0"], + }); + }); + + it("asks for the full document when the stored copy does not cover a new plugin", async () => { + const { check, result } = setup(); + await check( + [BLOG], + sources({ [ACME]: fetched(acmeManifest(), '"v1"') }), + T1, + ); + const fetchSource = sources({ [ACME]: { kind: "not_modified" } }); + + await check([BLOG, FORUM], fetchSource, T2); + + expect(fetchSource).toHaveBeenCalledWith(ACME, { validators: null }); + expect(result("@acme/forum")).toMatchObject({ + lastError: { code: "http_error" }, + result: null, + }); + }); + + it("forgets removed plugins and sources nothing points to", async () => { + const { check, memory } = setup(); + const core = component("@vitnode/core", CORE, ["2.0.0"]); + await check( + [core, BLOG], + sources({ + [ACME]: fetched(acmeManifest()), + [CORE]: fetched(coreManifest), + }), + T1, + ); + + await check([core], sources({ [CORE]: fetched(coreManifest) }), T2); + + expect( + memory.rows(core_update_results).map(row => row.componentId), + ).toEqual(["@vitnode/core"]); + expect(memory.rows(core_update_sources).map(row => row.url)).toEqual([ + CORE, + ]); + }); + + it("forgets everything when nothing has an update source any more", async () => { + const { check, memory } = setup(); + await check([BLOG], sources({ [ACME]: fetched(acmeManifest()) }), T1); + + await check([component("@acme/blog", null)], sources({}), T2); + + expect(memory.rows(core_update_results)).toEqual([]); + expect(memory.rows(core_update_sources)).toEqual([]); + }); +}); + +describe("runLockedUpdateCheck", () => { + const options = (db: Db, fetchSource: FetchUpdateSource) => ({ + channelOverride: null, + components: [BLOG], + coreVersion: "2.0.0", + db, + fetchSource, + now: T1, + }); + + it("does not check while another instance holds the lease", async () => { + const { db } = setup(); + await acquireUpdateCheckLease(db, { + manual: false, + now: T1, + owner: "other", + }); + const fetchSource = sources({}); + + await expect( + runLockedUpdateCheck({ ...options(db, fetchSource), manual: true }), + ).resolves.toMatchObject({ reason: "running", status: "busy" }); + expect(fetchSource).not.toHaveBeenCalled(); + }); + + it("gives the lease back after the check, even when it throws", async () => { + const { db } = setup(); + const failingDb = new Proxy(db, { + get: (target, property, receiver) => + property === "delete" + ? () => { + throw new Error("database went away"); + } + : (Reflect.get(target, property, receiver) as unknown), + }); + + await expect( + runLockedUpdateCheck({ + ...options(failingDb, sources({ [ACME]: fetched(acmeManifest()) })), + manual: false, + }), + ).rejects.toThrow("database went away"); + await expect(readUpdateCheckLock(db, T1)).resolves.toMatchObject({ + running: false, + }); + }); + + it("returns the summary of a completed check", async () => { + const { db } = setup(); + + await expect( + runLockedUpdateCheck({ + ...options(db, sources({ [ACME]: fetched(acmeManifest()) })), + manual: false, + }), + ).resolves.toMatchObject({ + status: "completed", + summary: { sources: 1, updates: 1 }, + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/check.ts b/packages/vitnode/src/api/lib/updates/check.ts index 257bd14da..a9b7b30f0 100644 --- a/packages/vitnode/src/api/lib/updates/check.ts +++ b/packages/vitnode/src/api/lib/updates/check.ts @@ -155,7 +155,7 @@ export const checkForUpdates = async ({ try { const response = await fetchSource(url, { validators: - reusable === null + reusable === null || (!previous?.etag && !previous?.lastModified) ? null : { etag: previous?.etag ?? null, diff --git a/packages/vitnode/src/api/lib/updates/components.test.ts b/packages/vitnode/src/api/lib/updates/components.test.ts new file mode 100644 index 000000000..0ba155900 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/components.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginManifest } from "@/lib/plugin-manifest"; + +import { buildInstalledPluginRegistry } from "../installed-plugins"; +import { collectUpdateComponents } from "./components"; + +const manifest = ( + id: string, + overrides: Partial = {}, +): PluginManifest => ({ + author: null, + description: null, + icon: null, + id, + license: null, + title: id, + version: "1.0.0", + ...overrides, +}); + +const CORE = { + id: "@vitnode/core", + updates: { url: "https://example.com/core.json" }, + version: "2.0.0", +}; + +const collect = ( + api: { manifest?: PluginManifest; pluginId: string }[], + frontend: { id: string; manifest: null | PluginManifest }[], +) => + collectUpdateComponents({ + api, + core: CORE, + frontend, + registry: buildInstalledPluginRegistry({ api, frontend }), + }); + +describe("collectUpdateComponents", () => { + it("lists core first, then each plugin with the versions both sides run", () => { + const blog = (version: string) => + manifest("@acme/blog", { + updates: { url: "https://example.com/acme.json" }, + version, + }); + + expect( + collect( + [{ manifest: blog("1.2.0"), pluginId: "@acme/blog" }], + [{ id: "@acme/blog", manifest: blog("1.1.0") }], + ), + ).toEqual([ + { + hasUnknownVersion: false, + id: "@vitnode/core", + kind: "core", + sourceUrl: "https://example.com/core.json", + versions: ["2.0.0"], + }, + { + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: "https://example.com/acme.json", + versions: ["1.2.0", "1.1.0"], + }, + ]); + }); + + it("takes the update source from the API manifest over the web app's", () => { + const [, plugin] = collect( + [ + { + manifest: manifest("@acme/blog", { + updates: { url: "https://api.example.com/updates.json" }, + }), + pluginId: "@acme/blog", + }, + ], + [ + { + id: "@acme/blog", + manifest: manifest("@acme/blog", { + updates: { url: "https://web.example.com/updates.json" }, + }), + }, + ], + ); + + expect(plugin?.sourceUrl).toBe("https://api.example.com/updates.json"); + }); + + it("falls back to the web app's update source", () => { + const [, plugin] = collect( + [{ manifest: manifest("@acme/blog"), pluginId: "@acme/blog" }], + [ + { + id: "@acme/blog", + manifest: manifest("@acme/blog", { + updates: { url: "https://web.example.com/updates.json" }, + }), + }, + ], + ); + + expect(plugin?.sourceUrl).toBe("https://web.example.com/updates.json"); + }); + + it("leaves a plugin without an update source uncheckable, and flags a side with no version", () => { + const [, plugin] = collect( + [{ pluginId: "@acme/blog" }], + [{ id: "@acme/blog", manifest: manifest("@acme/blog") }], + ); + + expect(plugin).toMatchObject({ + hasUnknownVersion: true, + sourceUrl: null, + versions: ["1.0.0"], + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/evaluate.test.ts b/packages/vitnode/src/api/lib/updates/evaluate.test.ts new file mode 100644 index 000000000..dc40cb28c --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/evaluate.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; + +import type { UpdateChannel, UpdatePackage } from "@/lib/updates/manifest"; + +import { evaluateUpdate, normalizeInstalledVersions } from "./evaluate"; + +const ENTRY: UpdatePackage = { + advisories: [], + channels: { canary: "2.2.0-canary.3", rc: "2.2.0-rc.1", stable: "2.1.0" }, + releases: { + "2.0.1": { requiresCore: "^1.0.0" }, + "2.0.2": { requiresCore: "^1.0.0 || ^2.0.0" }, + "2.0.3": {}, + "2.1.0": { + publishedAt: "2026-10-01T10:00:00Z", + requiresCore: "^3.0.0", + url: "https://example.com/releases/2.1.0", + }, + "2.2.0-canary.3": {}, + "2.2.0-rc.1": {}, + }, +}; + +const ADVISORY = { + affected: "<2.0.3", + fixed: "2.0.3", + id: "VN-2026-001", + severity: "critical" as const, + url: "https://example.com/advisories/VN-2026-001", +}; + +const evaluate = ( + reported: (null | string)[], + options: { + channelOverride?: null | UpdateChannel; + coreVersion?: null | string; + entry?: undefined | UpdatePackage; + isCore?: boolean; + } = {}, +) => + evaluateUpdate({ + channelOverride: options.channelOverride ?? null, + coreVersion: + "coreVersion" in options ? (options.coreVersion ?? null) : "2.5.0", + entry: "entry" in options ? options.entry : ENTRY, + installed: normalizeInstalledVersions(reported), + isCore: options.isCore ?? false, + }); + +describe("normalizeInstalledVersions", () => { + it("keeps distinct valid versions, newest first", () => { + expect( + normalizeInstalledVersions(["1.0.0", "v1.2.0", "1.0.0", "1.10.0"]), + ).toEqual({ + hasUnknownVersion: false, + versions: ["1.10.0", "1.2.0", "1.0.0"], + }); + }); + + it.each([ + ["no registration", []], + ["a registration without a version", ["1.0.0", null]], + ["a version that is not SemVer", ["1.0.0", "latest"]], + ])("flags %s as an unknown version", (_, reported) => { + expect(normalizeInstalledVersions(reported).hasUnknownVersion).toBe(true); + }); +}); + +describe("evaluateUpdate", () => { + it.each([ + ["stable", "2.0.0", "2.1.0"], + ["rc", "2.2.0-rc.0", "2.2.0-rc.1"], + ["canary", "2.2.0-canary.1", "2.2.0-canary.3"], + ])( + "follows the %s channel of the installed version", + (channel, installed, target) => { + expect(evaluate([installed])).toMatchObject({ + channel, + latest: { version: target }, + status: "update_available", + }); + }, + ); + + it("follows the configured channel instead of the installed version's", () => { + expect(evaluate(["2.0.0"], { channelOverride: "canary" })).toMatchObject({ + channel: "canary", + latest: { version: "2.2.0-canary.3" }, + status: "update_available", + }); + }); + + it("never offers a downgrade when the installed version is newer than the target", () => { + expect( + evaluate(["2.2.0-canary.9"], { channelOverride: "stable" }), + ).toMatchObject({ + latest: { version: "2.1.0" }, + status: "up_to_date", + }); + }); + + it("is up to date on the channel target, with its release details", () => { + expect(evaluate(["2.1.0"])).toEqual({ + advisories: [], + alternative: null, + channel: "stable", + compatibility: null, + latest: { + publishedAt: "2026-10-01T10:00:00Z", + requiresCore: "^3.0.0", + url: "https://example.com/releases/2.1.0", + version: "2.1.0", + }, + status: "up_to_date", + }); + }); + + it.each([ + [ + "the package is not in the manifest", + ["2.0.0"], + undefined, + "package_missing", + ], + [ + "the channel publishes nothing", + ["2.2.0-rc.0"], + { ...ENTRY, channels: { stable: "2.1.0" } }, + "channel_missing", + ], + [ + "the version is on no known channel", + ["2.2.0-beta.1"], + ENTRY, + "unknown_channel", + ], + ["no version is known", [null], ENTRY, "version_unknown"], + ["one side reports no version", ["2.1.0", null], ENTRY, "version_unknown"], + ])("when %s, says so", (_, reported, entry, status) => { + expect(evaluate(reported, { entry }).status).toBe(status); + }); + + it("reports an update when either side runs an older version", () => { + expect(evaluate(["2.1.0", "2.0.0"]).status).toBe("update_available"); + }); + + describe("advisories", () => { + const withAdvisories = ( + advisories: UpdatePackage["advisories"], + ): UpdatePackage => ({ ...ENTRY, advisories }); + + it("reports a matching advisory with the affected versions", () => { + expect( + evaluate(["2.0.1"], { entry: withAdvisories([ADVISORY]) }).advisories, + ).toEqual([ + { + affected: "<2.0.3", + fixed: "2.0.3", + id: "VN-2026-001", + severity: "critical", + summary: null, + url: "https://example.com/advisories/VN-2026-001", + versions: ["2.0.1"], + }, + ]); + }); + + it("reports a vulnerable web app even when the API runs an unaffected version", () => { + const result = evaluate(["2.1.0", "2.0.1"], { + entry: withAdvisories([ADVISORY]), + }); + + expect(result.advisories).toMatchObject([{ versions: ["2.0.1"] }]); + }); + + it("reports a vulnerable API even when the web app runs an unaffected version", () => { + const result = evaluate(["2.0.1", "2.0.3"], { + entry: withAdvisories([ADVISORY]), + }); + + expect(result.advisories).toMatchObject([{ versions: ["2.0.1"] }]); + }); + + it("matches prereleases inside the affected range", () => { + expect( + evaluate(["2.0.3-canary.1"], { entry: withAdvisories([ADVISORY]) }) + .advisories, + ).toHaveLength(1); + }); + + it("reports an advisory with no fix yet", () => { + const { fixed: _, ...unfixed } = ADVISORY; + + expect( + evaluate(["2.1.0"], { + entry: withAdvisories([{ ...unfixed, affected: "<=2.1.0" }]), + }), + ).toMatchObject({ + advisories: [{ fixed: null, versions: ["2.1.0"] }], + status: "up_to_date", + }); + }); + + it("leaves out an advisory no installed version is affected by", () => { + expect( + evaluate(["2.0.3"], { entry: withAdvisories([ADVISORY]) }).advisories, + ).toEqual([]); + }); + + it("reports advisories whatever the channel says", () => { + const entry: UpdatePackage = { + advisories: [{ ...ADVISORY, affected: "<3.0.0" }], + channels: {}, + releases: {}, + }; + + expect(evaluate(["2.2.0-beta.1"], { entry })).toMatchObject({ + advisories: [{ id: "VN-2026-001" }], + status: "unknown_channel", + }); + }); + }); + + describe("compatibility with the running core", () => { + const stableAt = (target: string, release: object): UpdatePackage => ({ + advisories: [], + channels: { stable: target }, + releases: { [target]: release }, + }); + + it.each([ + ["compatible", "^2.0.0", "2.5.0"], + ["incompatible", "^3.0.0", "2.5.0"], + ["unknown", undefined, "2.5.0"], + ["unknown", "^2.0.0", null], + ])( + "is %s for requiresCore %s on core %s", + (compatibility, requiresCore, coreVersion) => { + expect( + evaluate(["1.0.0"], { + coreVersion, + entry: stableAt("1.1.0", requiresCore ? { requiresCore } : {}), + }), + ).toMatchObject({ alternative: null, compatibility }); + }, + ); + + it("offers the newest older release that declares it works with this core", () => { + expect(evaluate(["2.0.0"])).toMatchObject({ + alternative: { requiresCore: "^1.0.0 || ^2.0.0", version: "2.0.2" }, + compatibility: "incompatible", + latest: { version: "2.1.0" }, + }); + }); + + it("offers no alternative when no newer release fits this core", () => { + expect(evaluate(["2.0.2"])).toMatchObject({ + alternative: null, + compatibility: "incompatible", + }); + }); + + it("does not judge core's own updates against itself", () => { + expect(evaluate(["2.0.0"], { isCore: true })).toMatchObject({ + alternative: null, + compatibility: null, + status: "update_available", + }); + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/evaluate.ts b/packages/vitnode/src/api/lib/updates/evaluate.ts index bebc259de..b1a0a0096 100644 --- a/packages/vitnode/src/api/lib/updates/evaluate.ts +++ b/packages/vitnode/src/api/lib/updates/evaluate.ts @@ -10,9 +10,9 @@ import type { import { channelOfVersion, versionInRange } from "@/lib/updates/manifest"; export type UpdateStatus = - /** The publisher has no entry for this package. */ - | "channel_missing" /** The package exists, but publishes nothing on the selected channel. */ + | "channel_missing" + /** The publisher has no entry for this package. */ | "package_missing" /** The installed version is on no known channel, and no override names one. */ | "unknown_channel" diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.test.ts b/packages/vitnode/src/api/lib/updates/fetch-source.test.ts new file mode 100644 index 000000000..d7d575827 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/fetch-source.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + FetchUpdateSourceOptions, + UpdateSourceTransport, + UpdateSourceTransportResponse, +} from "./fetch-source"; + +import { fetchUpdateSource } from "./fetch-source"; + +const MANIFEST = { packages: {}, schemaVersion: 1 }; + +const reply = ( + status: number, + { + body = new Uint8Array(), + contentLength = null, + destroy = () => undefined, + etag = null, + lastModified = null, + location = null, + }: Partial> & { + body?: Uint8Array; + } = {}, +): UpdateSourceTransportResponse => ({ + body: (async function* chunks() { + await Promise.resolve(); + yield body; + })(), + contentLength, + destroy, + etag, + lastModified, + location, + status, +}); + +const json = (value: unknown, extra: Parameters[1] = {}) => + reply(200, { + body: new TextEncoder().encode(JSON.stringify(value)), + ...extra, + }); + +const HOSTS: Record = { + "cdn.example.com": ["93.184.215.14"], + "mixed.example.com": ["93.184.215.14", "10.0.0.5"], + "private.example.com": ["192.168.1.10"], + "updates.example.com": ["93.184.215.14"], +}; + +const resolveHost = async (hostname: string) => + await Promise.resolve(HOSTS[hostname] ?? []); + +/** A transport that answers each request in turn, and records what was asked. */ +const scripted = (...responses: UpdateSourceTransportResponse[]) => { + const requests: { headers: Record; url: string }[] = []; + const transport: UpdateSourceTransport = async (url, { headers }) => { + requests.push({ headers, url: url.href }); + const next = responses.shift(); + if (!next) throw new Error("no more responses"); + + return await Promise.resolve(next); + }; + + return { requests, transport }; +}; + +const fetchWith = async ( + url: string, + transport: UpdateSourceTransport, + options: Omit = {}, +) => await fetchUpdateSource(url, { resolveHost, transport, ...options }); + +const URL_OK = "https://updates.example.com/updates.json"; + +describe("fetchUpdateSource", () => { + it("returns the JSON body and its validators, sending no cookie or authorization", async () => { + const { requests, transport } = scripted( + json(MANIFEST, { + etag: '"v1"', + lastModified: "Wed, 14 Oct 2026 10:00:00 GMT", + }), + ); + + await expect(fetchWith(URL_OK, transport)).resolves.toEqual({ + body: MANIFEST, + etag: '"v1"', + kind: "fetched", + lastModified: "Wed, 14 Oct 2026 10:00:00 GMT", + }); + expect(requests).toEqual([ + { + headers: { + accept: "application/json", + "user-agent": "VitNode update check", + }, + url: URL_OK, + }, + ]); + }); + + it("asks conditionally when validators are given, and reads a 304 as not modified", async () => { + const { requests, transport } = scripted(reply(304)); + + await expect( + fetchWith(URL_OK, transport, { + validators: { + etag: '"v1"', + lastModified: "Wed, 14 Oct 2026 10:00:00 GMT", + }, + }), + ).resolves.toEqual({ kind: "not_modified" }); + expect(requests[0]?.headers).toMatchObject({ + "if-modified-since": "Wed, 14 Oct 2026 10:00:00 GMT", + "if-none-match": '"v1"', + }); + }); + + it("refuses a 304 it did not ask for", async () => { + const { transport } = scripted(reply(304)); + + await expect(fetchWith(URL_OK, transport)).rejects.toMatchObject({ + reason: "http_error", + }); + }); + + it.each([ + ["http", "http://updates.example.com/updates.json", "invalid_url"], + [ + "a port other than 443", + "https://updates.example.com:8443/u.json", + "invalid_url", + ], + [ + "credentials", + "https://acme:secret@updates.example.com/u.json", + "invalid_url", + ], + ["not a URL", "updates.json", "invalid_url"], + ["loopback", "https://127.0.0.1/updates.json", "blocked_address"], + ["IPv6 loopback", "https://[::1]/updates.json", "blocked_address"], + ["a private network", "https://10.1.2.3/updates.json", "blocked_address"], + [ + "the cloud metadata endpoint", + "https://169.254.169.254/latest", + "blocked_address", + ], + ["a single-label host", "https://intranet/updates.json", "blocked_address"], + [ + "a .localhost host", + "https://app.localhost/updates.json", + "blocked_address", + ], + [ + "a host resolving to a private address", + "https://private.example.com/u.json", + "blocked_address", + ], + [ + "a host resolving to a private address among public ones", + "https://mixed.example.com/u.json", + "blocked_address", + ], + [ + "a host that does not resolve", + "https://nowhere.example.com/u.json", + "blocked_address", + ], + ])("refuses %s without connecting", async (_, url, reason) => { + const transport = vi.fn(); + + await expect(fetchWith(url, transport)).rejects.toMatchObject({ reason }); + expect(transport).not.toHaveBeenCalled(); + }); + + it("follows a relative redirect on the same host", async () => { + const { requests, transport } = scripted( + reply(302, { location: "/v2/updates.json" }), + json(MANIFEST), + ); + + await expect(fetchWith(URL_OK, transport)).resolves.toMatchObject({ + body: MANIFEST, + }); + expect(requests.map(request => request.url)).toEqual([ + URL_OK, + "https://updates.example.com/v2/updates.json", + ]); + }); + + it.each([ + ["a private host", "https://private.example.com/u.json", "blocked_address"], + [ + "the cloud metadata endpoint", + "https://169.254.169.254/latest", + "blocked_address", + ], + ["http", "http://cdn.example.com/u.json", "invalid_url"], + ])("refuses a redirect to %s", async (_, location, reason) => { + const { requests, transport } = scripted(reply(301, { location })); + + await expect(fetchWith(URL_OK, transport)).rejects.toMatchObject({ + reason, + }); + expect(requests).toHaveLength(1); + }); + + it("stops after the redirect limit", async () => { + const { requests, transport } = scripted( + reply(302, { location: "/1" }), + reply(302, { location: "/2" }), + reply(302, { location: "/3" }), + ); + + await expect( + fetchWith(URL_OK, transport, { maxRedirects: 2 }), + ).rejects.toMatchObject({ reason: "too_many_redirects" }); + expect(requests).toHaveLength(3); + }); + + it.each([ + ["a redirect without a location", reply(302), "302"], + ["a server error", reply(503), "503"], + ["a missing manifest", reply(404), "404"], + ])("reports %s as an HTTP error", async (_, response, detail) => { + const { transport } = scripted(response); + + await expect(fetchWith(URL_OK, transport)).rejects.toMatchObject({ + detail, + reason: "http_error", + }); + }); + + it("refuses a declared size over the limit before reading the body", async () => { + const destroy = vi.fn(); + const { transport } = scripted(reply(200, { contentLength: 11, destroy })); + + await expect( + fetchWith(URL_OK, transport, { maxBytes: 10 }), + ).rejects.toMatchObject({ reason: "too_large" }); + expect(destroy).toHaveBeenCalled(); + }); + + it("stops reading a body that grows past the limit without declaring its size", async () => { + const destroy = vi.fn(); + const { transport } = scripted( + reply(200, { body: new Uint8Array(11), destroy }), + ); + + await expect( + fetchWith(URL_OK, transport, { maxBytes: 10 }), + ).rejects.toMatchObject({ reason: "too_large" }); + expect(destroy).toHaveBeenCalled(); + }); + + it.each([ + ["text that is not JSON", new TextEncoder().encode("")], + ["bytes that are not UTF-8", new Uint8Array([0x7b, 0xff, 0x7d])], + ])("reports %s as invalid JSON", async (_, body) => { + const { transport } = scripted(reply(200, { body })); + + await expect(fetchWith(URL_OK, transport)).rejects.toMatchObject({ + reason: "invalid_json", + }); + }); + + it("times out a server that never answers", async () => { + const transport: UpdateSourceTransport = async (_, { signal }) => + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(signal.reason as Error); + }); + }); + + await expect( + fetchWith(URL_OK, transport, { timeoutMs: 5 }), + ).rejects.toMatchObject({ reason: "timeout" }); + }); + + it("times out a body that stalls", async () => { + const transport: UpdateSourceTransport = async (_, { signal }) => + await Promise.resolve({ + ...reply(200), + body: (async function* stalled() { + yield new TextEncoder().encode("{"); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + reject(signal.reason as Error); + }); + }); + })(), + }); + + await expect( + fetchWith(URL_OK, transport, { timeoutMs: 5 }), + ).rejects.toMatchObject({ reason: "timeout" }); + }); + + it.each([ + [ + "a connection the connect-time lookup blocked", + Object.assign(new Error("blocked"), { reason: "blocked_address" }), + "blocked_address", + ], + ["a refused connection", new Error("ECONNREFUSED"), "network"], + ])("reports %s", async (_, error, reason) => { + const transport: UpdateSourceTransport = async () => + await Promise.reject(error); + + await expect(fetchWith(URL_OK, transport)).rejects.toMatchObject({ + reason, + }); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/fetch-source.ts b/packages/vitnode/src/api/lib/updates/fetch-source.ts index 0fc1ec96f..ac0573aa0 100644 --- a/packages/vitnode/src/api/lib/updates/fetch-source.ts +++ b/packages/vitnode/src/api/lib/updates/fetch-source.ts @@ -257,7 +257,7 @@ export const fetchUpdateSource = async ( if (response.status === 304) { response.destroy(); - if (!validators) { + if (!headers["if-none-match"] && !headers["if-modified-since"]) { throw new UpdateSourceError( "http_error", "304 without a request for it", diff --git a/packages/vitnode/src/api/lib/updates/lock.test.ts b/packages/vitnode/src/api/lib/updates/lock.test.ts new file mode 100644 index 000000000..6cc03d8cd --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/lock.test.ts @@ -0,0 +1,160 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { describe, expect, it } from "vitest"; + +import { core_update_lock } from "@/database/updates"; +import { createMemoryDb } from "@/tests/memory-db"; + +import { + acquireUpdateCheckLease, + MANUAL_UPDATE_CHECK_COOLDOWN_MS, + readUpdateCheckLock, + releaseUpdateCheckLease, + UPDATE_CHECK_LEASE_MS, +} from "./lock"; + +type Db = ReturnType; + +const T0 = new Date("2026-10-15T12:00:00Z"); +const at = (ms: number) => new Date(T0.getTime() + ms); + +const memoryDb = () => { + const memory = createMemoryDb(); + + return { ...memory, db: memory.db as unknown as Db }; +}; + +describe("acquireUpdateCheckLease", () => { + it("lets only one of two racing checks take the lease", async () => { + const { db } = memoryDb(); + + const results = await Promise.all([ + acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }), + acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "b" }), + ]); + + expect(results.filter(result => result.acquired)).toHaveLength(1); + expect(results.find(result => !result.acquired)).toEqual({ + acquired: false, + reason: "running", + retryAfterSeconds: UPDATE_CHECK_LEASE_MS / 1000, + }); + }); + + it("takes over a lease whose holder died and let it expire", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + + await expect( + acquireUpdateCheckLease(db, { + manual: false, + now: at(UPDATE_CHECK_LEASE_MS), + owner: "b", + }), + ).resolves.toEqual({ acquired: true, owner: "b" }); + }); + + it("is free again once the holder gives it back", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + await releaseUpdateCheckLease(db, "a"); + + await expect( + acquireUpdateCheckLease(db, { manual: false, now: at(1000), owner: "b" }), + ).resolves.toEqual({ acquired: true, owner: "b" }); + }); + + it("keeps manual checks apart by the cooldown, but not the daily one", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await releaseUpdateCheckLease(db, "a"); + + await expect( + acquireUpdateCheckLease(db, { + manual: true, + now: at(30_000), + owner: "b", + }), + ).resolves.toEqual({ + acquired: false, + reason: "cooldown", + retryAfterSeconds: MANUAL_UPDATE_CHECK_COOLDOWN_MS / 1000 - 30, + }); + await expect( + acquireUpdateCheckLease(db, { + manual: false, + now: at(30_000), + owner: "c", + }), + ).resolves.toEqual({ acquired: true, owner: "c" }); + }); + + it("allows a manual check again once the cooldown is over", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await releaseUpdateCheckLease(db, "a"); + + await expect( + acquireUpdateCheckLease(db, { + manual: true, + now: at(MANUAL_UPDATE_CHECK_COOLDOWN_MS), + owner: "b", + }), + ).resolves.toEqual({ acquired: true, owner: "b" }); + }); + + it("does not let the daily check reset the manual cooldown", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + await releaseUpdateCheckLease(db, "a"); + await acquireUpdateCheckLease(db, { + manual: false, + now: at(10_000), + owner: "b", + }); + await releaseUpdateCheckLease(db, "b"); + + await expect(readUpdateCheckLock(db, at(20_000))).resolves.toEqual({ + lastManualAt: T0, + running: false, + }); + }); + + it("reports a running check to a manual request still in cooldown", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: true, now: T0, owner: "a" }); + + await expect( + acquireUpdateCheckLease(db, { manual: true, now: at(1000), owner: "b" }), + ).resolves.toMatchObject({ acquired: false, reason: "running" }); + }); +}); + +describe("releaseUpdateCheckLease", () => { + it("only lets the holder give the lease back", async () => { + const { db } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + + await expect(releaseUpdateCheckLease(db, "b")).resolves.toBe(false); + await expect(readUpdateCheckLock(db, at(1000))).resolves.toMatchObject({ + running: true, + }); + await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(true); + await expect(readUpdateCheckLock(db, at(1000))).resolves.toMatchObject({ + running: false, + }); + }); + + it("cannot give back a lease another instance took over after expiry", async () => { + const { db, rows } = memoryDb(); + await acquireUpdateCheckLease(db, { manual: false, now: T0, owner: "a" }); + await acquireUpdateCheckLease(db, { + manual: false, + now: at(UPDATE_CHECK_LEASE_MS + 1), + owner: "b", + }); + + await expect(releaseUpdateCheckLease(db, "a")).resolves.toBe(false); + expect(rows(core_update_lock)).toMatchObject([{ owner: "b" }]); + }); +}); diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts new file mode 100644 index 000000000..824fd7e55 --- /dev/null +++ b/packages/vitnode/src/api/lib/updates/status.test.ts @@ -0,0 +1,225 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; + +import { describe, expect, it } from "vitest"; + +import type { UpdateChannel, UpdateManifest } from "@/lib/updates/manifest"; + +import { createMemoryDb } from "@/tests/memory-db"; + +import type { UpdateComponent } from "./components"; + +import { checkForUpdates } from "./check"; +import { UpdateSourceError } from "./fetch-source"; +import { acquireUpdateCheckLease } from "./lock"; +import { loadUpdatesOverview } from "./status"; + +type Db = ReturnType; + +const ACME = "https://acme.example.com/updates.json"; +const T1 = new Date("2026-10-14T03:00:00Z"); +const T2 = new Date("2026-10-15T03:00:00Z"); + +const MANIFEST: UpdateManifest = { + packages: { + "@acme/blog": { + advisories: [ + { + affected: "<1.1.0", + fixed: "1.1.0", + id: "ACME-1", + severity: "high", + url: "https://acme.example.com/advisories/1", + }, + ], + channels: { canary: "1.2.0-canary.1", stable: "1.1.0" }, + releases: { "1.1.0": {}, "1.2.0-canary.1": {} }, + }, + }, + schemaVersion: 1, +}; + +const blog = (overrides: Partial = {}): UpdateComponent => ({ + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: ACME, + versions: ["1.0.0"], + ...overrides, +}); + +/** A database holding one successful check of `blog()` on core 2.0.0. */ +const checked = async () => { + const db = createMemoryDb().db as unknown as Db; + await checkForUpdates({ + channelOverride: null, + components: [blog()], + coreVersion: "2.0.0", + db, + fetchSource: async () => + await Promise.resolve({ + body: MANIFEST, + etag: null, + kind: "fetched" as const, + lastModified: null, + }), + now: T1, + }); + + return db; +}; + +const overview = async ( + db: Db, + { + channelOverride = null, + components = [blog()], + coreVersion = "2.0.0", + enabled = true, + }: { + channelOverride?: null | UpdateChannel; + components?: UpdateComponent[]; + coreVersion?: string; + enabled?: boolean; + } = {}, +) => + await loadUpdatesOverview({ + channelOverride, + components, + coreVersion, + db, + enabled, + now: T2, + }); + +describe("loadUpdatesOverview", () => { + it("shows the stored result while nothing changed", async () => { + const db = await checked(); + + const { components } = await overview(db); + + expect(components).toEqual([ + { + id: "@acme/blog", + lastAttemptAt: T1, + lastError: null, + lastSuccessAt: T1, + result: expect.objectContaining({ + advisories: [expect.objectContaining({ id: "ACME-1" })], + status: "update_available", + }) as unknown, + sourceUrl: ACME, + stale: false, + state: "checked", + }, + ]); + }); + + it("re-evaluates a redeployed plugin from the stored manifest and marks it stale", async () => { + const db = await checked(); + + const [state] = ( + await overview(db, { components: [blog({ versions: ["1.1.0"] })] }) + ).components; + + expect(state).toMatchObject({ + lastSuccessAt: T1, + result: { advisories: [], status: "up_to_date" }, + stale: true, + state: "checked", + }); + }); + + it.each([ + ["the channel changed", { channelOverride: "canary" as const }], + ["core was upgraded", { coreVersion: "2.1.0" }], + [ + "a side lost its version", + { components: [blog({ hasUnknownVersion: true })] }, + ], + ])("marks the result stale when %s", async (_, change) => { + const db = await checked(); + + const [state] = (await overview(db, change)).components; + + expect(state?.stale).toBe(true); + }); + + it("does not reuse a result checked against another update source", async () => { + const db = await checked(); + + const [state] = ( + await overview(db, { + components: [ + blog({ sourceUrl: "https://elsewhere.example.com/u.json" }), + ], + }) + ).components; + + expect(state).toMatchObject({ + result: null, + stale: false, + state: "not_checked", + }); + }); + + it("keeps the previous result next to a failed check", async () => { + const db = await checked(); + await checkForUpdates({ + channelOverride: null, + components: [blog()], + coreVersion: "2.0.0", + db, + fetchSource: async () => + await Promise.reject(new UpdateSourceError("timeout")), + now: T2, + }); + + const [state] = (await overview(db)).components; + + expect(state).toMatchObject({ + lastAttemptAt: T2, + lastError: { code: "timeout" }, + lastSuccessAt: T1, + result: { advisories: [{ id: "ACME-1" }] }, + stale: false, + state: "checked", + }); + }); + + it.each([ + [ + "a component without an update source", + blog({ sourceUrl: null }), + "unavailable", + ], + ["a component never checked", blog({ id: "@acme/forum" }), "not_checked"], + ])("shows %s as such", async (_, component, state) => { + const db = await checked(); + + const [entry] = (await overview(db, { components: [component] })) + .components; + + expect(entry).toMatchObject({ result: null, state }); + }); + + it("shows nothing as checkable while checks are turned off", async () => { + const db = await checked(); + + const result = await overview(db, { enabled: false }); + + expect(result).toMatchObject({ + components: [{ result: null, state: "unavailable" }], + enabled: false, + }); + }); + + it("reports a running check and the last manual one", async () => { + const db = await checked(); + await acquireUpdateCheckLease(db, { manual: true, now: T2, owner: "a" }); + + await expect(overview(db)).resolves.toMatchObject({ + lastManualAt: T2, + running: true, + }); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts new file mode 100644 index 000000000..6f1615a6d --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts @@ -0,0 +1,136 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { + acquireUpdateCheckLease, + releaseUpdateCheckLease, +} from "@/api/lib/updates/lock"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { checkPluginUpdatesAdminRoute } from "./check-updates.route"; + +type Db = ReturnType; + +const ADMIN = { id: 1, roleId: 1 }; + +const CAN_CHECK: PermissionsStaffArgs[] = [ + { module: "plugins", permission: "can_view", plugin: "@vitnode/core" }, + { + module: "plugins", + permission: "can_check_updates", + plugin: "@vitnode/core", + }, +]; + +const checkAs = async ( + permissions: PermissionsStaffArgs[], + { + enabled = true, + prepare, + }: { enabled?: boolean; prepare?: (db: Db) => Promise } = {}, +) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); + const db = createMemoryDb().db as unknown as Db; + await prepare?.(db); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: ADMIN } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", db); + c.set("core", { + updates: { + channelOverride: null, + // No update sources: the check runs end to end without the network. + components: [ + { + hasUnknownVersion: false, + id: "@vitnode/core", + kind: "core", + sourceUrl: null, + versions: ["2.0.0"], + }, + ], + coreVersion: "2.0.0", + enabled, + }, + } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi( + checkPluginUpdatesAdminRoute.route, + checkPluginUpdatesAdminRoute.handler, + ); + + return await app.request("/updates/check", { method: "POST" }); +}; + +describe("POST /admin/plugins/updates/check", () => { + it("runs the check for an administrator allowed to", async () => { + const response = await checkAs(CAN_CHECK); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + advisories: 0, + components: 0, + failedSources: 0, + sources: 0, + status: "completed", + updates: 0, + }); + }); + + it("refuses an administrator who can only view plugins", async () => { + const response = await checkAs(CAN_CHECK.slice(0, 1)); + + expect(response.status).toBe(403); + }); + + it.each([ + [ + "a check is already running", + { + prepare: async (db: Db) => { + await acquireUpdateCheckLease(db, { manual: false, owner: "cron" }); + }, + }, + 409, + "running", + ], + [ + "a manual check ran moments ago", + { + prepare: async (db: Db) => { + await acquireUpdateCheckLease(db, { manual: true, owner: "earlier" }); + await releaseUpdateCheckLease(db, "earlier"); + }, + }, + 429, + "cooldown", + ], + ])("answers %s with a Retry-After", async (_, options, status, body) => { + const response = await checkAs(CAN_CHECK, options); + + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ status: body }); + expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0); + }); + + it("answers 409 while update checks are turned off", async () => { + const response = await checkAs(CAN_CHECK, { enabled: false }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + retryAfterSeconds: null, + status: "disabled", + }); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts new file mode 100644 index 000000000..536cf7354 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.test.ts @@ -0,0 +1,114 @@ +import type { drizzle } from "drizzle-orm/postgres-js"; +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; +import type { UpdateComponent } from "@/api/lib/updates/components"; + +import { checkForUpdates } from "@/api/lib/updates/check"; +import { createTestCache } from "@/tests/cache"; +import { createMemoryDb } from "@/tests/memory-db"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { pluginUpdatesAdminRoute } from "./updates.route"; + +type Db = ReturnType; + +const ADMIN = { id: 1, roleId: 1 }; +const SOURCE = "https://vitnode.example.com/updates.json"; +const CHECKED_AT = new Date("2026-10-15T03:00:00Z"); + +const COMPONENTS: UpdateComponent[] = [ + { + hasUnknownVersion: false, + id: "@vitnode/core", + kind: "core", + sourceUrl: SOURCE, + versions: ["2.0.0"], + }, + { + hasUnknownVersion: false, + id: "@acme/blog", + kind: "plugin", + sourceUrl: null, + versions: ["1.0.0"], + }, +]; + +const requestAs = async (permissions: PermissionsStaffArgs[]) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); + const db = createMemoryDb().db as unknown as Db; + await checkForUpdates({ + channelOverride: null, + components: COMPONENTS, + coreVersion: "2.0.0", + db, + fetchSource: async () => + await Promise.resolve({ + body: { + packages: { + "@vitnode/core": { + channels: { stable: "2.1.0" }, + releases: { "2.1.0": {} }, + }, + }, + schemaVersion: 1, + }, + etag: null, + kind: "fetched" as const, + lastModified: null, + }), + now: CHECKED_AT, + }); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: ADMIN } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("db", db); + c.set("core", { + updates: { + channelOverride: null, + components: COMPONENTS, + coreVersion: "2.0.0", + enabled: true, + }, + } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi(pluginUpdatesAdminRoute.route, pluginUpdatesAdminRoute.handler); + + return await app.request("/updates"); +}; + +describe("GET /admin/plugins/updates", () => { + it("returns core and the plugins from the stored results", async () => { + const response = await requestAs([ + { module: "plugins", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + core: { + id: "@vitnode/core", + lastSuccessAt: CHECKED_AT.toISOString(), + result: { latest: { version: "2.1.0" }, status: "update_available" }, + state: "checked", + }, + enabled: true, + plugins: [{ id: "@acme/blog", state: "unavailable" }], + running: false, + }); + }); + + it("refuses an administrator without the plugins permission", async () => { + const response = await requestAs([ + { module: "system", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(403); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-inventory/generate.test.ts b/packages/vitnode/src/framework/plugin-inventory/generate.test.ts index 02cb64603..f89959946 100644 --- a/packages/vitnode/src/framework/plugin-inventory/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-inventory/generate.test.ts @@ -22,6 +22,8 @@ const BLOG_MANIFEST = { version: "1.0.0", }; +const WITH_UPDATES = { ...BLOG_MANIFEST, updates: null }; + describe("pluginInventoryFromLoadedConfig", () => { it("lists each registration with its manifest, or null without one", () => { expect( @@ -33,17 +35,29 @@ describe("pluginInventoryFromLoadedConfig", () => { SOURCE, ), ).toEqual([ - { id: "@acme/blog", manifest: BLOG_MANIFEST }, + { id: "@acme/blog", manifest: WITH_UPDATES }, { id: "@acme/shop", manifest: null }, ]); }); + it("keeps the update source of a manifest that declares one", () => { + const updates = { url: "https://example.com/updates.json" }; + + expect( + pluginInventoryFromLoadedConfig( + [{ manifest: { ...BLOG_MANIFEST, updates }, pluginId: "@acme/blog" }], + SOURCE, + ), + ).toEqual([{ id: "@acme/blog", manifest: { ...BLOG_MANIFEST, updates } }]); + }); + it.each([ ["a string", "Blog"], ["a missing title", { ...BLOG_MANIFEST, title: undefined }], ["a missing icon", { ...BLOG_MANIFEST, icon: undefined }], ["a numeric version", { ...BLOG_MANIFEST, version: 1 }], ["an undefined description", { ...BLOG_MANIFEST, description: undefined }], + ["an update source without a url", { ...BLOG_MANIFEST, updates: {} }], ])("rejects manifest that is %s", (_, manifest) => { expect(() => pluginInventoryFromLoadedConfig( @@ -63,12 +77,32 @@ describe("pluginInventoryFromLoadedConfig", () => { /registers the plugin "@acme\/shop" with the manifest of "@acme\/blog"/, ); }); + + it.each([ + ["an http update source", "http://example.com/updates.json"], + ["an update source with a token", "https://example.com/u.json?token=1"], + ])("rejects manifest with %s", (_, url) => { + expect(() => + pluginInventoryFromLoadedConfig( + [ + { + manifest: { ...BLOG_MANIFEST, updates: { url } }, + pluginId: "@acme/blog", + }, + ], + SOURCE, + ), + ).toThrow(/gives the plugin "@acme\/blog" the update source/); + }); }); describe("generatePluginInventorySource", () => { const blog: FrontendPluginInventoryEntry = { id: "@acme/blog", - manifest: BLOG_MANIFEST, + manifest: { + ...BLOG_MANIFEST, + updates: { url: "https://example.com/updates.json" }, + }, }; const shop: FrontendPluginInventoryEntry = { id: "@acme/shop", @@ -76,7 +110,7 @@ describe("generatePluginInventorySource", () => { }; const forum: FrontendPluginInventoryEntry = { id: "@acme/forum", - manifest: { ...BLOG_MANIFEST, icon: null, id: "@acme/forum" }, + manifest: { ...WITH_UPDATES, icon: null, id: "@acme/forum" }, }; it("writes the same bytes whatever order the plugins are configured in", () => { diff --git a/packages/vitnode/src/framework/plugin-manifest/generate.test.ts b/packages/vitnode/src/framework/plugin-manifest/generate.test.ts index 392ef0b62..cf0295d1f 100644 --- a/packages/vitnode/src/framework/plugin-manifest/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-manifest/generate.test.ts @@ -21,6 +21,7 @@ describe("pluginManifestFromPackageJson", () => { description: "Posts and categories", id: "@acme/blog", license: null, + updates: null, version: "1.2.0", }); }); @@ -35,6 +36,7 @@ describe("pluginManifestFromPackageJson", () => { description: null, id: "@acme/blog", license: null, + updates: null, version: null, }); }); @@ -59,6 +61,35 @@ describe("pluginManifestFromPackageJson", () => { ); }); + it("reads the update source from `vitnode.updates.url`, trimmed", () => { + expect( + pluginManifestFromPackageJson( + { + name: "@acme/blog", + vitnode: { + updates: { url: " https://example.com/acme/updates.json " }, + }, + }, + SOURCE, + ).updates, + ).toEqual({ url: "https://example.com/acme/updates.json" }); + }); + + it.each([ + ["a non-object `vitnode`", "plugin", /`vitnode` in package\.json/], + ["no url", { updates: {} }, /`vitnode\.updates` in package\.json/], + ["a non-string url", { updates: { url: 1 } }, /`vitnode\.updates`/], + [ + "a url carrying a token", + { updates: { url: "https://example.com/updates.json?token=secret" } }, + /`vitnode\.updates\.url` in package\.json cannot carry a query string/, + ], + ])("rejects an update source with %s", (_, vitnode, error) => { + expect(() => + pluginManifestFromPackageJson({ name: "@acme/blog", vitnode }, SOURCE), + ).toThrow(error); + }); + it("names the file and the manifest prefix in its errors", () => { expect(() => pluginManifestFromPackageJson({}, "plugins/blog/package.json"), diff --git a/packages/vitnode/src/lib/updates/manifest.test.ts b/packages/vitnode/src/lib/updates/manifest.test.ts new file mode 100644 index 000000000..dc561a3a8 --- /dev/null +++ b/packages/vitnode/src/lib/updates/manifest.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { + channelOfVersion, + parseUpdateManifest, + versionInRange, +} from "./manifest"; + +const ADVISORY = { + affected: "<1.2.1", + fixed: "1.2.1", + id: "VN-2026-001", + severity: "high", + url: "https://example.com/advisories/VN-2026-001", +}; + +const manifestWith = (entry: Record) => ({ + packages: { "@acme/blog": entry }, + schemaVersion: 1, +}); + +describe("parseUpdateManifest", () => { + it("reads a manifest and fills in what a package leaves out", () => { + const result = parseUpdateManifest({ + packages: { + "@acme/blog": { + advisories: [ADVISORY], + channels: { canary: "1.3.0-canary.2", stable: "1.2.1" }, + releases: { + "1.2.1": { requiresCore: "^2.0.0" }, + "1.3.0-canary.2": {}, + }, + }, + "@acme/shop": {}, + }, + schemaVersion: 1, + }); + + expect(result).toEqual({ + manifest: { + packages: { + "@acme/blog": { + advisories: [ADVISORY], + channels: { canary: "1.3.0-canary.2", stable: "1.2.1" }, + releases: { + "1.2.1": { requiresCore: "^2.0.0" }, + "1.3.0-canary.2": {}, + }, + }, + "@acme/shop": { advisories: [], channels: {}, releases: {} }, + }, + schemaVersion: 1, + }, + ok: true, + }); + }); + + it.each([ + ["a future version", { packages: {}, schemaVersion: 2 }, /schemaVersion 2/], + ["a missing version", { packages: {} }, /Missing or invalid/], + ["a string version", { schemaVersion: "1" }, /Missing or invalid/], + [ + "a future format that looks nothing like this one", + { schemaVersion: 2, sources: [] }, + /schemaVersion 2/, + ], + ])("reports %s as unsupported, not invalid", (_, value, error) => { + expect(parseUpdateManifest(value)).toEqual({ + error: expect.stringMatching(error), + ok: false, + reason: "unsupported_schema", + }); + }); + + it.each([ + ["an array", []], + ["null", null], + ["a string", "updates"], + [ + "a channel pointing to a release that is not listed", + manifestWith({ channels: { stable: "1.2.1" } }), + ], + [ + "a stable channel pointing to a prerelease", + manifestWith({ + channels: { stable: "1.3.0-rc.1" }, + releases: { "1.3.0-rc.1": {} }, + }), + ], + [ + "the same advisory twice", + manifestWith({ advisories: [ADVISORY, ADVISORY] }), + ], + [ + "an advisory linking over http", + manifestWith({ + advisories: [{ ...ADVISORY, url: "http://example.com/a" }], + }), + ], + [ + "an advisory with no valid range", + manifestWith({ advisories: [{ ...ADVISORY, affected: "soon" }] }), + ], + [ + "a release keyed by a non-SemVer version", + manifestWith({ releases: { latest: {} } }), + ], + [ + "a release with an unknown field", + manifestWith({ releases: { "1.0.0": { download: "https://x.io" } } }), + ], + [ + "a package name that is not an npm name", + { packages: { "Acme Blog": {} }, schemaVersion: 1 }, + ], + ])("rejects %s as invalid", (_, value) => { + expect(parseUpdateManifest(value)).toMatchObject({ + ok: false, + reason: "invalid", + }); + }); + + it("names the offending channel", () => { + const result = parseUpdateManifest( + manifestWith({ channels: { rc: "1.3.0-rc.1" } }), + ); + + expect(result.ok ? null : result.error).toMatch( + /packages\.@acme\/blog\.channels\.rc: .*1\.3\.0-rc\.1/, + ); + }); +}); + +describe("channelOfVersion", () => { + it.each([ + ["2.1.0", "stable"], + ["2.1.0-rc.1", "rc"], + ["2.1.0-rc1", "rc"], + ["2.1.0-canary.4", "canary"], + ["2.1.0-beta.1", null], + ["2.1.0-1", null], + ["not a version", null], + ])("puts %s on %s", (version, channel) => { + expect(channelOfVersion(version)).toBe(channel); + }); +}); + +describe("versionInRange", () => { + it.each([ + ["2.1.3-canary.1", "<2.1.3", true], + ["2.1.3-rc.1", ">=2.0.0 <2.1.3", true], + ["2.1.3", "<2.1.3", false], + ["2.0.5", ">=2.0.0 <2.1.3", true], + ["1.9.9", ">=2.0.0 <2.1.3", false], + ])("%s inside %s is %s", (version, range, inside) => { + expect(versionInRange(version, range)).toBe(inside); + }); +}); diff --git a/packages/vitnode/src/lib/updates/source-url.test.ts b/packages/vitnode/src/lib/updates/source-url.test.ts new file mode 100644 index 000000000..208c94b10 --- /dev/null +++ b/packages/vitnode/src/lib/updates/source-url.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { updateSourceUrlProblem } from "./source-url"; + +describe("updateSourceUrlProblem", () => { + it.each([ + "https://raw.githubusercontent.com/acme/plugins/main/updates.json", + "https://example.com:8443/updates.json", + ])("accepts %s", url => { + expect(updateSourceUrlProblem(url)).toBeNull(); + }); + + it.each([ + ["http", "http://example.com/updates.json", /https:\/\//], + ["a relative path", "/updates.json", /https:\/\//], + ["a user name", "https://acme@example.com/updates.json", /user name/], + ["a password", "https://acme:secret@example.com/u.json", /password/], + ["a query string", "https://example.com/u.json?token=1", /query string/], + ["a fragment", "https://example.com/u.json#main", /fragment/], + [ + "a URL over 2048 characters", + `https://example.com/${"a".repeat(2048)}`, + /https:\/\//, + ], + ])("rejects %s", (_, url, problem) => { + expect(updateSourceUrlProblem(url)).toMatch(problem); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx new file mode 100644 index 000000000..c7d23c372 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-update-status.test.tsx @@ -0,0 +1,198 @@ +import { render, screen, within } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it } from "vitest"; + +import type { AdminComponentUpdate } from "./plugin-updates-query"; + +import { PluginUpdateStatus, SecurityAdvisories } from "./plugin-update-status"; + +const T = "admin.system.plugins.updates"; + +const ADVISORY = { + affected: "<1.1.0", + fixed: "1.1.0", + id: "ACME-1", + severity: "critical" as const, + summary: "Stored XSS in post titles", + url: "https://acme.example.com/advisories/1", + versions: ["1.0.0"], +}; + +const update = ( + overrides: Partial = {}, + result: Partial> = {}, +): AdminComponentUpdate => ({ + id: "@acme/blog", + lastAttemptAt: "2026-10-15T03:00:00.000Z", + lastError: null, + lastSuccessAt: "2026-10-15T03:00:00.000Z", + result: { + advisories: [], + alternative: null, + channel: "stable", + compatibility: "compatible", + latest: { + publishedAt: null, + requiresCore: "^2.0.0", + url: "https://acme.example.com/releases/1.1.0", + version: "1.1.0", + }, + status: "update_available", + ...result, + }, + sourceUrl: "https://acme.example.com/updates.json", + stale: false, + state: "checked", + ...overrides, +}); + +const renderWithIntl = (node: React.ReactNode) => + render( + + {node} + , + ); + +const renderStatus = (value: AdminComponentUpdate, enabled = true) => + renderWithIntl( + , + ); + +describe("SecurityAdvisories", () => { + it("lists each advisory with its title, summary and an https link", () => { + renderWithIntl( + , + ); + + const list = screen.getByRole("list", { name: `${T}.security.label` }); + const item = within(list).getByRole("listitem"); + expect(within(item).getByText(`${T}.security.title`)).toBeTruthy(); + expect(within(item).getByText("Stored XSS in post titles")).toBeTruthy(); + expect( + within(item) + .getByRole("link", { name: `${T}.security.details` }) + .getAttribute("href"), + ).toBe("https://acme.example.com/advisories/1"); + }); + + it.each([ + "http://acme.example.com/advisories/1", + "javascript:alert(1)", + "not a url", + ])("does not link to %s", url => { + renderWithIntl( + , + ); + + expect(screen.getByText(`${T}.security.title`)).toBeTruthy(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("says when no fix is available yet", () => { + renderWithIntl( + , + ); + + expect(screen.getByText(new RegExp(`${T}.security.no_fix`))).toBeTruthy(); + }); + + it("renders nothing without advisories", () => { + const { container } = renderWithIntl( + , + ); + + expect(container.childElementCount).toBe(0); + }); +}); + +describe("PluginUpdateStatus", () => { + it("shows the available update with its release notes", () => { + renderStatus(update()); + + expect(screen.getByText(`${T}.status.update_available`)).toBeTruthy(); + expect( + screen + .getByRole("link", { name: `${T}.release_notes` }) + .getAttribute("href"), + ).toBe("https://acme.example.com/releases/1.1.0"); + }); + + it("warns about an incompatible update and offers the compatible one", () => { + renderStatus( + update( + {}, + { + alternative: { + publishedAt: null, + requiresCore: "^2.0.0", + url: null, + version: "1.0.5", + }, + compatibility: "incompatible", + }, + ), + ); + + expect(screen.getByText(`${T}.compatibility.incompatible`)).toBeTruthy(); + expect(screen.getByText(`${T}.compatibility.alternative`)).toBeTruthy(); + }); + + it("keeps the previous result next to a failed check", () => { + renderStatus(update({ lastError: { code: "timeout", detail: null } })); + + expect(screen.getByText(`${T}.status.update_available`)).toBeTruthy(); + expect(screen.getByText(`${T}.failed_with_previous`)).toBeTruthy(); + expect(screen.queryByText(`${T}.checked_at`)).toBeNull(); + }); + + it("reports a check that failed before anything was found", () => { + renderStatus( + update({ + lastError: { code: "blocked_address", detail: null }, + lastSuccessAt: null, + result: null, + state: "not_checked", + }), + ); + + expect(screen.getByText(`${T}.failed`)).toBeTruthy(); + expect(screen.queryByText(`${T}.status.not_checked`)).toBeNull(); + }); + + it("marks a result not yet confirmed for what runs now as stale", () => { + renderStatus(update({ stale: true })); + + expect(screen.getByText(`${T}.stale`)).toBeTruthy(); + expect(screen.queryByText(`${T}.checked_at`)).toBeNull(); + }); + + it("says when the last check is current", () => { + renderStatus(update()); + + expect(screen.getByText(`${T}.checked_at`)).toBeTruthy(); + }); + + it.each([ + ["checks are turned off", update(), false, `${T}.status.disabled`], + [ + "the plugin declares no update source", + update({ result: null, sourceUrl: null, state: "unavailable" }), + true, + `${T}.status.unavailable`, + ], + [ + "nothing was checked yet", + update({ lastSuccessAt: null, result: null, state: "not_checked" }), + true, + `${T}.status.not_checked`, + ], + ])("says so when %s", (_, value, enabled, text) => { + renderStatus(value, enabled); + + expect(screen.getByText(text)).toBeTruthy(); + }); +}); From 4821f22e5ec46b03e5890114f25545bbc809b5a7 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 16 Oct 2026 22:58:15 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(debug):=20=E2=9C=A8=20add=20debug=20na?= =?UTF-8?q?vigation=20and=20overview=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced `DebugNav` component for navigation within the debug section. - Created `DebugOverview` component to summarize system health and issues. - Added various sections including `SecurityUpdateAlert`, `VitnodeSection`, `PluginsSection`, and `ServicesSection` to provide detailed insights into system status. - Implemented reusable components like `FrameRow`, `CheckRow`, and `FactRow` for consistent UI structure. - Established tone indicators for health checks with `ToneIcon`, `ToneDot`, and `ToneBadge`. - Initialized `security-updates.json` for future security update management. --- .github/workflows/bump_publish.yml | 2 +- ...ories.yml => publish-security-updates.yml} | 16 +- .../content-engine/content-visibility.mdx | 2 +- .../docs/content-engine/production.mdx | 2 +- .../content/docs/content-engine/search.mdx | 6 +- apps/web/content/docs/dev/advanced/queue.mdx | 10 +- apps/web/content/docs/dev/advanced/redis.mdx | 6 +- apps/web/content/docs/dev/ai/admin.mdx | 8 +- .../web/content/docs/dev/ai/automatic-alt.mdx | 2 +- .../content/docs/dev/cron/custom-adapter.mdx | 6 +- apps/web/content/docs/dev/cron/index.mdx | 14 +- apps/web/content/docs/dev/cron/node-cron.mdx | 19 +- apps/web/content/docs/dev/cron/rest-api.mdx | 11 +- apps/web/content/docs/dev/debugging/index.mdx | 74 ++- .../content/docs/dev/debugging/logging.mdx | 10 +- .../docs/dev/events/built-in-events.mdx | 2 +- .../content/docs/dev/i18n/localized-urls.mdx | 5 +- .../dev/notifications/queue-and-scale.mdx | 2 +- .../dev/notifications/troubleshooting.mdx | 2 +- apps/web/content/docs/dev/plugins/catalog.mdx | 4 +- .../content/docs/dev/search-elasticsearch.mdx | 8 +- apps/web/content/docs/dev/search.mdx | 14 +- apps/web/content/docs/dev/updates/admin.mdx | 14 +- apps/web/content/docs/dev/updates/index.mdx | 8 +- .../web/content/docs/dev/updates/manifest.mdx | 30 +- apps/web/content/docs/dev/updates/meta.json | 4 +- .../docs/dev/updates/official-releases.mdx | 15 +- .../docs/dev/updates/plugin-authors.mdx | 4 +- .../docs/dev/updates/security-advisories.mdx | 50 -- .../docs/dev/updates/security-updates.mdx | 48 ++ apps/web/content/docs/guides/forum.mdx | 2 +- apps/web/src/locales/@vitnode/core/pl.json | 216 ++++++-- apps/web/src/page-cache.gen.ts | 13 +- .../scripts/update-manifest/merge.test.ts | 57 +- .../vitnode/scripts/update-manifest/merge.ts | 40 +- .../vitnode/src/api/lib/updates/check.test.ts | 23 +- packages/vitnode/src/api/lib/updates/check.ts | 6 +- .../src/api/lib/updates/evaluate.test.ts | 56 +- .../vitnode/src/api/lib/updates/evaluate.ts | 31 +- .../src/api/lib/updates/status.test.ts | 9 +- .../routes/check-updates.route.test.ts | 2 +- .../plugins/routes/check-updates.route.ts | 4 +- .../admin/plugins/routes/updates.route.ts | 2 +- .../admin/plugins/routes/updates.schema.ts | 6 +- .../modules/cron/cron/check-updates.cron.ts | 2 +- packages/vitnode/src/components/side-nav.tsx | 79 ++- .../vitnode/src/lib/updates/manifest.test.ts | 23 +- packages/vitnode/src/lib/updates/manifest.ts | 37 +- packages/vitnode/src/locales/en.json | 200 +++++-- .../vitnode/src/pages/admin/advanced/cron.tsx | 32 -- .../src/pages/admin/advanced/queue.tsx | 32 -- .../src/pages/admin/advanced/search.tsx | 34 -- .../vitnode/src/pages/admin/ai/history.tsx | 38 -- packages/vitnode/src/pages/admin/debug.tsx | 32 -- packages/vitnode/src/pages/admin/debug/ai.tsx | 21 + .../vitnode/src/pages/admin/debug/cache.tsx | 8 + .../vitnode/src/pages/admin/debug/cron.tsx | 16 + .../vitnode/src/pages/admin/debug/index.tsx | 8 + .../vitnode/src/pages/admin/debug/layout.tsx | 35 ++ .../vitnode/src/pages/admin/debug/logs.tsx | 16 + .../vitnode/src/pages/admin/debug/plugins.tsx | 8 + .../vitnode/src/pages/admin/debug/queue.tsx | 16 + .../vitnode/src/pages/admin/debug/search.tsx | 16 + .../src/pages/admin/debug/services.tsx | 8 + .../vitnode/src/pages/admin/debug/vitnode.tsx | 8 + packages/vitnode/src/routes.test.ts | 8 +- packages/vitnode/src/routes.tsx | 97 ++-- .../src/tanstack/admin/ai/history-screen.tsx | 99 ---- .../vitnode/src/tanstack/admin/ai/index.ts | 8 +- .../vitnode/src/tanstack/admin/ai/route.tsx | 38 +- .../vitnode/src/tanstack/admin/cron/index.ts | 7 - .../vitnode/src/tanstack/admin/cron/route.tsx | 55 -- .../src/tanstack/admin/cron/screen.tsx | 65 --- .../src/tanstack/admin/debug/breadcrumb.tsx | 24 + .../src/tanstack/admin/debug/crumb.tsx | 24 + .../vitnode/src/tanstack/admin/debug/index.ts | 59 +- .../src/tanstack/admin/debug/layout.tsx | 47 ++ .../src/tanstack/admin/debug/pending.tsx | 122 +++++ .../vitnode/src/tanstack/admin/debug/query.ts | 143 ++++- .../src/tanstack/admin/debug/route-search.ts | 60 ++- .../src/tanstack/admin/debug/route.tsx | 155 ++++-- .../src/tanstack/admin/debug/screen.tsx | 78 --- .../src/tanstack/admin/debug/sections.tsx | 105 ++++ .../src/tanstack/admin/debug/status.tsx | 28 + .../src/tanstack/admin/debug/tables.tsx | 301 +++++++++++ .../vitnode/src/tanstack/admin/debug/tabs.ts | 41 ++ .../vitnode/src/tanstack/admin/queue/index.ts | 4 - .../src/tanstack/admin/queue/route.tsx | 52 -- .../src/tanstack/admin/queue/screen.tsx | 57 -- .../src/tanstack/admin/search-index/index.ts | 7 - .../src/tanstack/admin/search-index/route.tsx | 47 -- .../tanstack/admin/search-index/screen.tsx | 76 --- .../vitnode/src/tanstack/admin/user-bar.tsx | 10 + .../layouts/search/search-only-pages.tsx | 20 +- .../layouts/sidebar/nav/nav-model.test.ts | 5 - .../admin/layouts/sidebar/nav/nav-model.tsx | 28 - .../layouts/user-bar/user-bar-content.tsx | 8 +- .../core/advanced/cron/cron-table-content.tsx | 67 ++- .../admin/views/core/debug/health/health.ts | 503 ++++++++++++++++++ .../admin/views/core/debug/nav/debug-nav.tsx | 218 ++++++++ .../core/debug/queue/queue-view-content.tsx | 83 --- .../views/core/debug/sections/frame-row.tsx | 86 +++ .../views/core/debug/sections/overview.tsx | 153 ++++++ .../views/core/debug/sections/security.tsx | 180 +++++++ .../views/core/debug/sections/systems.tsx | 290 ++++++++++ .../debug/system-logs/system-logs-content.tsx | 7 - .../src/views/admin/views/core/debug/tone.tsx | 86 +++ .../views/core/shared/admin-permissions.ts | 13 + .../system/plugins/check-updates-button.tsx | 2 +- .../core/system/plugins/core-update-card.tsx | 6 +- .../plugins/plugin-update-status.test.tsx | 34 +- .../system/plugins/plugin-update-status.tsx | 34 +- .../system/plugins/plugin-updates-query.ts | 2 +- .../core/system/plugins/plugins-content.tsx | 4 +- .../src/views/auth/settings/nav-content.tsx | 6 +- .../src/views/moderation/modcp/modcp-nav.tsx | 6 +- packages/vitnode/src/vitnode.config.ts | 2 +- .../publish-update-manifest.ts | 34 +- security/README.md | 8 +- ...{advisories.json => security-updates.json} | 2 +- 120 files changed, 3679 insertions(+), 1559 deletions(-) rename .github/workflows/{publish-advisories.yml => publish-security-updates.yml} (72%) delete mode 100644 apps/web/content/docs/dev/updates/security-advisories.mdx create mode 100644 apps/web/content/docs/dev/updates/security-updates.mdx delete mode 100644 packages/vitnode/src/pages/admin/advanced/cron.tsx delete mode 100644 packages/vitnode/src/pages/admin/advanced/queue.tsx delete mode 100644 packages/vitnode/src/pages/admin/advanced/search.tsx delete mode 100644 packages/vitnode/src/pages/admin/ai/history.tsx delete mode 100644 packages/vitnode/src/pages/admin/debug.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/ai.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/cache.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/cron.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/index.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/layout.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/logs.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/plugins.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/queue.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/search.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/services.tsx create mode 100644 packages/vitnode/src/pages/admin/debug/vitnode.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/ai/history-screen.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/cron/route.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/cron/screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/breadcrumb.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/crumb.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/layout.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/pending.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/debug/screen.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/sections.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/status.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/tables.tsx create mode 100644 packages/vitnode/src/tanstack/admin/debug/tabs.ts delete mode 100644 packages/vitnode/src/tanstack/admin/queue/route.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/queue/screen.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/search-index/route.tsx delete mode 100644 packages/vitnode/src/tanstack/admin/search-index/screen.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/debug/health/health.ts create mode 100644 packages/vitnode/src/views/admin/views/core/debug/nav/debug-nav.tsx delete mode 100644 packages/vitnode/src/views/admin/views/core/debug/queue/queue-view-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/debug/sections/frame-row.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/debug/sections/overview.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/debug/sections/security.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/debug/sections/systems.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/debug/tone.tsx rename security/{advisories.json => security-updates.json} (52%) diff --git a/.github/workflows/bump_publish.yml b/.github/workflows/bump_publish.yml index b5633b9c1..5610029d1 100644 --- a/.github/workflows/bump_publish.yml +++ b/.github/workflows/bump_publish.yml @@ -134,7 +134,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - # Shared with the advisories workflow: one writer of updates.json at a time. + # Shared with the security updates workflow: one writer of updates.json at a time. concurrency: group: vitnode-update-manifest cancel-in-progress: false diff --git a/.github/workflows/publish-advisories.yml b/.github/workflows/publish-security-updates.yml similarity index 72% rename from .github/workflows/publish-advisories.yml rename to .github/workflows/publish-security-updates.yml index 7f5b9410a..18dd822a4 100644 --- a/.github/workflows/publish-advisories.yml +++ b/.github/workflows/publish-security-updates.yml @@ -1,19 +1,19 @@ -name: Publish security advisories +name: Publish security updates on: pull_request: paths: - - "security/advisories.json" + - "security/security-updates.json" push: branches: - canary paths: - - "security/advisories.json" + - "security/security-updates.json" workflow_dispatch: jobs: - advisories: - name: "Validate and publish advisories" + security-updates: + name: "Validate and publish security updates" runs-on: ubuntu-latest permissions: contents: write @@ -40,11 +40,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Validate security/advisories.json + - name: Validate security/security-updates.json run: pnpm run release:update-manifest check - - name: Publish advisories to updates.json + - name: Publish security updates to updates.json if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/canary' }} - run: pnpm run release:update-manifest advisories + run: pnpm run release:update-manifest security-updates env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/web/content/docs/content-engine/content-visibility.mdx b/apps/web/content/docs/content-engine/content-visibility.mdx index 06abc5dde..d21c360ef 100644 --- a/apps/web/content/docs/content-engine/content-visibility.mdx +++ b/apps/web/content/docs/content-engine/content-visibility.mdx @@ -141,7 +141,7 @@ The route tells whose record it is by its `id`, so `id` must be in `publicApi.fi Everyone else, signed in or not, still gets a `404`. Old slugs never redirect to a hidden record, and the list, sitemap and `hreflang` alternates leave it out for everybody. -Search learns that a record is hidden when it is hidden or unhidden. Records hidden before you updated VitNode show up for their authors and editors only after a reindex: **AdminCP → Advanced → Search**, then **Reindex** on the collection, such as `blog.post`. See [Search](/docs/content-engine/search). +Search learns that a record is hidden when it is hidden or unhidden. Records hidden before you updated VitNode show up for their authors and editors only after a reindex: **Debug Panel → Search** (`/admin/core/debug/search`), then **Reindex** on the collection, such as `blog.post`. See [Search](/docs/content-engine/search). Hiding changes nothing else. `status` and `publishedAt` stay as they are, so unhiding a published record puts it back with its original date. Publishing, unpublishing and scheduled publishes never change visibility, so publishing a hidden record keeps it off the site until someone unhides it. Restoring a revision never changes visibility either. diff --git a/apps/web/content/docs/content-engine/production.mdx b/apps/web/content/docs/content-engine/production.mdx index 759a268d2..0275704d8 100644 --- a/apps/web/content/docs/content-engine/production.mdx +++ b/apps/web/content/docs/content-engine/production.mdx @@ -56,7 +56,7 @@ publicApi: { ## Check content health -**AdminCP → Advanced → Search** shows how much of each content type is in the search index. Use **Reindex** on a collection, or **Rebuild index**, after a bulk import or a failed sync. +**Debug Panel → Search** in the AdminCP (`/admin/core/debug/search`) shows how much of each content type is in the search index. Use **Reindex** on a collection, or **Rebuild index**, after a bulk import or a failed sync. Both buttons need the **Rebuild and clear the search index** staff permission (`system` → `can_manage_search`). A rebuild clears the collection and refills it in pages of 200 records. It is a queued job, so it only runs while [cron](/docs/dev/cron) is running. @@ -156,7 +156,7 @@ The two records then stay in step: - The document stores the related record as its container: `containerType` is the target's content type id and `containerId` its record id. - When the related record's search title changes, or it is published, unpublished, hidden or unhidden, every record that borrows its title is re-indexed. Deleting it removes their documents. -Records saved before you turned this on need a **Reindex** of the collection, or **Rebuild index** in **AdminCP → Advanced → Search**. +Records saved before you turned this on need a **Reindex** of the collection, or **Rebuild index** in **Debug Panel → Search** (`/admin/core/debug/search`). ## Say where a record lives diff --git a/apps/web/content/docs/dev/advanced/queue.mdx b/apps/web/content/docs/dev/advanced/queue.mdx index b8eed7673..7a1efb716 100644 --- a/apps/web/content/docs/dev/advanced/queue.mdx +++ b/apps/web/content/docs/dev/advanced/queue.mdx @@ -101,13 +101,13 @@ A task still `processing` after 15 minutes (`QUEUE_STALE_PROCESSING_MINUTES`) be --- -## AdminCP Queue Monitor +## Inspect tasks in the Debug Panel -{/* Image prompt: VitNode AdminCP Queue Management screen at /admin/core/advanced/queue. Table displaying queued jobs with status badges (pending, processing, completed, failed), attempts count, payload JSON preview, and a retry action button. Dark theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/queue with the "Queue" section open in the side menu. Table of queue tasks with columns Task, Queue, Status (pending, processing, completed, failed badges), Attempts, Available At, Created At and Last Error, plus a Status filter above it. Dark theme, 1440x900. */} -Inspect and debug tasks at **Core → Advanced → Queue** (`/admin/core/advanced/queue`): -- Monitor pending, active, and failed jobs in real time. -- View failure error traces and retry failed tasks with one click. +Open **Debug Panel → Queue** in the AdminCP (`/admin/core/debug/queue`), or type "Queue tasks" into the ⌘K admin search. The table lists every task with its queue, status, attempts, when it is next available, when it was created and its last error. Filter by status to find the failed ones. + +The section is read-only. A failed task stays failed, so fix the cause and dispatch it again from your code. Viewing it needs the `queue → can_view` staff permission. --- diff --git a/apps/web/content/docs/dev/advanced/redis.mdx b/apps/web/content/docs/dev/advanced/redis.mdx index 1c18db137..a1388cd73 100644 --- a/apps/web/content/docs/dev/advanced/redis.mdx +++ b/apps/web/content/docs/dev/advanced/redis.mdx @@ -75,11 +75,11 @@ See [Cache API data](/docs/dev/cache/api) for invalidation and the full method l --- -## Verifying Redis in AdminCP +## Check Redis in the AdminCP -{/* Image prompt: VitNode AdminCP System -> Diagnostics screen at /admin/core/advanced/debug. System health card shows Redis status "Connected" with latency graph, memory usage, and ping metrics. Dark theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/cache with the "Cache" section open. Engine "Redis" with a green "Redis connected" status, "Realtime across instances: Yes", and a "Clear cache" button. Dark theme, 1440x900. */} -Check Redis connection health anytime under **Core → Advanced → Debug** (`/admin/core/advanced/debug`). +Open **Debug Panel → Cache** in the AdminCP (`/admin/core/debug/cache`). It shows whether Redis is connected, unreachable or not configured (then the cache lives in memory), and whether realtime events reach every instance. An unreachable Redis also shows up on the Debug Panel's **Overview**. ## Learn More diff --git a/apps/web/content/docs/dev/ai/admin.mdx b/apps/web/content/docs/dev/ai/admin.mdx index 69327f44d..bcc653c7e 100644 --- a/apps/web/content/docs/dev/ai/admin.mdx +++ b/apps/web/content/docs/dev/ai/admin.mdx @@ -17,7 +17,7 @@ The AdminCP **Artificial Intelligence (AI)** section (`/admin/core/ai`) is where | Overview | `/admin/core/ai` | Spend against the budget, a forecast, trends and breakdowns. | | Settings sheet | `/admin/core/ai?settings=open` | Global switch, budgets, rate limits, retention, ALT text. | | Actions | `/admin/core/ai/actions` | Every registered AI feature: switch, model, limits. | -| History | `/admin/core/ai/history` | Every run: who, which model, tokens and cost. | +| Debug Panel → AI logs | `/admin/core/debug/ai` | Every run: who, which model, tokens and cost. | | Roles → Artificial intelligence (AI) tab | `/admin/core/users/roles` | A role's AI permissions, monthly points and daily limits. | | User → AI card | `/admin/core/users/{id}` | One member's exception: blocked, unlimited or own points. | | AI usage | `/settings/ai` (member) | A member's own points and what each AI feature cost them. | @@ -162,9 +162,9 @@ A member with several roles gets the **largest** allowance (never the sum), the The **AI access** card on a member's AdminCP page (**Users → a member**) gives them their own rule, ahead of their roles: **Block AI features**, **Unlimited points**, or a personal monthly allowance. It replaces what their roles give until you remove it. -## History +## AI logs -**Artificial Intelligence (AI) → History** lists every run with its action, status, origin (Members or Background jobs), model, cost and points. Filter by status, origin, action or model. **Details** shows: +**Debug Panel → AI logs** (`/admin/core/debug/ai`) lists every run with its action, status, origin (Members or Background jobs), model, cost and points. Filter by status, origin, action or model. **Details** shows: - tokens in and out, cost, cost source and what was charged or reserved; - every provider call, with retries and fallbacks listed as separate calls; @@ -172,7 +172,7 @@ The **AI access** card on a member's AdminCP page (**Users → a member**) gives - whether an editor accepted the suggestion (tracking only, never billing). - History records what a run took and what it cost. It never keeps the prompt, + The AI logs record what a run took and what it cost. It never keeps the prompt, the input text or the generated text. The same is true on the member's page. diff --git a/apps/web/content/docs/dev/ai/automatic-alt.mdx b/apps/web/content/docs/dev/ai/automatic-alt.mdx index 75d8bb2d2..fc3ad39a3 100644 --- a/apps/web/content/docs/dev/ai/automatic-alt.mdx +++ b/apps/web/content/docs/dev/ai/automatic-alt.mdx @@ -202,7 +202,7 @@ curl -X POST https://example.com/api/@vitnode/core/cron \ -H "Authorization: Bearer $CRON_SECRET" ``` -Set a real `CRON_SECRET`. Each call runs every job whose schedule came due since its last run. A slower tick still runs the hourly sweep; it just works the queue less often (2 images per tick). Check **Last Run** at `/admin/core/advanced/cron`. See [Cron over REST](/docs/dev/cron/rest-api) and [Node CRON adapter](/docs/dev/cron/node-cron). +Set a real `CRON_SECRET`. Each call runs every job whose schedule came due since its last run. A slower tick still runs the hourly sweep; it just works the queue less often (2 images per tick). Check the last run in the **Time** column of **Debug Panel → Cron & queue** (`/admin/core/debug/cron`). See [Cron over REST](/docs/dev/cron/rest-api) and [Node CRON adapter](/docs/dev/cron/node-cron). ## Edit ALT text through the API diff --git a/apps/web/content/docs/dev/cron/custom-adapter.mdx b/apps/web/content/docs/dev/cron/custom-adapter.mdx index 4180939ad..ab008ba92 100644 --- a/apps/web/content/docs/dev/cron/custom-adapter.mdx +++ b/apps/web/content/docs/dev/cron/custom-adapter.mdx @@ -165,9 +165,9 @@ npm run dev -Open `/admin/core/advanced/cron` and watch the `process-queue` row - it is -scheduled `* * * * *`, so a healthy adapter keeps its **Last Run** under a minute -old. If it stays on _Never_, your `schedule()` ran but your tick is not landing: +Open **Debug Panel → Cron & queue** (`/admin/core/debug/cron`) and watch the +`process-queue` row - it is scheduled `* * * * *`, so a healthy adapter keeps its +last run under a minute old. If the **Time** column keeps saying _Last Never_, your `schedule()` ran but your tick is not landing: check the origin, then the secret, then whether the API can reach itself. diff --git a/apps/web/content/docs/dev/cron/index.mdx b/apps/web/content/docs/dev/cron/index.mdx index 5989789fa..cf3ab61e9 100644 --- a/apps/web/content/docs/dev/cron/index.mdx +++ b/apps/web/content/docs/dev/cron/index.mdx @@ -75,14 +75,14 @@ curl -X POST https://your-domain.com/api/@vitnode/core/cron -H "Authorization: --- -## AdminCP Management +## Manage cron jobs in the Debug Panel -{/* Image prompt: VitNode AdminCP Cron management page at /admin/core/advanced/cron. Data table displaying scheduled jobs with columns: Name, Schedule, Status, Last Run, Next Run, and an action button "Run Now". Dark theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/cron with the "Cron & queue" section open in the side menu. A green "All jobs on schedule" scheduler card with last run, next run and job count, and below it a cron jobs table with a search box and columns Name, Plugin and Time (schedule, "On schedule" badge, "Last … · Next …"), plus a play button per row. Dark theme, 1440x900. */} -Manage and inspect cron jobs in the AdminCP at **Core → Advanced → Cron Jobs** (`/admin/core/advanced/cron`): +Open **Debug Panel → Cron & queue** in the AdminCP (`/admin/core/debug/cron`), or type "Cron jobs" into the ⌘K admin search. Viewing it needs the `cron → can_view` staff permission. -- View status, schedules, and last execution logs. -- Click **Run Now** to execute any job manually on demand. +- The table lists every job with its **Name**, **Plugin** and **Time**: the schedule, a status badge, and the last and next run. It is sorted by next run, with a search box to find a job. +- The play button on a row (**Run Job Now**) runs that job on demand. It needs the `cron → can_run` permission. ### Health warnings @@ -90,7 +90,7 @@ Cron fails quietly by nature: nobody notices a job that didn't run. So VitNode w A job is **overdue** when its next scheduled run is more than 15 minutes in the past. Never-run jobs count from the moment they were registered. -The scheduler card at the top of the page sums it up: +The scheduler card at the top of the section sums it up: | What you see | What it means | | --- | --- | @@ -100,7 +100,7 @@ The scheduler card at the top of the page sums it up: Next to it you get the last run, the next run and how many jobs are registered. The table below lists every job with an **Overdue**, **On schedule** or **Waiting for first run** status. -The same check powers the **Cron Jobs** and **Queue Tasks** cards at **Core → System → Integrations**. +The same check powers the Debug Panel **Overview** and the **Cron Jobs** and **Queue Tasks** cards at **Core → System → Integrations**. --- diff --git a/apps/web/content/docs/dev/cron/node-cron.mdx b/apps/web/content/docs/dev/cron/node-cron.mdx index 9c8cb5e6c..a856cd039 100644 --- a/apps/web/content/docs/dev/cron/node-cron.mdx +++ b/apps/web/content/docs/dev/cron/node-cron.mdx @@ -93,13 +93,14 @@ npm run dev ### Verify the first tick -Open `/admin/core/advanced/cron` and watch the `clean` row - core's hourly -session cleanup, which every installation has. Within a minute of the restart -**Last Run** stops saying _Never_ and **Next Run** fills in. +Open **Debug Panel → Cron & queue** (`/admin/core/debug/cron`) and watch the +`clean` row - core's hourly session cleanup, which every installation has. +Within a minute of the restart its **Time** column stops saying _Last Never_ and +the next run fills in. Nothing else needs to happen for the clock to be proven: `process-queue` runs -every minute, so its **Last Run** should never be more than a minute old while -the adapter is alive. +every minute, so its last run should never be more than a minute old while the +adapter is alive. @@ -148,8 +149,8 @@ Three consequences worth writing down: `http://localhost:3000`. This repo's standalone API listens on **8000** - which is exactly why `apps/api/.env.example` sets the variable - so leaving it unset sends every tick to port 3000, where the web app lives. Symptom: the - scheduler is configured, the boot log is clean, and **Last Run** stays _Never_ - forever. + scheduler is configured, the boot log is clean, and the **Time** column keeps + saying _Last Never_ forever. @@ -158,8 +159,8 @@ Three consequences worth writing down: before the logger even exists, and a mismatched secret raises an `HTTPException` that the API's error handler returns without logging. So the failure you are most likely to hit writes nothing to the console and nothing - to the [Debug Panel](/docs/dev/debugging/logging). The AdminCP's **Last Run** - column is the health check. + to the [Debug Panel](/docs/dev/debugging/logging). The last run in the + **Time** column of **Debug Panel → Cron & queue** is the health check. diff --git a/apps/web/content/docs/dev/cron/rest-api.mdx b/apps/web/content/docs/dev/cron/rest-api.mdx index 8e4fcf386..b9860af66 100644 --- a/apps/web/content/docs/dev/cron/rest-api.mdx +++ b/apps/web/content/docs/dev/cron/rest-api.mdx @@ -111,10 +111,11 @@ daily jobs honest and a poor way to drain a queue. ### Verify it from the AdminCP Send the request by hand once. A `200` with a job count means the endpoint -accepted you; open `/admin/core/advanced/cron` and the rows you expected to run -now show a **Last Run** of a moment ago. +accepted you; open **Debug Panel → Cron & queue** (`/admin/core/debug/cron`) +and the rows you expected to run now show a last run of a moment ago in the +**Time** column. -Then leave the scheduler to it and check back. **Last Run** drifting behind a +Then leave the scheduler to it and check back. A last run drifting behind a job's schedule is the symptom of a tick that is not arriving, and it is the only signal that reflects an external scheduler - see the gotcha about the Integrations screen below. @@ -169,8 +170,8 @@ Integrations screen below. registers a `cron` adapter - it is reading the config, not counting ticks. Tick from outside and that card stays grey however healthy the schedule is, and the queue card with it, because the queue's status is derived from cron's. - The honest signal for an external scheduler is the **Last Run** column on - `/admin/core/advanced/cron`. + The honest signal for an external scheduler is the last run in the **Time** + column of **Debug Panel → Cron & queue** (`/admin/core/debug/cron`). A generated route emits one lifecycle event per successful mutation, after the diff --git a/apps/web/content/docs/dev/i18n/localized-urls.mdx b/apps/web/content/docs/dev/i18n/localized-urls.mdx index 5102fc649..288b12ba0 100644 --- a/apps/web/content/docs/dev/i18n/localized-urls.mdx +++ b/apps/web/content/docs/dev/i18n/localized-urls.mdx @@ -361,8 +361,9 @@ Details live in [Content Delivery and SEO](/docs/content-engine/public-pages) an Search stores each result's URL when it indexes it. After you change - `routePaths`, `domains` or `localePrefix`, rebuild the index in **Core → - Advanced → Search** so old results pick up the new URLs. + `routePaths`, `domains` or `localePrefix`, rebuild the index in **Debug + Panel → Search** (`/admin/core/debug/search`) so old results pick up the + new URLs. ## Validation errors diff --git a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx index d613c7e17..f5ac35946 100644 --- a/apps/web/content/docs/dev/notifications/queue-and-scale.mdx +++ b/apps/web/content/docs/dev/notifications/queue-and-scale.mdx @@ -8,7 +8,7 @@ Notifications run on VitNode's database-backed [queue](/docs/dev/advanced/queue) ## Queue tasks and cron jobs -All of these belong to `@vitnode/core` and appear in **AdminCP → Advanced → Queue**. +All of these belong to `@vitnode/core`. Tasks appear in **Debug Panel → Queue** (`/admin/core/debug/queue`) and cron jobs in **Debug Panel → Cron & queue** (`/admin/core/debug/cron`). | Name | Kind | Does | | ------------------------ | --------------------- | ----------------------------------------------------------------------- | diff --git a/apps/web/content/docs/dev/notifications/troubleshooting.mdx b/apps/web/content/docs/dev/notifications/troubleshooting.mdx index 450ea6c2a..ae7200a61 100644 --- a/apps/web/content/docs/dev/notifications/troubleshooting.mdx +++ b/apps/web/content/docs/dev/notifications/troubleshooting.mdx @@ -17,7 +17,7 @@ A publishing request starts delivery right after its response. Everything else i 1. **The cron adapter is not calling the queue worker.** In development there is no `CRON_SECRET`, so queued tasks wait until you run them. See [Cron](/docs/dev/cron). 2. **Notifications are paused.** The AdminCP notifications screen shows a **Notifications are paused** banner. See [Use the Settings sheet](/docs/dev/notifications/admincp#use-the-settings-sheet). -3. **The delivery task failed.** **AdminCP → Advanced → Queue** lists failed `notifications-fanout` tasks with the error. +3. **The delivery task failed.** **Debug Panel → Queue** in the AdminCP (`/admin/core/debug/queue`) lists failed `notifications-fanout` tasks with their last error. Filter by the **Failed** status to find them. 4. **The member never gets it.** The actor is skipped unless you pass `allowSelf: true`, and members who turned the type off or fail your `access` check get nothing. See [Choose the recipients](/docs/dev/notifications/publishing#choose-the-recipients). ## Emails are not sending diff --git a/apps/web/content/docs/dev/plugins/catalog.mdx b/apps/web/content/docs/dev/plugins/catalog.mdx index dcf775a53..5cf500f4b 100644 --- a/apps/web/content/docs/dev/plugins/catalog.mdx +++ b/apps/web/content/docs/dev/plugins/catalog.mdx @@ -16,7 +16,7 @@ import catalog from './admin-plugins-catalog.webp' withoutBackground /> -The catalog reports what is installed. It also shows available updates and security advisories for core and for plugins that declare an update source - see [Update checks](/docs/dev/updates). It never installs or removes plugins, or switches them on and off. +The catalog reports what is installed. It also shows available updates and security updates for core and for plugins that declare an update source - see [Update checks](/docs/dev/updates). It never installs or removes plugins, or switches them on and off. ## Add your plugin to the catalog @@ -157,7 +157,7 @@ A frontend inventory entry carrying the manifest of another package throws at st ## Update and security information -Add `vitnode.updates.url` to your plugin's `package.json` and the catalog row also shows new releases and security advisories. Setup is covered in [Publish updates for your plugin](/docs/dev/updates/plugin-authors); what administrators see is in [Check for updates in the AdminCP](/docs/dev/updates/admin). +Add `vitnode.updates.url` to your plugin's `package.json` and the catalog row also shows new releases and security updates. Setup is covered in [Publish updates for your plugin](/docs/dev/updates/plugin-authors); what administrators see is in [Check for updates in the AdminCP](/docs/dev/updates/admin). ## Who can see the catalog diff --git a/apps/web/content/docs/dev/search-elasticsearch.mdx b/apps/web/content/docs/dev/search-elasticsearch.mdx index 7df73dc9d..33a25ee3e 100644 --- a/apps/web/content/docs/dev/search-elasticsearch.mdx +++ b/apps/web/content/docs/dev/search-elasticsearch.mdx @@ -68,13 +68,13 @@ also accepts `username` and `password` for a self-hosted secured cluster. ### Rebuild the mirror -Restart the API, then open **Core → Advanced → Search** in the AdminCP and run -**Rebuild index**. New changes are mirrored automatically; the rebuild fills +Restart the API, then open **Debug Panel → Search** in the AdminCP +(`/admin/core/debug/search`) and run **Rebuild index**. New changes are mirrored automatically; the rebuild fills the historical records already in Postgres. Rebuilding (and clearing a collection) needs the **Rebuild and clear the search index** permission -(`system:can_manage_search`) - viewing the page alone is not enough. +(`system:can_manage_search`) - viewing the section alone is not enough. -{/* Image prompt: VitNode AdminCP search page showing Elasticsearch as connected, one “Rebuild index” button, a progress state, and collection counts. Dark theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/search showing the Search section with Elasticsearch as the engine, a “Rebuild index” button in the header, and collection counts. Dark theme, 1440x900. */} diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index a20b72d5e..a145d4fe6 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -218,14 +218,18 @@ A document you index yourself with `c.get('search').index()` keeps its `url` exa --- -## AdminCP Search Management +## Manage the search index in the Debug Panel -{/* Image prompt: VitNode AdminCP Search settings screen at /admin/core/advanced/search. Dashboard displaying search index statistics, engine status (PostgreSQL / Elasticsearch), registered item types with indexed document counts, and a "Rebuild Index" button. Dark theme, 1440x900. */} +{/* Image prompt: VitNode AdminCP Debug Panel at /admin/core/debug/search with the "Search" section open in the side menu. Header with a "Rebuild index" button, a row of stat cards (status, engine PostgreSQL, indexed items, last indexed), and a collections table with a search box, indexed counts and a "Reindex" button per row. Dark theme, 1440x900. */} -Manage search status at **Core → Advanced → Search** (`/admin/core/advanced/search`): +Open **Debug Panel → Search** in the AdminCP (`/admin/core/debug/search`), or type "Search index" into the ⌘K admin search: -- View indexed record counts across all collections. -- Trigger background rebuilds of specific collections or the entire site. +- The stat cards show the index status, the engine, how many items are indexed and when the last one was indexed. Recent sync errors appear below them. +- The collections table lists indexed counts per collection, with a search box to find one. +- **Reindex** rebuilds one collection, and **Rebuild index** in the header rebuilds all of them. +- **Remove documents** shows up instead of **Reindex** on a collection with no rebuild indexer. It clears that collection's documents, and live plugin writes may add them back. + +Opening the section needs `system → can_view`. The rebuild, reindex and remove buttons need `system → can_manage_search`. A rebuild is a queued job, so it only runs once [cron](/docs/dev/cron) is running. Until then it waits in the queue, patient but unproductive. diff --git a/apps/web/content/docs/dev/updates/admin.mdx b/apps/web/content/docs/dev/updates/admin.mdx index c5f200afd..9561318bb 100644 --- a/apps/web/content/docs/dev/updates/admin.mdx +++ b/apps/web/content/docs/dev/updates/admin.mdx @@ -1,6 +1,6 @@ --- title: Check for updates in the AdminCP -description: Where VitNode shows available updates and security advisories, how the daily check and the Check for updates button work, and what each status means. +description: Where VitNode shows available updates and security updates, how the daily check and the Check for updates button work, and what each status means. icon: ShieldCheck --- @@ -8,15 +8,15 @@ import { ImgDocs } from '@/components/fumadocs/img' import pluginUpdates from './admin-plugin-updates.webp' -**AdminCP → System → Plugins** shows, next to every installed version, whether a newer release is published and whether a security advisory affects it. Core gets its own card above the plugin list. +**AdminCP → System → Plugins** shows, next to every installed version, whether a newer release is published and whether a security update affects it. Core gets its own card above the plugin list. -The advisory in the screenshot is illustrative demo data. +The security update in the screenshot is illustrative demo data. ## Before you begin @@ -57,9 +57,9 @@ When the web app and the API run different versions of a plugin, the existing mi ## Security warnings -A red **Security advisory** box with a shield icon appears when a version you run is inside an advisory's affected range. It names the advisory, its severity, the affected version - with _(API)_ or _(web app)_ when they differ - and either _Fixed in 2.1.3_ or _No fixed version has been published yet_. **Advisory details** opens the publisher's page. +A red **Security update** box with a shield icon appears when a version you run is inside a security update's affected range. It names the security update, the affected version - with _(API)_ or _(web app)_ when they differ - and either _Fixed in 2.1.3_ or _No fixed version has been published yet_. **Details** opens the publisher's page. -Advisories are checked even when your channel has no newer release. A patch release on its own is never shown as a security fix; only a matching advisory is. +Security updates are checked even when your channel has no newer release. A patch release on its own is never shown as a security fix; only a matching security update is. ## Choose a channel @@ -78,4 +78,4 @@ An install without outbound network access can switch checks off with `updates: ## Check the result -Open **System → Plugins** and press **Check for updates**. A toast reports how many updates and advisories were found, and the core card shows _Last checked_ with the current time. +Open **System → Plugins** and press **Check for updates**. A toast reports how many updates and security updates were found, and the core card shows _Last checked_ with the current time. diff --git a/apps/web/content/docs/dev/updates/index.mdx b/apps/web/content/docs/dev/updates/index.mdx index 4e6a1ec7b..89b577afe 100644 --- a/apps/web/content/docs/dev/updates/index.mdx +++ b/apps/web/content/docs/dev/updates/index.mdx @@ -1,10 +1,10 @@ --- title: Update checks -description: How VitNode tells administrators about new versions and security advisories for core and plugins, using public JSON manifests checked once a day. +description: How VitNode tells administrators about new versions and security updates for core and plugins, using public JSON manifests checked once a day. icon: RefreshCw --- -VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security advisory. The results appear in **AdminCP → System → Plugins**. +VitNode checks once a day whether a newer version of core or any installed plugin is published, and whether the version you run is affected by a published security update. The results appear in **AdminCP → System → Plugins**. Each package says where its **update manifest** lives - a public JSON file on GitHub Raw or any other HTTPS host. VitNode reads those files from the server. It does not ask npm, and it does not install, download or change anything. @@ -15,11 +15,11 @@ Each package says where its **update manifest** lives - a public JSON file on Gi | See updates and security warnings, run a check, choose a channel | [Check for updates in the AdminCP](/docs/dev/updates/admin) | | Make your own plugin report updates | [Publish updates for your plugin](/docs/dev/updates/plugin-authors) | | Look up every field of the JSON format | [Update manifest reference](/docs/dev/updates/manifest) | -| Warn installations about a vulnerability | [Publish a security advisory](/docs/dev/updates/security-advisories) | +| Warn installations about a vulnerability | [Publish a security update](/docs/dev/updates/security-updates) | | Understand how VitNode's own release workflow updates the official feed | [Official release feed](/docs/dev/updates/official-releases) | ## What the checks can and cannot tell you - **Informational only.** A check never installs an update or edits your dependencies. You upgrade the way you always do, then rebuild and redeploy. - **Only what publishers declare.** A package without an update source shows _Update checking unavailable_. Private and local plugins work exactly as before. -- **Not a vulnerability scanner.** A security warning means a publisher listed your installed version in an advisory. No warning is not proof a version is secure, and dependencies of your plugins are not checked at all. Keep using `pnpm audit` or your usual scanner for those. +- **Not a vulnerability scanner.** A security warning means a publisher listed your installed version in a security update. No warning is not proof a version is secure, and dependencies of your plugins are not checked at all. Keep using `pnpm audit` or your usual scanner for those. diff --git a/apps/web/content/docs/dev/updates/manifest.mdx b/apps/web/content/docs/dev/updates/manifest.mdx index e32ae24c6..23e0d9b7e 100644 --- a/apps/web/content/docs/dev/updates/manifest.mdx +++ b/apps/web/content/docs/dev/updates/manifest.mdx @@ -1,6 +1,6 @@ --- title: Update manifest reference -description: Every field of the VitNode update manifest JSON - packages, channels, releases and advisories - with validation rules and how versions are compared. +description: Every field of the VitNode update manifest JSON - packages, channels, releases and security updates - with validation rules and how versions are compared. icon: FileCode --- @@ -25,14 +25,12 @@ An update manifest is one public JSON file per publisher. It can describe any nu }, "2.2.0-canary.4": {} }, - "advisories": [ + "securityUpdates": [ { "id": "NOTES-2026-001", - "severity": "high", "affected": ">=2.0.0 <2.1.3", "fixed": "2.1.3", - "url": "https://github.com/acme/site-notes/security/advisories/GHSA-xxxx-xxxx-xxxx", - "summary": "Stored XSS in note titles." + "url": "https://github.com/acme/site-notes/security/advisories/GHSA-xxxx-xxxx-xxxx" } ] } @@ -55,7 +53,7 @@ The values above are illustrative, not a real release or vulnerability. | ------------ | ------ | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `channels` | object | No | `{}` | `stable`, `rc` and/or `canary`, each a version. Every one must have an entry in `releases`. `stable` cannot be a prerelease. A package does not have to publish every channel. | | `releases` | object | No | `{}` | Keyed by exact SemVer version. At most 2000. | -| `advisories` | array | No | `[]` | Independent of channels and releases. At most 500, with unique `id`s. | +| `securityUpdates` | array | No | `[]` | Independent of channels and releases. At most 500, with unique `id`s. | ## Release @@ -65,22 +63,20 @@ The values above are illustrative, not a real release or vulnerability. | `requiresCore` | SemVer range | No | The `@vitnode/core` versions this release supports. Missing means compatibility is unknown. | | `url` | `https://` URL | No | Shown as **Release notes**. | -## Advisory +## Security update -| Name | Type | Required | Description | -| ---------- | ------------------------------------------- | -------- | ------------------------------------------------------------ | -| `id` | string | Yes | Letters, digits, `.`, `_`, `:` or `-`, up to 100 characters. | -| `severity` | `low` \| `moderate` \| `high` \| `critical` | Yes | Shown in the warning title. | -| `affected` | SemVer range | Yes | Installed versions inside this range get the red warning. | -| `fixed` | SemVer version | No | Leave it out when no fix exists yet. | -| `url` | `https://` URL | Yes | Shown as **Advisory details**. | -| `summary` | string, up to 500 characters | No | Shown as plain text under the warning. | +| Name | Type | Required | Description | +| ---------- | -------------- | -------- | ------------------------------------------------------------ | +| `id` | string | Yes | Letters, digits, `.`, `_`, `:` or `-`, up to 100 characters. | +| `affected` | SemVer range | Yes | Installed versions inside this range get the red warning. | +| `fixed` | SemVer version | No | Leave it out when no fix exists yet. | +| `url` | `https://` URL | Yes | Shown as **Details**. | -Text and links are treated as untrusted: text is rendered as text, and only `https://` links become clickable. +Every security update is a security update - there are no severity levels. Explain the issue on the security update page; links are treated as untrusted, and only `https://` links become clickable. ## How versions are compared - Comparisons use SemVer, not string order: `2.10.0` is newer than `2.9.0`, and `2.1.0-canary.4` is older than `2.1.0`. - The selected channel is the installation's `updates.channel`, or else the channel of the installed version. An installed version newer than the channel's target is never offered a "downgrade". - `requiresCore` is checked against the running core version. When the channel's newest release needs a newer core, VitNode names the newest release on the same channel that is newer than yours and explicitly declares support for your core, if one is listed. -- Advisory ranges include prereleases: see [how ranges match prereleases](/docs/dev/updates/security-advisories#how-affected-ranges-match-prereleases). +- Security update ranges include prereleases: see [how ranges match prereleases](/docs/dev/updates/security-updates#how-affected-ranges-match-prereleases). diff --git a/apps/web/content/docs/dev/updates/meta.json b/apps/web/content/docs/dev/updates/meta.json index 1b95e09a0..122df555f 100644 --- a/apps/web/content/docs/dev/updates/meta.json +++ b/apps/web/content/docs/dev/updates/meta.json @@ -1,6 +1,6 @@ { "title": "Updates & Security", - "description": "Daily update checks and security advisories for VitNode core and plugins, read from public JSON manifests", + "description": "Daily update checks and security updates for VitNode core and plugins, read from public JSON manifests", "icon": "RefreshCw", "pages": [ "index", @@ -8,7 +8,7 @@ "---Publish updates---", "plugin-authors", "manifest", - "security-advisories", + "security-updates", "official-releases" ] } diff --git a/apps/web/content/docs/dev/updates/official-releases.mdx b/apps/web/content/docs/dev/updates/official-releases.mdx index f028a127c..ba5f6006e 100644 --- a/apps/web/content/docs/dev/updates/official-releases.mdx +++ b/apps/web/content/docs/dev/updates/official-releases.mdx @@ -1,6 +1,6 @@ --- title: Official release feed -description: How VitNode's release workflow publishes the official update manifest after npm publication, where publishedAt comes from, and how reruns and advisories are handled. +description: How VitNode's release workflow publishes the official update manifest after npm publication, where publishedAt comes from, and how reruns and security updates are handled. icon: Workflow --- @@ -27,18 +27,17 @@ The **Bump and Publish npm packages** workflow has a **Publish update manifest** - **If npm publishing fails**, the job does not run. Fix the cause and re-run the workflow in `publish` mode. - **If only the manifest job fails**, use **Re-run failed jobs**: it publishes the same commit again. -## Add an advisory to the official feed +## Add a security update to the official feed -Advisories are not part of releases. Edit `security/advisories.json` in a pull request: +Security updates are not part of releases. Edit `security/security-updates.json` in a pull request: -```json title="security/advisories.json" +```json title="security/security-updates.json" { "schemaVersion": 1, - "advisories": { + "securityUpdates": { "@vitnode/blog": [ { "id": "VITNODE-BLOG-2026-001", - "severity": "high", "affected": ">=2.0.0 <2.1.3", "fixed": "2.1.3", "url": "https://github.com/aXenDeveloper/vitnode/security/advisories/GHSA-xxxx-xxxx-xxxx" @@ -48,8 +47,8 @@ Advisories are not part of releases. Edit `security/advisories.json` in a pull r } ``` -The **Publish security advisories** workflow validates the file on the pull request. After it merges into `canary`, the workflow replaces the advisories in `updates.json` with exactly what the file lists; releases and channels stay untouched. The example above is illustrative - only real advisories belong in that file. Writing ranges is covered in [Publish a security advisory](/docs/dev/updates/security-advisories). +The **Publish security updates** workflow validates the file on the pull request. After it merges into `canary`, the workflow replaces the security updates in `updates.json` with exactly what the file lists; releases and channels stay untouched. The example above is illustrative - only real security updates belong in that file. Writing ranges is covered in [Publish a security update](/docs/dev/updates/security-updates). ## Check the result -After a release or an advisory change, open the feed URL above. Within a few minutes of GitHub's cache, it shows the new version under `channels` and its entry under `releases`. +After a release or a security update change, open the feed URL above. Within a few minutes of GitHub's cache, it shows the new version under `channels` and its entry under `releases`. diff --git a/apps/web/content/docs/dev/updates/plugin-authors.mdx b/apps/web/content/docs/dev/updates/plugin-authors.mdx index bfac59f57..199b117ab 100644 --- a/apps/web/content/docs/dev/updates/plugin-authors.mdx +++ b/apps/web/content/docs/dev/updates/plugin-authors.mdx @@ -1,10 +1,10 @@ --- title: Publish updates for your plugin -description: Declare an update source in your plugin's package.json and host a small JSON manifest so VitNode installations see your new releases and security advisories. +description: Declare an update source in your plugin's package.json and host a small JSON manifest so VitNode installations see your new releases and security updates. icon: Upload --- -Add one field to your plugin's `package.json` and publish a JSON file next to your code. Every installation of your plugin then shows your new releases and advisories in **AdminCP → System → Plugins**. +Add one field to your plugin's `package.json` and publish a JSON file next to your code. Every installation of your plugin then shows your new releases and security updates in **AdminCP → System → Plugins**. diff --git a/apps/web/content/docs/dev/updates/security-advisories.mdx b/apps/web/content/docs/dev/updates/security-advisories.mdx deleted file mode 100644 index 69f844c7b..000000000 --- a/apps/web/content/docs/dev/updates/security-advisories.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Publish a security advisory -description: Add a security advisory to an update manifest so VitNode installations running an affected version see a red warning in the AdminCP, with or without a fixed version. -icon: ShieldAlert ---- - -A security advisory in your update manifest turns every affected installation's catalog row red within a day - or right away when an administrator presses **Check for updates**. VitNode compares each installed version, API and web app separately, with the advisory's `affected` range. - -## Add an advisory - -Add an entry to the package's `advisories` array: - -```json title="updates.json" -{ - "id": "NOTES-2026-001", - "severity": "high", - "affected": ">=2.0.0 <2.1.3", - "fixed": "2.1.3", - "url": "https://github.com/acme/site-notes/security/advisories/GHSA-xxxx-xxxx-xxxx", - "summary": "Stored XSS in note titles." -} -``` - -Write `id`, `severity`, `affected` and `url` every time. Add `fixed` once a release contains the fix; installations then read _Fixed in 2.1.3_. The example is illustrative, not a real vulnerability. - -Advisories do not depend on channels or releases. An installation is warned even when its channel has no newer release, and even when you no longer publish that channel at all. - -## Announce a vulnerability without a fix - -Publish the advisory without `fixed`. Affected installations see _No fixed version has been published yet_. Use `summary` and the advisory page for mitigation steps, such as disabling a feature. When the fix ships, add `fixed` - and widen or narrow `affected` if the release that fixes it changes the picture. - -## How affected ranges match prereleases - -Ranges are matched with prereleases included, by SemVer order: - -| Installed | `affected: ">=2.0.0 <2.1.3"` | Why | -| ---------------- | ---------------------------- | --------------------------------------------------- | -| `2.1.0` | affected | Inside the range. | -| `2.1.0-canary.4` | affected | `2.1.0-canary.4` sorts between `2.0.0` and `2.1.3`. | -| `2.1.3-canary.1` | affected | A prerelease of `2.1.3` sorts before `2.1.3`. | -| `2.1.3` | not affected | The upper bound is exclusive. | -| `2.0.0-rc.1` | not affected | Prereleases of `2.0.0` sort before `2.0.0`. | - -If the fix already landed in `2.1.3-canary.2`, say so in the range: `">=2.0.0 <2.1.3-canary.2"`. When in doubt, keep a prerelease inside the range - a false warning is cheaper than a missed one. - -## What a warning does not mean - -A warning means the publisher listed that version. No warning is not proof that a version is secure, and VitNode does not scan your plugins' own dependencies. A patch release without an advisory is not shown as a security release. - -For VitNode's own packages, advisories are maintained in `security/advisories.json` in the VitNode repository; see [Official release feed](/docs/dev/updates/official-releases#add-an-advisory-to-the-official-feed). diff --git a/apps/web/content/docs/dev/updates/security-updates.mdx b/apps/web/content/docs/dev/updates/security-updates.mdx new file mode 100644 index 000000000..cd28e6a83 --- /dev/null +++ b/apps/web/content/docs/dev/updates/security-updates.mdx @@ -0,0 +1,48 @@ +--- +title: Publish a security update +description: Add a security update to an update manifest so VitNode installations running an affected version see a red warning in the AdminCP, with or without a fixed version. +icon: ShieldAlert +--- + +A security update in your update manifest turns every affected installation's catalog row red within a day - or right away when an administrator presses **Check for updates**. VitNode compares each installed version, API and web app separately, with the security update's `affected` range. + +## Add a security update + +Add an entry to the package's `securityUpdates` array: + +```json title="updates.json" +{ + "id": "NOTES-2026-001", + "affected": ">=2.0.0 <2.1.3", + "fixed": "2.1.3", + "url": "https://github.com/acme/site-notes/security/advisories/GHSA-xxxx-xxxx-xxxx" +} +``` + +Write `id`, `affected` and `url` every time. There are no severity levels or summaries: a security update is a security update, and the details live on the page `url` points to. Add `fixed` once a release contains the fix; installations then read _Fixed in 2.1.3_. The example is illustrative, not a real vulnerability. + +Security updates do not depend on channels or releases. An installation is warned even when its channel has no newer release, and even when you no longer publish that channel at all. + +## Announce a vulnerability without a fix + +Publish the security update without `fixed`. Affected installations see _No fixed version has been published yet_. Put mitigation steps on the security update page, such as disabling a feature. When the fix ships, add `fixed` - and widen or narrow `affected` if the release that fixes it changes the picture. + +## How affected ranges match prereleases + +Ranges are matched with prereleases included, by SemVer order: + +| Installed | `affected: ">=2.0.0 <2.1.3"` | Why | +| ---------------- | ---------------------------- | --------------------------------------------------- | +| `2.1.0` | affected | Inside the range. | +| `2.1.0-canary.4` | affected | `2.1.0-canary.4` sorts between `2.0.0` and `2.1.3`. | +| `2.1.3-canary.1` | affected | A prerelease of `2.1.3` sorts before `2.1.3`. | +| `2.1.3` | not affected | The upper bound is exclusive. | +| `2.0.0-rc.1` | not affected | Prereleases of `2.0.0` sort before `2.0.0`. | + +If the fix already landed in `2.1.3-canary.2`, say so in the range: `">=2.0.0 <2.1.3-canary.2"`. When in doubt, keep a prerelease inside the range - a false warning is cheaper than a missed one. + +## What a warning does not mean + +A warning means the publisher listed that version. No warning is not proof that a version is secure, and VitNode does not scan your plugins' own dependencies. A patch release without a security update is not shown as a security release. + +For VitNode's own packages, security updates are maintained in `security/security-updates.json` in the VitNode repository; see [Official release feed](/docs/dev/updates/official-releases#add-an-security update-to-the-official-feed). diff --git a/apps/web/content/docs/guides/forum.mdx b/apps/web/content/docs/guides/forum.mdx index 89323bd04..319c55319 100644 --- a/apps/web/content/docs/guides/forum.mdx +++ b/apps/web/content/docs/guides/forum.mdx @@ -127,7 +127,7 @@ on their authors' profile timelines. A comment is headed by its topic's title and stays out of public results while its topic is not public - see [Borrow the title from a related record](/docs/content-engine/search#borrow-the-title-from-a-related-record). Upgrading a forum that already has posts? Rebuild the index once in -**AdminCP → Advanced → Search**. +**Debug Panel → Search** (`/admin/core/debug/search`). ## Choose who publishes straight away diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index d7b8f4479..08f5bed40 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -64,7 +64,6 @@ "admin": { "advanced": { "cron": { - "desc": "Zarządzaj i monitoruj zaplanowane zadania.", "health": { "facts": { "jobs": "Zadania", @@ -97,28 +96,27 @@ } }, "lastRun": { - "never": "Nigdy", - "title": "Ostatnie uruchomienie" + "never": "Nigdy" }, "name": "Nazwa", "nextRun": { - "never": "Nigdy", - "title": "Następne uruchomienie" + "never": "Nigdy" }, "plugin": "Wtyczka", - "schedule": "Harmonogram", "search": "Szukaj zadań…", "status": { "on_schedule": "Zgodnie z harmonogramem", "overdue": "Zaległe", - "title": "Status", "waiting": "Czeka na pierwsze uruchomienie" + }, + "time": { + "title": "Czas", + "last": "Ostatnio ", + "next": "Następnie " } - }, - "title": "Zadania cron" + } }, "queue": { - "desc": "Monitoruj zadania w tle oczekujące na przetworzenie przez proces cron.", "list": { "attempts": "Próby", "availableAt": "Dostępne od", @@ -134,8 +132,7 @@ "failed": "Nieudane", "pending": "Oczekujące", "processing": "Przetwarzanie" - }, - "title": "Zadania w kolejce" + } } }, "dashboard": { @@ -227,6 +224,141 @@ } }, "debug": { + "title": "Panel debugowania", + "desc": "Sprawdź stan instalacji, jej logi i pracę w tle.", + "unavailable": "Nie udało się wczytać tej sekcji. Spróbuj ponownie za chwilę.", + "nav": { + "label": "Sekcje panelu debugowania", + "menu": "Sekcja", + "systems": "Systemy", + "diagnostics": "Diagnostyka", + "overview": "Przegląd", + "vitnode": "VitNode", + "plugins": "Wtyczki", + "cron": "Cron i kolejka", + "cache": "Pamięć podręczna", + "search": "Wyszukiwanie", + "services": "Usługi", + "logs": "Przeglądarka logów", + "queue": "Kolejka", + "ai": "Logi AI" + }, + "tone": { + "critical": "Krytyczne", + "warn": "Wymaga uwagi", + "ok": "Sprawne", + "off": "Wyłączone" + }, + "overview": { + "attention": "{count, plural, one {# rzecz wymaga uwagi} few {# rzeczy wymagają uwagi} many {# rzeczy wymaga uwagi} other {# rzeczy wymaga uwagi}}", + "healthy": "Wszystko działa poprawnie", + "summary": "{critical, plural, =0 {Brak problemów krytycznych} one {# problem krytyczny} few {# problemy krytyczne} many {# problemów krytycznych} other {# problemów krytycznych}} · {warnings, plural, =0 {brak ostrzeżeń} one {# ostrzeżenie} few {# ostrzeżenia} many {# ostrzeżeń} other {# ostrzeżeń}}", + "recheck": "Sprawdź ponownie", + "systems": { + "title": "Systemy", + "desc": "Wszystko, co VitNode sprawdził w tej instalacji." + } + }, + "diagnostics": { + "logs": "Każde ostrzeżenie i błąd zapisane przez API, od najnowszych.", + "queue": "Zadania w tle i to, co się z nimi stało.", + "ai": "Każde zapytanie do AI z modelem, statusem i kosztem." + }, + "security": { + "alert": { + "title": "Dostępna aktualizacja bezpieczeństwa: VitNode {version}", + "desc": "Problem dotyczy VitNode {version}. Zaktualizuj jak najszybciej.", + "review": "Zobacz aktualizację" + }, + "panel": { + "title": "Dostępna aktualizacja bezpieczeństwa", + "desc": "Problem dotyczy VitNode {version}. Zaktualizuj do {fixed} jak najszybciej.", + "desc_no_fix": "Problem dotyczy VitNode {version}. Poprawiona wersja nie została jeszcze opublikowana.", + "details": "Szczegóły", + "version": "Wersja", + "released": "wydano ", + "release_notes": "Informacje o wydaniu" + } + }, + "sections": { + "vitnode": { + "version": "Wersja", + "channel": "Kanał aktualizacji", + "auto": "Automatyczny", + "last_checked": "Ostatnie sprawdzenie aktualizacji", + "never": "Nigdy" + }, + "cache": { + "engine": "Silnik", + "redis": "Redis", + "memory": "W pamięci", + "cross_instance": "Czas rzeczywisty między instancjami", + "yes": "Tak", + "no": "Nie" + }, + "services": { + "active": "Aktywna", + "not_configured": "Nieskonfigurowana", + "set_up": "Skonfiguruj" + } + }, + "health": { + "vitnode": { + "subtitle": "Wersja {version}", + "security_update": "Aktualizacja bezpieczeństwa: VitNode {version}", + "update_available": "Dostępny VitNode {version}", + "source_unreachable": "Nie udało się odczytać źródła aktualizacji", + "not_checked": "Aktualizacje nie zostały jeszcze sprawdzone", + "up_to_date": "Działa najnowsza wersja ({version})" + }, + "plugins": { + "subtitle": "{count, plural, one {# zainstalowana wtyczka} few {# zainstalowane wtyczki} many {# zainstalowanych wtyczek} other {# zainstalowanych wtyczek}}", + "ok": "{count, plural, one {Wtyczka jest aktualna} few {Wszystkie # wtyczki są aktualne} many {Wszystkie # wtyczek jest aktualnych} other {Wszystkie # wtyczek jest aktualnych}}", + "problem": { + "security_update": "Aktualizacja bezpieczeństwa", + "version_mismatch": "Wersje API i web się różnią", + "update_available": "Aktualizacja do {version}", + "source_unreachable": "Źródło aktualizacji niedostępne" + }, + "summary": { + "security_update": "{count, plural, one {# wtyczka wymaga aktualizacji bezpieczeństwa} few {# wtyczki wymagają aktualizacji bezpieczeństwa} many {# wtyczek wymaga aktualizacji bezpieczeństwa} other {# wtyczek wymaga aktualizacji bezpieczeństwa}}", + "version_mismatch": "{count, plural, one {# wtyczka ma różne wersje API i web} few {# wtyczki mają różne wersje API i web} many {# wtyczek ma różne wersje API i web} other {# wtyczek ma różne wersje API i web}}", + "update_available": "{count, plural, one {Dostępna # aktualizacja wtyczki} few {Dostępne # aktualizacje wtyczek} many {Dostępnych # aktualizacji wtyczek} other {Dostępnych # aktualizacji wtyczek}}", + "source_unreachable": "{count, plural, one {# źródło aktualizacji wtyczki jest niedostępne} few {# źródła aktualizacji wtyczek są niedostępne} many {# źródeł aktualizacji wtyczek jest niedostępnych} other {# źródeł aktualizacji wtyczek jest niedostępnych}}" + } + }, + "cron": { + "subtitle_adapter": "Działa w procesie", + "subtitle_external": "Uruchamiany z zewnątrz", + "insecure_secret": "CRON_SECRET nie jest ustawiony lub ma wartość domyślną, więc uruchomienia cron są odrzucane", + "stopped": "Harmonogram przestał działać", + "overdue": "{count, plural, one {# zadanie cron jest opóźnione} few {# zadania cron są opóźnione} many {# zadań cron jest opóźnionych} other {# zadań cron jest opóźnionych}}", + "failed_tasks": "{count, plural, one {# zadanie w kolejce zakończyło się błędem} few {# zadania w kolejce zakończyły się błędem} many {# zadań w kolejce zakończyło się błędem} other {# zadań w kolejce zakończyło się błędem}}", + "ok": "{count, plural, one {# zadanie cron zgodnie z harmonogramem} few {# zadania cron zgodnie z harmonogramem} many {# zadań cron zgodnie z harmonogramem} other {# zadań cron zgodnie z harmonogramem}}" + }, + "cache": { + "subtitle_redis": "Redis", + "subtitle_memory": "W pamięci", + "redis_down": "Redis jest niedostępny", + "redis_connected": "Połączono z Redis", + "memory": "Redis nie jest skonfigurowany, więc pamięć podręczna działa w pamięci" + }, + "search": { + "subtitle": "Silnik: {engine}", + "engine_down": "Silnik wyszukiwania {engine} nie odpowiada", + "incomplete": "{count, plural, one {# kolekcja jest niekompletna} few {# kolekcje są niekompletne} many {# kolekcji jest niekompletnych} other {# kolekcji jest niekompletnych}}", + "sync_errors": "{count, plural, one {# niedawny błąd synchronizacji} few {# niedawne błędy synchronizacji} many {# niedawnych błędów synchronizacji} other {# niedawnych błędów synchronizacji}}", + "ok": "{count, plural, one {# zaindeksowany element} few {# zaindeksowane elementy} many {# zaindeksowanych elementów} other {# zaindeksowanych elementów}}" + }, + "services": { + "subtitle": "Skonfigurowano {active} z {total}", + "email": "E-mail", + "storage": "Przechowywanie plików", + "ai": "Modele AI", + "captcha": "Captcha", + "websocket": "WebSocket" + } + }, "actions": { "clear_cache": { "confirm": "Tak, wyczyść pamięć podręczną", @@ -236,7 +368,6 @@ "title": "Czy na pewno chcesz wyczyścić pamięć podręczną?" } }, - "desc": "Sprawdzaj logi, błędy i inne informacje debugowania.", "logs": { "content": "Treść", "created_at": "Utworzono", @@ -265,42 +396,16 @@ "title": "Więcej szczegółów" }, "plugin": "Wtyczka", - "status_code": "Kod statusu", - "title": "Logi systemowe", "type": "Typ", "types": { "debug": "Debug", "error": "Błąd", "warn": "Ostrzeżenie" } - }, - "queue": { - "counts": { - "completed": "Ukończone", - "failed": "Nieudane", - "pending": "Oczekujące", - "processing": "Przetwarzanie" - }, - "empty": "Brak aktywnych zadań w kolejce.", - "list": { - "attempts": "Próby", - "availableAt": "Dostępne od", - "name": "Zadanie", - "queue": "Kolejka", - "status": "Status" - }, - "title": "Zadania w kolejce" - }, - "title": "Panel debugowania" + } }, "global": { "nav": { - "advanced": { - "cron": "Zadania cron", - "queue": "Zadania w kolejce", - "search": "Szukaj", - "title": "Zaawansowane" - }, "core": "Rdzeń", "dashboard": "Pulpit", "staff": { @@ -320,7 +425,8 @@ "home_page": "Strona główna", "log_out": "Wyloguj się", "report_issue": "Zgłoś problem", - "website": "Strona VitNode" + "website": "Strona VitNode", + "debug_status": "{count, plural, one {# problem wymaga uwagi} few {# problemy wymagają uwagi} many {# problemów wymaga uwagi} other {# problemów wymaga uwagi}}" }, "users": { "list": "Lista użytkowników", @@ -330,8 +436,12 @@ "ai": { "title": "Sztuczna Inteligencja (AI)", "overview": "Przegląd", - "actions": "Akcje", - "history": "Historia" + "actions": "Akcje" + }, + "debug": { + "cron": "Zadania cron", + "queue": "Zadania w kolejce", + "search": "Indeks wyszukiwania" } }, "search": { @@ -887,7 +997,7 @@ "button": "Sprawdź aktualizacje", "success": { "title": "Sprawdzanie aktualizacji zakończone", - "desc": "Znaleziono {updates, plural, =0 {0 aktualizacji} one {# aktualizację} few {# aktualizacje} many {# aktualizacji} other {# aktualizacji}} i {advisories, plural, =0 {0 ostrzeżeń bezpieczeństwa} one {# ostrzeżenie bezpieczeństwa} few {# ostrzeżenia bezpieczeństwa} many {# ostrzeżeń bezpieczeństwa} other {# ostrzeżeń bezpieczeństwa}}." + "desc": "Znaleziono {updates, plural, =0 {0 aktualizacji} one {# aktualizację} few {# aktualizacje} many {# aktualizacji} other {# aktualizacji}} i {securityUpdates, plural, =0 {0 aktualizacji bezpieczeństwa} one {# aktualizację bezpieczeństwa} few {# aktualizacje bezpieczeństwa} many {# aktualizacji bezpieczeństwa} other {# aktualizacji bezpieczeństwa}}." }, "partial": { "title": "Nie udało się odczytać niektórych źródeł aktualizacji", @@ -915,7 +1025,7 @@ "never": "Codzienne sprawdzanie uruchamia harmonogram cron. Kliknij Sprawdź aktualizacje, aby uruchomić je teraz.", "disabled": "Sprawdzanie aktualizacji jest wyłączone w tej instalacji.", "channel": "Kanał: {channel} (ustawiony w konfiguracji API)", - "info": "Informacje o aktualizacjach mają charakter wyłącznie informacyjny: nic nie jest instalowane ani zmieniane. Brak ostrzeżenia nie dowodzi, że wersja jest bezpieczna." + "info": "Informacje o aktualizacjach mają charakter wyłącznie informacyjny: nic nie jest instalowane ani zmieniane. Brak aktualizacji bezpieczeństwa nie dowodzi, że wersja jest bezpieczna." }, "status": { "not_checked": "Jeszcze nie sprawdzono", @@ -953,18 +1063,12 @@ "unsupported_schema": "manifest aktualizacji używa formatu nieobsługiwanego przez tę wersję VitNode ({detail})." }, "security": { - "label": "Ostrzeżenia bezpieczeństwa", - "title": "Ostrzeżenie bezpieczeństwa {id} - poziom {severity}", + "label": "Aktualizacje bezpieczeństwa", + "title": "Aktualizacja bezpieczeństwa {id}", "affects": "Dotyczy zainstalowanej wersji {versions} (zakres {range}).", "fixed": "Naprawiono w {version}.", "no_fix": "Poprawiona wersja nie została jeszcze opublikowana.", - "details": "Szczegóły ostrzeżenia" - }, - "severity": { - "low": "niski", - "moderate": "umiarkowany", - "high": "wysoki", - "critical": "krytyczny" + "details": "Szczegóły" }, "channel": { "stable": "stabilny", @@ -1509,8 +1613,6 @@ "plugin": "Wtyczka" }, "history": { - "title": "Historia AI", - "desc": "Każda operacja AI: kto ją uruchomił, który model odpowiedział i ile to kosztowało.", "charged_reservation": "Pobrano {amount} (rezerwacja)", "filters": { "all": "Wszystkie", @@ -2855,7 +2957,6 @@ "dismiss": "Odrzuć", "title": "Zadania w tle wymagają skonfigurowanego adaptera cron" }, - "desc": "Silnik wyszukiwania treści i status indeksu.", "healthy": "Działa", "never": "Nigdy", "rebuild": "Przebuduj indeks", @@ -2874,7 +2975,6 @@ "statusHealthy": "Wszystkie systemy działają poprawnie", "statusUnhealthy": "Wyszukiwarka niedostępna" }, - "title": "Szukaj", "unhealthy": "Niedostępny" }, "desc": "Przeszukaj wszystko w społeczności.", diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index ac0163d4b..6ad5252a0 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -23,11 +23,16 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/staff/moderators/edit/:id' }, { path: '/admin/core/staff/admins/create' }, { path: '/admin/core/staff/moderators/create' }, - { path: '/admin/core/advanced/cron' }, - { path: '/admin/core/advanced/queue' }, - { path: '/admin/core/advanced/search' }, { path: '/admin/core/ai/actions' }, - { path: '/admin/core/ai/history' }, + { path: '/admin/core/debug/ai' }, + { path: '/admin/core/debug/cache' }, + { path: '/admin/core/debug/cron' }, + { path: '/admin/core/debug/logs' }, + { path: '/admin/core/debug/plugins' }, + { path: '/admin/core/debug/queue' }, + { path: '/admin/core/debug/search' }, + { path: '/admin/core/debug/services' }, + { path: '/admin/core/debug/vitnode' }, { path: '/admin/core/staff/admins' }, { path: '/admin/core/staff/moderators' }, { path: '/admin/core/system/files' }, diff --git a/packages/vitnode/scripts/update-manifest/merge.test.ts b/packages/vitnode/scripts/update-manifest/merge.test.ts index 84bda9b26..454c046f0 100644 --- a/packages/vitnode/scripts/update-manifest/merge.test.ts +++ b/packages/vitnode/scripts/update-manifest/merge.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest"; import type { UpdateManifest } from "../../src/lib/updates/manifest"; import { - applyAdvisories, + applySecurityUpdates, emptyUpdateManifest, mergeRelease, - parseAdvisoriesInput, + parseSecurityUpdatesInput, readPublishedManifest, requiresCoreFrom, serializeUpdateManifest, @@ -17,11 +17,10 @@ const RELEASE = { url: "https://github.com/acme/vitnode/releases/tag/v2.1.0", }; -const ADVISORY = { +const SECURITY_UPDATE = { affected: "<2.1.0", fixed: "2.1.0", id: "VN-2026-001", - severity: "high" as const, url: "https://github.com/acme/vitnode/security/advisories/1", }; @@ -40,12 +39,12 @@ describe("mergeRelease", () => { ]).packages, ).toEqual({ "@vitnode/blog": { - advisories: [], + securityUpdates: [], channels: { stable: "2.1.0" }, releases: { "2.1.0": { ...RELEASE, requiresCore: "^2.1.0" } }, }, "@vitnode/core": { - advisories: [], + securityUpdates: [], channels: { stable: "2.1.0" }, releases: { "2.1.0": RELEASE }, }, @@ -103,16 +102,16 @@ describe("mergeRelease", () => { }); }); - it("leaves other packages, channels and advisories untouched", () => { + it("leaves other packages, channels and securityUpdates untouched", () => { const before: UpdateManifest = { packages: { "@acme/other": { - advisories: [], + securityUpdates: [], channels: { stable: "1.0.0" }, releases: { "1.0.0": {} }, }, "@vitnode/core": { - advisories: [ADVISORY], + securityUpdates: [SECURITY_UPDATE], channels: { canary: "2.1.0-canary.1", stable: "2.0.0" }, releases: { "2.0.0": {}, "2.1.0-canary.1": {} }, }, @@ -130,7 +129,7 @@ describe("mergeRelease", () => { before.packages["@acme/other"], ); expect(merged.packages["@vitnode/core"]).toMatchObject({ - advisories: [ADVISORY], + securityUpdates: [SECURITY_UPDATE], channels: { canary: "2.1.0-canary.2", stable: "2.0.0" }, }); }); @@ -173,41 +172,43 @@ describe("readPublishedManifest", () => { }); }); -describe("applyAdvisories", () => { - it("gives each package exactly the advisories the file lists, and keeps releases", () => { +describe("applySecurityUpdates", () => { + it("gives each package exactly the securityUpdates the file lists, and keeps releases", () => { const published = stable(emptyUpdateManifest(), [ { id: "@vitnode/blog", version: "2.1.0" }, { id: "@vitnode/core", version: "2.1.0" }, ]); - const withOld = applyAdvisories(published, { - advisories: { "@vitnode/blog": [ADVISORY] }, + const withOld = applySecurityUpdates(published, { + securityUpdates: { "@vitnode/blog": [SECURITY_UPDATE] }, schemaVersion: 1, }); - const next = applyAdvisories(withOld, { - advisories: { "@vitnode/core": [ADVISORY] }, + const next = applySecurityUpdates(withOld, { + securityUpdates: { "@vitnode/core": [SECURITY_UPDATE] }, schemaVersion: 1, }); expect(next.packages["@vitnode/blog"]).toMatchObject({ - advisories: [], + securityUpdates: [], channels: { stable: "2.1.0" }, }); - expect(next.packages["@vitnode/core"]?.advisories).toEqual([ADVISORY]); + expect(next.packages["@vitnode/core"]?.securityUpdates).toEqual([ + SECURITY_UPDATE, + ]); }); }); -describe("parseAdvisoriesInput", () => { - it("reads the maintained advisories file", () => { +describe("parseSecurityUpdatesInput", () => { + it("reads the maintained security updates file", () => { expect( - parseAdvisoriesInput( + parseSecurityUpdatesInput( JSON.stringify({ - advisories: { "@vitnode/core": [ADVISORY] }, + securityUpdates: { "@vitnode/core": [SECURITY_UPDATE] }, schemaVersion: 1, }), ), ).toEqual({ - advisories: { "@vitnode/core": [ADVISORY] }, + securityUpdates: { "@vitnode/core": [SECURITY_UPDATE] }, schemaVersion: 1, }); }); @@ -215,15 +216,17 @@ describe("parseAdvisoriesInput", () => { it.each([ ["text that is not JSON", "[", /not JSON/], [ - "an advisory linking over http", + "a security update linking over http", JSON.stringify({ - advisories: { "@vitnode/core": [{ ...ADVISORY, url: "http://x.io" }] }, + securityUpdates: { + "@vitnode/core": [{ ...SECURITY_UPDATE, url: "http://x.io" }], + }, schemaVersion: 1, }), - /invalid: advisories\.@vitnode\/core\.0\.url/, + /invalid: securityUpdates\.@vitnode\/core\.0\.url/, ], ])("rejects %s", (_, text, error) => { - expect(() => parseAdvisoriesInput(text)).toThrow(error); + expect(() => parseSecurityUpdatesInput(text)).toThrow(error); }); }); diff --git a/packages/vitnode/scripts/update-manifest/merge.ts b/packages/vitnode/scripts/update-manifest/merge.ts index 8548d6933..264aa09c9 100644 --- a/packages/vitnode/scripts/update-manifest/merge.ts +++ b/packages/vitnode/scripts/update-manifest/merge.ts @@ -13,15 +13,15 @@ import { isPackageId, isStrictVersion, parseUpdateManifest, + securityUpdateSchema, UPDATE_MANIFEST_SCHEMA_VERSION, - updateAdvisorySchema, } from "../../src/lib/updates/manifest"; /** * The pure half of publishing VitNode's own update manifest: everything that * decides what the JSON says, with no git, GitHub or npm in sight. The release - * workflow feeds it what it published; the advisories workflow feeds it the - * maintained advisory list. + * workflow feeds it what it published; the security updates workflow feeds it the + * maintained security update list. */ const ERROR_PREFIX = "[update manifest]"; @@ -104,7 +104,7 @@ const assertValid = (manifest: UpdateManifest): UpdateManifest => { }; const emptyPackage = (): UpdatePackage => ({ - advisories: [], + securityUpdates: [], channels: {}, releases: {}, }); @@ -131,7 +131,7 @@ export interface GitHubReleaseMetadata { * changes nothing else. * - Without a GitHub Release, `publishedAt` and `url` are left out - never * invented. - * - Other packages, channels, releases and every advisory are untouched. + * - Other packages, channels, releases and every security update are untouched. */ export const mergeRelease = ( manifest: UpdateManifest, @@ -185,28 +185,30 @@ export const mergeRelease = ( return assertValid(next); }; -export const advisoriesInputSchema = z.strictObject({ +export const securityUpdatesInputSchema = z.strictObject({ schemaVersion: z.literal(UPDATE_MANIFEST_SCHEMA_VERSION), - advisories: z.record( + securityUpdates: z.record( z.string().refine(isPackageId, { message: "Expected an npm package name" }), - z.array(updateAdvisorySchema), + z.array(securityUpdateSchema), ), }); -export type AdvisoriesInput = z.infer; +export type SecurityUpdatesInput = z.infer; -export const parseAdvisoriesInput = (text: string): AdvisoriesInput => { +export const parseSecurityUpdatesInput = ( + text: string, +): SecurityUpdatesInput => { let value: unknown; try { value = JSON.parse(text); } catch { - throw new Error(`${ERROR_PREFIX} The advisories file is not JSON.`); + throw new Error(`${ERROR_PREFIX} The security updates file is not JSON.`); } - const parsed = advisoriesInputSchema.safeParse(value); + const parsed = securityUpdatesInputSchema.safeParse(value); if (!parsed.success) { throw new Error( - `${ERROR_PREFIX} The advisories file is invalid: ${parsed.error.issues + `${ERROR_PREFIX} The security updates file is invalid: ${parsed.error.issues .slice(0, 5) .map(issue => `${issue.path.join(".")}: ${issue.message}`) .join("; ")}`, @@ -217,20 +219,20 @@ export const parseAdvisoriesInput = (text: string): AdvisoriesInput => { }; /** - * The maintained advisories file is the whole truth about advisories: each + * The maintained security updates file is the whole truth about security updates: each * package gets exactly the list it declares, and a package it does not list has * none. Releases and channels are untouched. */ -export const applyAdvisories = ( +export const applySecurityUpdates = ( manifest: UpdateManifest, - input: AdvisoriesInput, + input: SecurityUpdatesInput, ): UpdateManifest => { const next: UpdateManifest = structuredClone(manifest); - for (const entry of Object.values(next.packages)) entry.advisories = []; - for (const [id, advisories] of Object.entries(input.advisories)) { + for (const entry of Object.values(next.packages)) entry.securityUpdates = []; + for (const [id, securityUpdates] of Object.entries(input.securityUpdates)) { const entry = next.packages[id] ?? emptyPackage(); - entry.advisories = advisories; + entry.securityUpdates = securityUpdates; next.packages[id] = entry; } diff --git a/packages/vitnode/src/api/lib/updates/check.test.ts b/packages/vitnode/src/api/lib/updates/check.test.ts index 9b8539252..28e2bdedc 100644 --- a/packages/vitnode/src/api/lib/updates/check.test.ts +++ b/packages/vitnode/src/api/lib/updates/check.test.ts @@ -34,11 +34,10 @@ const component = ( versions, }); -const ADVISORY = { +const SECURITY_UPDATE = { affected: "<1.1.0", fixed: "1.1.0", id: "ACME-1", - severity: "high" as const, url: "https://acme.example.com/advisories/1", }; @@ -49,7 +48,7 @@ const acmeManifest = ( ids.map(id => [ id, { - advisories: [ADVISORY], + securityUpdates: [SECURITY_UPDATE], channels: { stable: "1.1.0" }, releases: { "1.1.0": {} }, }, @@ -61,7 +60,7 @@ const acmeManifest = ( const coreManifest: UpdateManifest = { packages: { "@vitnode/core": { - advisories: [], + securityUpdates: [], channels: { stable: "2.0.0" }, releases: { "2.0.0": {} }, }, @@ -133,7 +132,7 @@ describe("checkForUpdates", () => { expect(fetchSource).toHaveBeenCalledTimes(1); expect(summary).toEqual({ - advisories: 1, + securityUpdates: 1, components: 2, failedSources: 0, sources: 1, @@ -142,12 +141,15 @@ describe("checkForUpdates", () => { expect(result("@acme/blog")).toMatchObject({ lastError: null, lastSuccessAt: T1, - result: { advisories: [{ id: "ACME-1" }], status: "update_available" }, + result: { + securityUpdates: [{ id: "ACME-1" }], + status: "update_available", + }, sourceUrl: ACME, versions: ["1.0.0"], }); expect(result("@acme/forum")).toMatchObject({ - result: { advisories: [], status: "up_to_date" }, + result: { securityUpdates: [], status: "up_to_date" }, }); }); @@ -228,7 +230,10 @@ describe("checkForUpdates", () => { lastAttemptAt: T2, lastError: error, lastSuccessAt: T1, - result: { advisories: [{ id: "ACME-1" }], status: "update_available" }, + result: { + securityUpdates: [{ id: "ACME-1" }], + status: "update_available", + }, }); expect(source(ACME)).toMatchObject({ document: { packages: { "@acme/blog": {} } }, @@ -255,7 +260,7 @@ describe("checkForUpdates", () => { expect(result("@acme/blog")).toMatchObject({ lastError: null, lastSuccessAt: T2, - result: { advisories: [], status: "up_to_date" }, + result: { securityUpdates: [], status: "up_to_date" }, versions: ["1.1.0"], }); }); diff --git a/packages/vitnode/src/api/lib/updates/check.ts b/packages/vitnode/src/api/lib/updates/check.ts index a9b7b30f0..7ae474cf2 100644 --- a/packages/vitnode/src/api/lib/updates/check.ts +++ b/packages/vitnode/src/api/lib/updates/check.ts @@ -34,9 +34,9 @@ export type FetchUpdateSource = ( ) => Promise; export interface UpdateCheckSummary { - advisories: number; components: number; failedSources: number; + securityUpdates: number; sources: number; updates: number; } @@ -230,7 +230,7 @@ export const checkForUpdates = async ({ }); const summary: UpdateCheckSummary = { - advisories: 0, + securityUpdates: 0, components: checked.length, failedSources: [...outcomes.values()].filter( outcome => outcome.error !== null, @@ -273,7 +273,7 @@ export const checkForUpdates = async ({ installed: component, isCore: component.kind === "core", }); - summary.advisories += result.advisories.length; + summary.securityUpdates += result.securityUpdates.length; if (result.status === "update_available") summary.updates += 1; const values = { diff --git a/packages/vitnode/src/api/lib/updates/evaluate.test.ts b/packages/vitnode/src/api/lib/updates/evaluate.test.ts index dc40cb28c..19162ffe3 100644 --- a/packages/vitnode/src/api/lib/updates/evaluate.test.ts +++ b/packages/vitnode/src/api/lib/updates/evaluate.test.ts @@ -5,7 +5,7 @@ import type { UpdateChannel, UpdatePackage } from "@/lib/updates/manifest"; import { evaluateUpdate, normalizeInstalledVersions } from "./evaluate"; const ENTRY: UpdatePackage = { - advisories: [], + securityUpdates: [], channels: { canary: "2.2.0-canary.3", rc: "2.2.0-rc.1", stable: "2.1.0" }, releases: { "2.0.1": { requiresCore: "^1.0.0" }, @@ -21,11 +21,10 @@ const ENTRY: UpdatePackage = { }, }; -const ADVISORY = { +const SECURITY_UPDATE = { affected: "<2.0.3", fixed: "2.0.3", id: "VN-2026-001", - severity: "critical" as const, url: "https://example.com/advisories/VN-2026-001", }; @@ -101,7 +100,7 @@ describe("evaluateUpdate", () => { it("is up to date on the channel target, with its release details", () => { expect(evaluate(["2.1.0"])).toEqual({ - advisories: [], + securityUpdates: [], alternative: null, channel: "stable", compatibility: null, @@ -144,21 +143,20 @@ describe("evaluateUpdate", () => { expect(evaluate(["2.1.0", "2.0.0"]).status).toBe("update_available"); }); - describe("advisories", () => { - const withAdvisories = ( - advisories: UpdatePackage["advisories"], - ): UpdatePackage => ({ ...ENTRY, advisories }); + describe("securityUpdates", () => { + const withSecurityUpdates = ( + securityUpdates: UpdatePackage["securityUpdates"], + ): UpdatePackage => ({ ...ENTRY, securityUpdates }); - it("reports a matching advisory with the affected versions", () => { + it("reports a matching securityUpdate with the affected versions", () => { expect( - evaluate(["2.0.1"], { entry: withAdvisories([ADVISORY]) }).advisories, + evaluate(["2.0.1"], { entry: withSecurityUpdates([SECURITY_UPDATE]) }) + .securityUpdates, ).toEqual([ { affected: "<2.0.3", fixed: "2.0.3", id: "VN-2026-001", - severity: "critical", - summary: null, url: "https://example.com/advisories/VN-2026-001", versions: ["2.0.1"], }, @@ -167,55 +165,57 @@ describe("evaluateUpdate", () => { it("reports a vulnerable web app even when the API runs an unaffected version", () => { const result = evaluate(["2.1.0", "2.0.1"], { - entry: withAdvisories([ADVISORY]), + entry: withSecurityUpdates([SECURITY_UPDATE]), }); - expect(result.advisories).toMatchObject([{ versions: ["2.0.1"] }]); + expect(result.securityUpdates).toMatchObject([{ versions: ["2.0.1"] }]); }); it("reports a vulnerable API even when the web app runs an unaffected version", () => { const result = evaluate(["2.0.1", "2.0.3"], { - entry: withAdvisories([ADVISORY]), + entry: withSecurityUpdates([SECURITY_UPDATE]), }); - expect(result.advisories).toMatchObject([{ versions: ["2.0.1"] }]); + expect(result.securityUpdates).toMatchObject([{ versions: ["2.0.1"] }]); }); it("matches prereleases inside the affected range", () => { expect( - evaluate(["2.0.3-canary.1"], { entry: withAdvisories([ADVISORY]) }) - .advisories, + evaluate(["2.0.3-canary.1"], { + entry: withSecurityUpdates([SECURITY_UPDATE]), + }).securityUpdates, ).toHaveLength(1); }); - it("reports an advisory with no fix yet", () => { - const { fixed: _, ...unfixed } = ADVISORY; + it("reports a security update with no fix yet", () => { + const { fixed: _, ...unfixed } = SECURITY_UPDATE; expect( evaluate(["2.1.0"], { - entry: withAdvisories([{ ...unfixed, affected: "<=2.1.0" }]), + entry: withSecurityUpdates([{ ...unfixed, affected: "<=2.1.0" }]), }), ).toMatchObject({ - advisories: [{ fixed: null, versions: ["2.1.0"] }], + securityUpdates: [{ fixed: null, versions: ["2.1.0"] }], status: "up_to_date", }); }); - it("leaves out an advisory no installed version is affected by", () => { + it("leaves out a security update no installed version is affected by", () => { expect( - evaluate(["2.0.3"], { entry: withAdvisories([ADVISORY]) }).advisories, + evaluate(["2.0.3"], { entry: withSecurityUpdates([SECURITY_UPDATE]) }) + .securityUpdates, ).toEqual([]); }); - it("reports advisories whatever the channel says", () => { + it("reports securityUpdates whatever the channel says", () => { const entry: UpdatePackage = { - advisories: [{ ...ADVISORY, affected: "<3.0.0" }], + securityUpdates: [{ ...SECURITY_UPDATE, affected: "<3.0.0" }], channels: {}, releases: {}, }; expect(evaluate(["2.2.0-beta.1"], { entry })).toMatchObject({ - advisories: [{ id: "VN-2026-001" }], + securityUpdates: [{ id: "VN-2026-001" }], status: "unknown_channel", }); }); @@ -223,7 +223,7 @@ describe("evaluateUpdate", () => { describe("compatibility with the running core", () => { const stableAt = (target: string, release: object): UpdatePackage => ({ - advisories: [], + securityUpdates: [], channels: { stable: target }, releases: { [target]: release }, }); diff --git a/packages/vitnode/src/api/lib/updates/evaluate.ts b/packages/vitnode/src/api/lib/updates/evaluate.ts index b1a0a0096..0318d6fa7 100644 --- a/packages/vitnode/src/api/lib/updates/evaluate.ts +++ b/packages/vitnode/src/api/lib/updates/evaluate.ts @@ -1,7 +1,6 @@ import { gt, lte, rcompare, satisfies, valid } from "semver"; import type { - AdvisorySeverity, UpdateChannel, UpdatePackage, UpdateRelease, @@ -30,25 +29,23 @@ export interface UpdateReleaseInfo { version: string; } -export interface UpdateAdvisoryFinding { +export interface SecurityUpdateFinding { affected: string; fixed: null | string; id: string; - severity: AdvisorySeverity; - summary: null | string; url: string; /** The installed versions inside `affected` - the API's, the web app's, or both. */ versions: string[]; } export interface UpdateEvaluation { - advisories: UpdateAdvisoryFinding[]; /** The newest release on the channel that declares it works with the running core, when `latest` does not. */ alternative: null | UpdateReleaseInfo; channel: null | UpdateChannel; /** Of `latest` with the running core. `null` for core itself and when there is no newer release. */ compatibility: null | UpdateCompatibility; latest: null | UpdateReleaseInfo; + securityUpdates: SecurityUpdateFinding[]; status: UpdateStatus; } @@ -133,24 +130,22 @@ const compatibleAlternative = ( return null; }; -const advisoryFindings = ( +const securityUpdateFindings = ( entry: undefined | UpdatePackage, versions: readonly string[], -): UpdateAdvisoryFinding[] => - (entry?.advisories ?? []).flatMap(advisory => { +): SecurityUpdateFinding[] => + (entry?.securityUpdates ?? []).flatMap(securityUpdate => { const affected = versions.filter(version => - versionInRange(version, advisory.affected), + versionInRange(version, securityUpdate.affected), ); if (affected.length === 0) return []; return [ { - affected: advisory.affected, - fixed: advisory.fixed ?? null, - id: advisory.id, - severity: advisory.severity, - summary: advisory.summary ?? null, - url: advisory.url, + affected: securityUpdate.affected, + fixed: securityUpdate.fixed ?? null, + id: securityUpdate.id, + url: securityUpdate.url, versions: affected, }, ]; @@ -174,11 +169,11 @@ export const evaluateUpdate = ({ const [newest] = [...installed.versions].sort(rcompare); const channel = channelOverride ?? (newest === undefined ? null : channelOfVersion(newest)); - // Advisories never depend on channels or releases: a vulnerable build is + // Security updates never depend on channels or releases: a vulnerable build is // reported even when its publisher stopped shipping that channel. - const advisories = advisoryFindings(entry, installed.versions); + const securityUpdates = securityUpdateFindings(entry, installed.versions); const base = { - advisories, + securityUpdates, alternative: null, channel, compatibility: null, diff --git a/packages/vitnode/src/api/lib/updates/status.test.ts b/packages/vitnode/src/api/lib/updates/status.test.ts index 824fd7e55..c99c25dcc 100644 --- a/packages/vitnode/src/api/lib/updates/status.test.ts +++ b/packages/vitnode/src/api/lib/updates/status.test.ts @@ -22,12 +22,11 @@ const T2 = new Date("2026-10-15T03:00:00Z"); const MANIFEST: UpdateManifest = { packages: { "@acme/blog": { - advisories: [ + securityUpdates: [ { affected: "<1.1.0", fixed: "1.1.0", id: "ACME-1", - severity: "high", url: "https://acme.example.com/advisories/1", }, ], @@ -104,7 +103,7 @@ describe("loadUpdatesOverview", () => { lastError: null, lastSuccessAt: T1, result: expect.objectContaining({ - advisories: [expect.objectContaining({ id: "ACME-1" })], + securityUpdates: [expect.objectContaining({ id: "ACME-1" })], status: "update_available", }) as unknown, sourceUrl: ACME, @@ -123,7 +122,7 @@ describe("loadUpdatesOverview", () => { expect(state).toMatchObject({ lastSuccessAt: T1, - result: { advisories: [], status: "up_to_date" }, + result: { securityUpdates: [], status: "up_to_date" }, stale: true, state: "checked", }); @@ -180,7 +179,7 @@ describe("loadUpdatesOverview", () => { lastAttemptAt: T2, lastError: { code: "timeout" }, lastSuccessAt: T1, - result: { advisories: [{ id: "ACME-1" }] }, + result: { securityUpdates: [{ id: "ACME-1" }] }, stale: false, state: "checked", }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts index 6f1615a6d..12f95b6e9 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.test.ts @@ -79,7 +79,7 @@ describe("POST /admin/plugins/updates/check", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ - advisories: 0, + securityUpdates: 0, components: 0, failedSources: 0, sources: 0, diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts index e8bd6ab9c..0acc48b64 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/check-updates.route.ts @@ -16,7 +16,7 @@ export const checkPluginUpdatesAdminRoute = buildRoute({ route: { method: "post", description: - "Check core and every installed plugin for updates and security advisories now. Shares its lease with the daily cron job.", + "Check core and every installed plugin for updates and security updates now. Shares its lease with the daily cron job.", path: "/updates/check", responses: { 200: { @@ -28,7 +28,7 @@ export const checkPluginUpdatesAdminRoute = buildRoute({ failedSources: z.number(), components: z.number(), updates: z.number(), - advisories: z.number(), + securityUpdates: z.number(), }), }, }, diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts index 183588268..c026b8eb0 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.route.ts @@ -12,7 +12,7 @@ export const pluginUpdatesAdminRoute = buildRoute({ route: { method: "get", description: - "Stored update and security-advisory results for core and every installed plugin. Never contacts a publisher.", + "Stored update and security-update results for core and every installed plugin. Never contacts a publisher.", path: "/updates", responses: { 200: { diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts index 4a5d19877..d92223c4f 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/updates.schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { ComponentUpdateState } from "@/api/lib/updates/status"; -import { ADVISORY_SEVERITIES, UPDATE_CHANNELS } from "@/lib/updates/manifest"; +import { UPDATE_CHANNELS } from "@/lib/updates/manifest"; const releaseSchema = z.object({ version: z.string(), @@ -37,14 +37,12 @@ export const componentUpdateSchema = z.object({ .enum(["compatible", "incompatible", "unknown"]) .nullable(), alternative: releaseSchema.nullable(), - advisories: z.array( + securityUpdates: z.array( z.object({ id: z.string(), - severity: z.enum(ADVISORY_SEVERITIES), affected: z.string(), fixed: z.string().nullable(), url: z.string(), - summary: z.string().nullable(), versions: z.array(z.string()), }), ), diff --git a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts index d3d770493..96792fa90 100644 --- a/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts +++ b/packages/vitnode/src/api/modules/cron/cron/check-updates.cron.ts @@ -4,7 +4,7 @@ import { runUpdateCheckFor } from "@/api/lib/updates/context"; export const checkUpdatesCron = buildCron({ name: "check_updates", description: - "Check core and plugins for updates and security advisories in their public update manifests", + "Check core and plugins for updates and security updates in their public update manifests", // Daily at 03:17 in the API server's time zone. A new job has never run, so // the first scheduler tick after it is registered runs the first check. schedule: "17 3 * * *", diff --git a/packages/vitnode/src/components/side-nav.tsx b/packages/vitnode/src/components/side-nav.tsx index b06d4c696..35780dc2c 100644 --- a/packages/vitnode/src/components/side-nav.tsx +++ b/packages/vitnode/src/components/side-nav.tsx @@ -1,17 +1,58 @@ import { Link } from "@tanstack/react-router"; import { cn } from "cn"; +import React from "react"; -export const SideNavCard = ({ +import { glideSidebarIndicators } from "./ui/sidebar-glide"; + +export const SideNav = ({ + children, className, ...props -}: React.ComponentProps<"nav">) => ( - - + {MODCP_NAV_ITEMS.map(item => ( { /> ))} - + ); }; diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 1ec112f8e..a8054ecfb 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -160,7 +160,7 @@ export interface VitNodeApiConfig { }; }; /** - * Update and security-advisory checks for core and plugins, shown in + * Update and security-update checks for core and plugins, shown in * AdminCP → System → Plugins. They read public JSON manifests once a day and * never install anything. */ diff --git a/scripts/update-manifest/publish-update-manifest.ts b/scripts/update-manifest/publish-update-manifest.ts index d1819940f..c88c0ce8e 100644 --- a/scripts/update-manifest/publish-update-manifest.ts +++ b/scripts/update-manifest/publish-update-manifest.ts @@ -17,10 +17,10 @@ import type { } from "../../packages/vitnode/scripts/update-manifest/merge.ts"; import { - applyAdvisories, + applySecurityUpdates, channelForReleaseType, mergeRelease, - parseAdvisoriesInput, + parseSecurityUpdatesInput, readPublishedManifest, requiresCoreFrom, serializeUpdateManifest, @@ -31,8 +31,8 @@ import { * served at https://raw.githubusercontent.com//update-manifest/updates.json. * * release after a successful npm publish: adds the published versions - * advisories replaces the advisories with `security/advisories.json` - * check validates `security/advisories.json` and exits + * security-updates replaces the security updates with `security/security-updates.json` + * check validates `security/security-updates.json` and exits * * Every write fetches the branch, merges, and pushes; a push that loses a race * is retried from a fresh fetch, so concurrent runs cannot overwrite each other. @@ -40,7 +40,7 @@ import { const BRANCH = process.env.UPDATE_MANIFEST_BRANCH ?? "update-manifest"; const FILE = "updates.json"; -const ADVISORIES_FILE = join("security", "advisories.json"); +const SECURITY_UPDATES_FILE = join("security", "security-updates.json"); const MAX_PUSH_ATTEMPTS = 5; const CORE_ID = "@vitnode/core"; @@ -253,16 +253,20 @@ const publishToBranch = ({ ); }; -const readAdvisories = () => - parseAdvisoriesInput(readFileSync(join(workspace, ADVISORIES_FILE), "utf8")); +const readSecurityUpdates = () => + parseSecurityUpdatesInput( + readFileSync(join(workspace, SECURITY_UPDATES_FILE), "utf8"), + ); const main = () => { const mode = process.argv[2]; if (mode === "check") { - const input = readAdvisories(); - const count = Object.values(input.advisories).flat().length; - console.log(`${ADVISORIES_FILE} is valid (${String(count)} advisories).`); + const input = readSecurityUpdates(); + const count = Object.values(input.securityUpdates).flat().length; + console.log( + `${SECURITY_UPDATES_FILE} is valid (${String(count)} security updates).`, + ); return; } @@ -271,13 +275,13 @@ const main = () => { const token = requireEnv("GITHUB_TOKEN"); const feedUrl = `https://raw.githubusercontent.com/${repository}/${BRANCH}/${FILE}`; - if (mode === "advisories") { - const input = readAdvisories(); + if (mode === "security-updates") { + const input = readSecurityUpdates(); publishToBranch({ - message: "chore: update security advisories", + message: "chore: update security updates", repository, token, - update: manifest => applyAdvisories(manifest, input), + update: manifest => applySecurityUpdates(manifest, input), }); return; @@ -319,7 +323,7 @@ const main = () => { } throw new Error( - "Usage: publish-update-manifest.ts ", + "Usage: publish-update-manifest.ts ", ); }; diff --git a/security/README.md b/security/README.md index a839b06ac..99f527ff5 100644 --- a/security/README.md +++ b/security/README.md @@ -1,7 +1,7 @@ -# Security advisories +# Security updates -`advisories.json` is the maintained list of security advisories for the packages VitNode publishes. The **Publish security advisories** workflow validates it on every pull request and, once it lands on `canary`, copies it into the public update manifest on the `update-manifest` branch - the one every VitNode installation reads once a day. +`security-updates.json` is the maintained list of security updates for the packages VitNode publishes. The **Publish security updates** workflow validates it on every pull request and, once it lands on `canary`, copies it into the public update manifest on the `update-manifest` branch - the one every VitNode installation reads once a day. -How to write an entry, how ranges match prereleases, and how to announce a vulnerability without a fix: https://vitnode.com/docs/dev/updates/security-advisories +How to write an entry, how ranges match prereleases, and how to announce a vulnerability without a fix: https://vitnode.com/docs/dev/updates/security-updates -Only real advisories belong here. Examples live in the documentation. +Only real security updates belong here. Examples live in the documentation. diff --git a/security/advisories.json b/security/security-updates.json similarity index 52% rename from security/advisories.json rename to security/security-updates.json index f407294ca..44fa4f228 100644 --- a/security/advisories.json +++ b/security/security-updates.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "advisories": {} + "securityUpdates": {} } From 6ffbf7ae366175fa83fd4da24145d8293071ac67 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sat, 17 Oct 2026 10:44:24 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat(plugin):=20=E2=9C=A8=20update=20plugin?= =?UTF-8?q?=20manifest=20structure=20and=20improve=20update=20source=20han?= =?UTF-8?q?dling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/content/docs/dev/advanced/auth.mdx | 4 +- apps/web/content/docs/dev/plugins/build.mdx | 2 +- apps/web/content/docs/dev/plugins/catalog.mdx | 2 +- .../docs/dev/updates/official-releases.mdx | 2 +- .../docs/dev/updates/plugin-authors.mdx | 30 +++++------ apps/web/src/locales/@vitnode/core/pl.json | 8 +-- packages/vitnode/package.json | 5 -- .../scripts/write-plugin-manifest.test.ts | 20 ++----- .../plugin-manifest/generate.test.ts | 31 ----------- .../src/framework/plugin-manifest/generate.ts | 45 +++------------- packages/vitnode/src/lib/plugin-manifest.ts | 4 +- packages/vitnode/src/locales/en.json | 8 +-- .../src/tanstack/admin/tab-presence.test.tsx | 4 +- .../src/tanstack/admin/tab-presence.ts | 2 +- .../core/system/plugins/core-update-card.tsx | 53 ++++++++++++------- .../core/system/plugins/plugins-content.tsx | 9 ---- plugins/blog/package.json | 5 -- plugins/blog/src/manifest.gen.ts | 10 ++-- plugins/blog/src/plugin-info.ts | 3 ++ plugins/example/src/manifest.gen.ts | 10 ++-- plugins/forum/src/manifest.gen.ts | 10 ++-- .../publish-update-manifest.ts | 46 +++++++++++----- 22 files changed, 121 insertions(+), 192 deletions(-) diff --git a/apps/web/content/docs/dev/advanced/auth.mdx b/apps/web/content/docs/dev/advanced/auth.mdx index 02b712a47..cbd98334b 100644 --- a/apps/web/content/docs/dev/advanced/auth.mdx +++ b/apps/web/content/docs/dev/advanced/auth.mdx @@ -46,11 +46,11 @@ export const vitNodeApiConfig = buildApiConfig({ The AdminCP is the keys to the kingdom, so its session is deliberately short-lived. Staff are asked to sign in again when: - **They go quiet for an hour.** Every AdminCP request pushes the expiry an hour ahead, so an admin who keeps working is never interrupted. Leave the tab alone for an hour and it signs itself out and lands on the sign-in page, with a toast explaining why. Tune the window with `adminCookieExpires`. -- **They close every AdminCP tab.** Open AdminCP tabs keep a heartbeat going. Open the AdminCP again after every tab was closed and VitNode ends the old session instead of letting it back in. A page reload, or opening one more AdminCP tab while another is still open, is not affected. This check is skipped when `cookieDomain` is set: a browser can only see tabs on its own origin, so an AdminCP tab open on another subdomain would look closed. +- **They close every AdminCP tab for an hour.** Open AdminCP tabs keep a heartbeat going. Open the AdminCP again more than an hour after the last tab was closed and VitNode ends the old session instead of letting it back in. A page reload, or opening one more AdminCP tab while another is still open, is not affected. This check is skipped when `cookieDomain` is set: a browser can only see tabs on its own origin, so an AdminCP tab open on another subdomain would look closed. - **They close the browser.** The `vitnode_auth_admin` cookie has no expiry date, so the browser drops it when it shuts down. - Moving from the AdminCP to the public site in your only AdminCP tab stops the heartbeat. Come back within about 15 seconds and you are still signed in; any later and you sign in again. Open the public site in a new tab to keep your AdminCP session. + Moving from the AdminCP to the public site in your only AdminCP tab stops the heartbeat. Come back within an hour and you are still signed in; any later and you sign in again. Background tabs the browser throttles or freezes count as open for the same hour. --- diff --git a/apps/web/content/docs/dev/plugins/build.mdx b/apps/web/content/docs/dev/plugins/build.mdx index 28013e720..1bc1c6502 100644 --- a/apps/web/content/docs/dev/plugins/build.mdx +++ b/apps/web/content/docs/dev/plugins/build.mdx @@ -78,7 +78,7 @@ You don't need `isolatedDeclarations` or explicit return types. Inferred types c ## Generated manifest -Before compiling, `vitnode build` also writes `src/manifest.gen.ts`. It exports `manifest`, which holds the plugin's `name` (as `id`), `version`, `description`, `author` and `license` from `package.json`, and imports the title and Lucide icon from the plugin's own `src/plugin-info.ts`. `vitnode dev` writes it on start and again whenever `package.json` changes. It is the single source of the plugin id, passed to `buildPlugin()`, `buildApiPlugin()` and `bindApiPlugin()`, and it is what the [AdminCP plugin catalog](/docs/dev/plugins/catalog) shows, so commit it with the rest of `src`. +Before compiling, `vitnode build` also writes `src/manifest.gen.ts`. It exports `manifest`, which holds the plugin's `name` (as `id`), `version`, `description`, `author` and `license` from `package.json`, and imports the title, Lucide icon and update source from the plugin's own `src/plugin-info.ts`. `vitnode dev` writes it on start and again whenever `package.json` changes. It is the single source of the plugin id, passed to `buildPlugin()`, `buildApiPlugin()` and `bindApiPlugin()`, and it is what the [AdminCP plugin catalog](/docs/dev/plugins/catalog) shows, so commit it with the rest of `src`. ## What a plugin package needs diff --git a/apps/web/content/docs/dev/plugins/catalog.mdx b/apps/web/content/docs/dev/plugins/catalog.mdx index 5cf500f4b..9848e9fb3 100644 --- a/apps/web/content/docs/dev/plugins/catalog.mdx +++ b/apps/web/content/docs/dev/plugins/catalog.mdx @@ -157,7 +157,7 @@ A frontend inventory entry carrying the manifest of another package throws at st ## Update and security information -Add `vitnode.updates.url` to your plugin's `package.json` and the catalog row also shows new releases and security updates. Setup is covered in [Publish updates for your plugin](/docs/dev/updates/plugin-authors); what administrators see is in [Check for updates in the AdminCP](/docs/dev/updates/admin). +Add `updates.url` to your plugin's `src/plugin-info.ts` and the catalog row also shows new releases and security updates. Setup is covered in [Publish updates for your plugin](/docs/dev/updates/plugin-authors); what administrators see is in [Check for updates in the AdminCP](/docs/dev/updates/admin). ## Who can see the catalog diff --git a/apps/web/content/docs/dev/updates/official-releases.mdx b/apps/web/content/docs/dev/updates/official-releases.mdx index ba5f6006e..8563f07e7 100644 --- a/apps/web/content/docs/dev/updates/official-releases.mdx +++ b/apps/web/content/docs/dev/updates/official-releases.mdx @@ -11,7 +11,7 @@ VitNode core and the official plugins read the feed at `https://raw.githubuserco The **Bump and Publish npm packages** workflow has a **Publish update manifest** job. It runs only in the `bump_and_publish` and `publish` modes, and only after the publish job succeeds - a `bump` run publishes nothing, so it advertises nothing. 1. It checks out the exact commit that was published. -2. From `PUBLISHED_PACKAGES` - the same list the publish steps use - it keeps the packages whose `package.json` declares this feed as `vitnode.updates.url` (today `@vitnode/core` and `@vitnode/blog`). +2. From `PUBLISHED_PACKAGES` - the same list the publish steps use - it keeps the packages whose manifest points at this feed as `updates.url` - `src/plugin-info.ts` for a plugin, `src/manifest.ts` for core (today `@vitnode/core` and `@vitnode/blog`). 3. It reads each version from `package.json`. A plugin's `@vitnode/core` dependency becomes the release's `requiresCore`, resolving `workspace:*` the way pnpm does on publish. 4. It adds the releases and moves only the channel of this run: **canary**, **release-candidate** (`rc`) or **stable**. A channel never moves backwards. 5. It commits `updates.json` to the `update-manifest` branch. That branch exists only for the feed, so it outlives release branches. diff --git a/apps/web/content/docs/dev/updates/plugin-authors.mdx b/apps/web/content/docs/dev/updates/plugin-authors.mdx index 199b117ab..d56df0fd1 100644 --- a/apps/web/content/docs/dev/updates/plugin-authors.mdx +++ b/apps/web/content/docs/dev/updates/plugin-authors.mdx @@ -1,28 +1,28 @@ --- title: Publish updates for your plugin -description: Declare an update source in your plugin's package.json and host a small JSON manifest so VitNode installations see your new releases and security updates. +description: Declare an update source in your plugin's src/plugin-info.ts and host a small JSON manifest so VitNode installations see your new releases and security updates. icon: Upload --- -Add one field to your plugin's `package.json` and publish a JSON file next to your code. Every installation of your plugin then shows your new releases and security updates in **AdminCP → System → Plugins**. +Add one field to your plugin's `src/plugin-info.ts` and publish a JSON file next to your code. Every installation of your plugin then shows your new releases and security updates in **AdminCP → System → Plugins**. ### Declare the update source -Add `vitnode.updates.url` to the plugin's `package.json`: +Add `updates.url` to the plugin's `src/plugin-info.ts`, next to its title and icon: -```json title="plugins/site-notes/package.json" -{ - "name": "@acme/site-notes", - "version": "1.2.0", - "vitnode": { - "updates": { - "url": "https://raw.githubusercontent.com/acme/site-notes/main/updates.json" - } - } -} +```ts title="plugins/site-notes/src/plugin-info.ts" +import type { PluginInfo } from "@vitnode/core/lib/plugin-manifest"; + +export const pluginInfo: PluginInfo = { + title: "Site Notes", + icon: "sticky-note", + updates: { + url: "https://raw.githubusercontent.com/acme/site-notes/main/updates.json", + }, +}; ``` The URL has to be `https://`, without a user name, password, query string or fragment. It ends up in browser-visible metadata, so it must never carry a token. @@ -32,7 +32,7 @@ The URL has to be `https://`, without a user name, password, query string or fra ### Rebuild the plugin -Run `vitnode build` (or keep `vitnode dev` running). The URL is written into `src/manifest.gen.ts` as `updates`, next to the version, so both the API and the web app's plugin inventory know where to look. +Run `vitnode build` (or keep `vitnode dev` running). `src/manifest.gen.ts` picks the URL up from `pluginInfo` as `updates`, next to the version, so both the API and the web app's plugin inventory know where to look. @@ -96,4 +96,4 @@ Any other HTTPS host works too, as long as it is publicly reachable on port 443. ## Private and local plugins -Leave `vitnode.updates` out. The plugin works as before, and the catalog shows _Update checking unavailable_ for it. Nothing is sent anywhere for a plugin without an update source. +Leave `updates` out of `src/plugin-info.ts`. The plugin works as before, and the catalog shows _Update checking unavailable_ for it. Nothing is sent anywhere for a plugin without an update source. diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index 08f5bed40..3a66b4cfe 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -984,10 +984,6 @@ "search": "Szukaj wtyczek...", "title": "Wtyczki", "version_unknown": "Nieznana wersja", - "inventory_missing": { - "title": "Widoczne są tylko wtyczki API", - "desc": "API nie otrzymało inwentarza wtyczek aplikacji webowej, więc wtyczki działające tylko w przeglądarce nie są wymienione, a wersje frontendu nie są porównywane. Przekaż frontendPlugins do buildApiConfig, aby zobaczyć obie strony." - }, "core": { "title": "Rdzeń VitNode", "version": "Zainstalowana wersja {version}" @@ -1022,10 +1018,8 @@ }, "summary": { "last_checked": "Ostatnio sprawdzono ", - "never": "Codzienne sprawdzanie uruchamia harmonogram cron. Kliknij Sprawdź aktualizacje, aby uruchomić je teraz.", "disabled": "Sprawdzanie aktualizacji jest wyłączone w tej instalacji.", - "channel": "Kanał: {channel} (ustawiony w konfiguracji API)", - "info": "Informacje o aktualizacjach mają charakter wyłącznie informacyjny: nic nie jest instalowane ani zmieniane. Brak aktualizacji bezpieczeństwa nie dowodzi, że wersja jest bezpieczna." + "channel": "Kanał: {channel} (ustawiony w konfiguracji API)" }, "status": { "not_checked": "Jeszcze nie sprawdzono", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 9f011156c..4a740b0da 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -8,11 +8,6 @@ "url": "https://vitnode.com" }, "license": "MIT", - "vitnode": { - "updates": { - "url": "https://raw.githubusercontent.com/aXenDeveloper/vitnode/update-manifest/updates.json" - } - }, "homepage": "https://vitnode.com", "repository": { "type": "git", diff --git a/packages/vitnode/scripts/write-plugin-manifest.test.ts b/packages/vitnode/scripts/write-plugin-manifest.test.ts index 4ffba7451..c0c63a85c 100644 --- a/packages/vitnode/scripts/write-plugin-manifest.test.ts +++ b/packages/vitnode/scripts/write-plugin-manifest.test.ts @@ -40,7 +40,7 @@ const writeManifest = (root: string, manifest: Record) => { write(root, "package.json", JSON.stringify(manifest, null, 2)); }; -const writeInfo = (root: string, info: Record) => { +const writeInfo = (root: string, info: Record) => { write( root, PLUGIN_INFO_PATH, @@ -104,11 +104,9 @@ describe("writePluginManifest", () => { }); }); - it("writes the update source declared in package.json", async () => { - const root = pluginAt({ - ...BLOG, - vitnode: { updates: { url: "https://example.com/updates.json" } }, - }); + it("takes the update source from src/plugin-info.ts", async () => { + const root = pluginAt(BLOG); + writeInfo(root, { updates: { url: "https://example.com/updates.json" } }); writePluginManifest(root); @@ -139,16 +137,6 @@ describe("writePluginManifest", () => { }, true, ], - [ - "a new update source", - (root: string) => { - writeManifest(root, { - ...BLOG, - vitnode: { updates: { url: "https://example.com/updates.json" } }, - }); - }, - true, - ], [ "src/plugin-info.ts disappearing", (root: string) => { diff --git a/packages/vitnode/src/framework/plugin-manifest/generate.test.ts b/packages/vitnode/src/framework/plugin-manifest/generate.test.ts index cf0295d1f..392ef0b62 100644 --- a/packages/vitnode/src/framework/plugin-manifest/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-manifest/generate.test.ts @@ -21,7 +21,6 @@ describe("pluginManifestFromPackageJson", () => { description: "Posts and categories", id: "@acme/blog", license: null, - updates: null, version: "1.2.0", }); }); @@ -36,7 +35,6 @@ describe("pluginManifestFromPackageJson", () => { description: null, id: "@acme/blog", license: null, - updates: null, version: null, }); }); @@ -61,35 +59,6 @@ describe("pluginManifestFromPackageJson", () => { ); }); - it("reads the update source from `vitnode.updates.url`, trimmed", () => { - expect( - pluginManifestFromPackageJson( - { - name: "@acme/blog", - vitnode: { - updates: { url: " https://example.com/acme/updates.json " }, - }, - }, - SOURCE, - ).updates, - ).toEqual({ url: "https://example.com/acme/updates.json" }); - }); - - it.each([ - ["a non-object `vitnode`", "plugin", /`vitnode` in package\.json/], - ["no url", { updates: {} }, /`vitnode\.updates` in package\.json/], - ["a non-string url", { updates: { url: 1 } }, /`vitnode\.updates`/], - [ - "a url carrying a token", - { updates: { url: "https://example.com/updates.json?token=secret" } }, - /`vitnode\.updates\.url` in package\.json cannot carry a query string/, - ], - ])("rejects an update source with %s", (_, vitnode, error) => { - expect(() => - pluginManifestFromPackageJson({ name: "@acme/blog", vitnode }, SOURCE), - ).toThrow(error); - }); - it("names the file and the manifest prefix in its errors", () => { expect(() => pluginManifestFromPackageJson({}, "plugins/blog/package.json"), diff --git a/packages/vitnode/src/framework/plugin-manifest/generate.ts b/packages/vitnode/src/framework/plugin-manifest/generate.ts index 37970a9f1..19a7a8633 100644 --- a/packages/vitnode/src/framework/plugin-manifest/generate.ts +++ b/packages/vitnode/src/framework/plugin-manifest/generate.ts @@ -1,11 +1,9 @@ import type { PluginAuthor, PluginManifest, - PluginUpdateSource, } from "../../lib/plugin-manifest.js"; import { PLUGIN_MANIFEST_ERROR_PREFIX as ERROR_PREFIX } from "../../lib/plugin-manifest.js"; -import { updateSourceUrlProblem } from "../../lib/updates/source-url.js"; export const PLUGIN_MANIFEST_PATH = "src/manifest.gen.ts"; @@ -14,7 +12,7 @@ export const PLUGIN_INFO_PATH = "src/plugin-info.ts"; export type PluginPackageManifest = Pick< PluginManifest, "author" | "description" | "id" | "license" | "version" -> & { updates: null | PluginUpdateSource }; +>; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -78,36 +76,6 @@ const authorFrom = (value: unknown, source: string): null | PluginAuthor => { }; }; -const updatesFrom = ( - value: unknown, - source: string, -): null | PluginUpdateSource => { - if (value === undefined || value === null) return null; - if (!isRecord(value)) { - throw new Error( - `${ERROR_PREFIX} \`vitnode\` in ${source} has to be an object.`, - ); - } - - const updates = value.updates; - if (updates === undefined || updates === null) return null; - if (!isRecord(updates) || typeof updates.url !== "string") { - throw new Error( - `${ERROR_PREFIX} \`vitnode.updates\` in ${source} has to be an object with a \`url\`, such as { "url": "https://raw.githubusercontent.com/acme/plugins/main/updates.json" }.`, - ); - } - - const url = updates.url.trim(); - const problem = updateSourceUrlProblem(url); - if (problem !== null) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.updates.url\` in ${source} ${problem}.`, - ); - } - - return { url }; -}; - export const pluginManifestFromPackageJson = ( manifest: unknown, source: string, @@ -128,7 +96,6 @@ export const pluginManifestFromPackageJson = ( description: optionalText(manifest.description, "description", source), id, license: optionalText(manifest.license, "license", source), - updates: updatesFrom(manifest.vitnode, source), version: optionalText(manifest.version, "version", source), }; }; @@ -153,10 +120,10 @@ export const generatePluginManifestSource = ( // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by \`vitnode build\` and \`vitnode dev\` from this package's -// package.json - \`name\`, \`version\`, \`description\`, \`author\`, \`license\` and -// \`vitnode.updates\` - and the title and -// icon in \`src/plugin-info.ts\`. Pass \`manifest\` to \`buildPlugin\`, -// \`buildApiPlugin\` and \`bindApiPlugin\` - it is where the plugin id lives. +// package.json - \`name\`, \`version\`, \`description\`, \`author\` and \`license\` - +// and the title, icon and update source in \`src/plugin-info.ts\`. Pass +// \`manifest\` to \`buildPlugin\`, \`buildApiPlugin\` and \`bindApiPlugin\` - it is +// where the plugin id lives. ${hasInfo ? `\nimport { pluginInfo } from "./plugin-info"\n` : ""} export const manifest = { id: ${literal(fields.id)}, @@ -166,6 +133,6 @@ export const manifest = { author: ${authorLiteral(fields.author)}, license: ${literal(fields.license)}, version: ${literal(fields.version)}, - updates: ${fields.updates === null ? "null" : `{ url: ${literal(fields.updates.url)} }`}, + updates: ${hasInfo ? "pluginInfo.updates ?? null" : "null"}, } as const `; diff --git a/packages/vitnode/src/lib/plugin-manifest.ts b/packages/vitnode/src/lib/plugin-manifest.ts index 82965a081..eb4f30e2a 100644 --- a/packages/vitnode/src/lib/plugin-manifest.ts +++ b/packages/vitnode/src/lib/plugin-manifest.ts @@ -4,6 +4,7 @@ import { updateSourceUrlProblem } from "./updates/source-url"; export interface PluginInfo { icon?: string; title?: string; + updates?: PluginUpdateSource; } export interface PluginAuthor { @@ -12,7 +13,6 @@ export interface PluginAuthor { url: null | string; } -/** `vitnode.updates` in a plugin's package.json: where its public update manifest lives. */ export interface PluginUpdateSource { url: string; } @@ -55,7 +55,7 @@ export const assertPluginManifest = ( const problem = updateSourceUrlProblem(updatesUrl); if (problem !== null) { throw new Error( - `${PLUGIN_MANIFEST_ERROR_PREFIX} ${where} gives the plugin "${pluginId}" the update source ${JSON.stringify(updatesUrl)}, which ${problem}. Set \`vitnode.updates.url\` in the plugin's package.json.`, + `${PLUGIN_MANIFEST_ERROR_PREFIX} ${where} gives the plugin "${pluginId}" the update source ${JSON.stringify(updatesUrl)}, which ${problem}. Set \`updates.url\` in the plugin's \`src/plugin-info.ts\`.`, ); } } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 20eff1c2f..a8ae193e7 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -3730,10 +3730,6 @@ "desc": "Try the plugin's name, its package ID or a word from its description.", "clear": "Clear search" }, - "inventory_missing": { - "title": "Showing the API's plugins only", - "desc": "This API was not given the web app's plugin inventory, so plugins that run only in the browser are not listed and frontend versions are not compared. Pass frontendPlugins to buildApiConfig to see both." - }, "core": { "title": "VitNode core", "version": "Installed version {version}" @@ -3768,10 +3764,8 @@ }, "summary": { "last_checked": "Last checked ", - "never": "The daily check runs with the cron scheduler. Press Check for updates to run it now.", "disabled": "Update checks are turned off for this installation.", - "channel": "Channel: {channel} (set in the API config)", - "info": "Update information is informational only: nothing is installed or changed. No security update listed is not proof that a version is secure." + "channel": "Channel: {channel} (set in the API config)" }, "status": { "not_checked": "Not checked yet", diff --git a/packages/vitnode/src/tanstack/admin/tab-presence.test.tsx b/packages/vitnode/src/tanstack/admin/tab-presence.test.tsx index f2a88cca2..98c22de7e 100644 --- a/packages/vitnode/src/tanstack/admin/tab-presence.test.tsx +++ b/packages/vitnode/src/tanstack/admin/tab-presence.test.tsx @@ -69,7 +69,7 @@ describe("isAdminSessionStillInUse", () => { it("is in use while a quiet background tab still answers", async () => { localStorage.setItem( ADMIN_TAB_HEARTBEAT_KEY, - String(Date.now() - 10 * 60_000), + String(Date.now() - ADMIN_TAB_HEARTBEAT_FRESH_MS - 60_000), ); const stop = answerAdminTabPings(); @@ -80,7 +80,7 @@ describe("isAdminSessionStillInUse", () => { it("is abandoned once every AdminCP tab was closed", async () => { localStorage.setItem( ADMIN_TAB_HEARTBEAT_KEY, - String(Date.now() - 10 * 60_000), + String(Date.now() - ADMIN_TAB_HEARTBEAT_FRESH_MS - 60_000), ); await expect(isAdminSessionStillInUse()).resolves.toBe(false); diff --git a/packages/vitnode/src/tanstack/admin/tab-presence.ts b/packages/vitnode/src/tanstack/admin/tab-presence.ts index 801f10ae9..359c4e676 100644 --- a/packages/vitnode/src/tanstack/admin/tab-presence.ts +++ b/packages/vitnode/src/tanstack/admin/tab-presence.ts @@ -4,7 +4,7 @@ export const ADMIN_TAB_CHANNEL = "vitnode:admin-tabs"; export const ADMIN_TAB_HEARTBEAT_INTERVAL_MS = 5_000; -export const ADMIN_TAB_HEARTBEAT_FRESH_MS = 15_000; +export const ADMIN_TAB_HEARTBEAT_FRESH_MS = 60 * 60_000; export const ADMIN_TAB_PING_TIMEOUT_MS = 750; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx index 253837e87..36519f5c8 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/core-update-card.tsx @@ -16,13 +16,17 @@ const lastCheckedOf = (updates: AdminPluginUpdates): null | string => null, ); -const CheckSummary = ({ updates }: { updates: AdminPluginUpdates }) => { +const CheckSummary = ({ + lastChecked, + updates, +}: { + lastChecked: null | string; + updates: AdminPluginUpdates; +}) => { const t = useTranslations("admin.system.plugins.updates"); if (!updates.enabled) return <>{t("summary.disabled")}; - - const lastChecked = lastCheckedOf(updates); - if (lastChecked === null) return <>{t("summary.never")}; + if (lastChecked === null) return null; return ( <> @@ -33,6 +37,31 @@ const CheckSummary = ({ updates }: { updates: AdminPluginUpdates }) => { ); }; +const CheckFooter = ({ updates }: { updates: AdminPluginUpdates }) => { + const t = useTranslations("admin.system.plugins"); + const lastChecked = lastCheckedOf(updates); + const hasSummary = !updates.enabled || lastChecked !== null; + + if (!hasSummary && updates.channelOverride === null) return null; + + return ( +
+ {hasSummary ? ( +

+ +

+ ) : null} + {updates.channelOverride === null ? null : ( +

+ {t("updates.summary.channel", { + channel: t(`updates.channel.${updates.channelOverride}`), + })} +

+ )} +
+ ); +}; + /** * Core's own row, kept apart from the plugins: its version, its update status * and any securityUpdate against it, plus when everything was last checked. @@ -84,21 +113,7 @@ export const CoreUpdateCard = ({ - {updates === undefined ? null : ( -
-

- -

- {updates.channelOverride === null ? null : ( -

- {t("updates.summary.channel", { - channel: t(`updates.channel.${updates.channelOverride}`), - })} -

- )} -

{t("updates.summary.info")}

-
- )} + {updates === undefined ? null : } ); }; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx index e0260e948..50506b201 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx @@ -12,7 +12,6 @@ import { readTableSearch, withTableSearch, } from "@/components/table/url-state"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import type { VersionSides } from "./plugin-update-status"; @@ -148,14 +147,6 @@ export const PluginsContent = ({ data, updates }: PluginsContentProps) => { return ( - {data.frontendInventory ? null : ( - - {t("inventory_missing.title")} - - {t("inventory_missing.desc")} - - - )} ; + dir: string; name: string; - updatesUrl: null | string; version: null | string; } +const MANIFEST_FILES = ["src/manifest.gen.ts", "src/manifest.ts"]; + +const updatesUrlOf = async (dir: string): Promise => { + const file = MANIFEST_FILES.map(name => join(dir, name)).find(existsSync); + if (file === undefined) return null; + + const { manifest } = (await import(pathToFileURL(file).href)) as { + manifest?: { updates?: null | { url?: string } }; + }; + + return manifest?.updates?.url ?? null; +}; + const readWorkspacePackages = (): Map => { const packages = new Map(); @@ -98,14 +112,13 @@ const readWorkspacePackages = (): Map => { name?: string; peerDependencies?: Record; version?: string; - vitnode?: { updates?: { url?: string } }; }; if (!json.name) continue; packages.set(json.name, { dependencies: { ...json.peerDependencies, ...json.dependencies }, + dir: join(root, dir), name: json.name, - updatesUrl: json.vitnode?.updates?.url ?? null, version: json.version ?? null, }); } @@ -148,15 +161,20 @@ const githubReleaseFor = ( * published but declares another update source - or none - is not ours to * advertise here. */ -const publishedPackages = (feedUrl: string): PublishedPackage[] => { +const publishedPackages = async ( + feedUrl: string, +): Promise => { const names = requireEnv("PUBLISHED_PACKAGES").split(/\s+/).filter(Boolean); const workspacePackages = readWorkspacePackages(); const coreVersion = workspacePackages.get(CORE_ID)?.version ?? null; - return names.flatMap(name => { + const packages: PublishedPackage[] = []; + for (const name of names) { const pkg = workspacePackages.get(name); if (!pkg) throw new Error(`${name} is not a package in this workspace.`); - if (pkg.updatesUrl?.toLowerCase() !== feedUrl.toLowerCase()) return []; + + const updatesUrl = await updatesUrlOf(pkg.dir); + if (updatesUrl?.toLowerCase() !== feedUrl.toLowerCase()) continue; if (!pkg.version) throw new Error(`${name} has no version.`); const requiresCore = @@ -164,8 +182,10 @@ const publishedPackages = (feedUrl: string): PublishedPackage[] => { ? undefined : requiresCoreFrom(pkg.dependencies[CORE_ID], coreVersion); - return [{ id: name, requiresCore, version: pkg.version }]; - }); + packages.push({ id: name, requiresCore, version: pkg.version }); + } + + return packages; }; const publishToBranch = ({ @@ -258,7 +278,7 @@ const readSecurityUpdates = () => readFileSync(join(workspace, SECURITY_UPDATES_FILE), "utf8"), ); -const main = () => { +const main = async () => { const mode = process.argv[2]; if (mode === "check") { @@ -289,7 +309,7 @@ const main = () => { if (mode === "release") { const channel = channelForReleaseType(requireEnv("RELEASE_TYPE")); - const packages = publishedPackages(feedUrl); + const packages = await publishedPackages(feedUrl); if (packages.length === 0) { console.log("No published package points at this update manifest."); @@ -327,9 +347,7 @@ const main = () => { ); }; -try { - main(); -} catch (error) { +main().catch((error: unknown) => { console.error(`❌ ${error instanceof Error ? error.message : String(error)}`); process.exit(1); -} +});