Skip to content

Implement personal access tokens, storage profiles, and deployments management#35

Open
krishna-santosh wants to merge 16 commits into
mainfrom
dev
Open

Implement personal access tokens, storage profiles, and deployments management#35
krishna-santosh wants to merge 16 commits into
mainfrom
dev

Conversation

@krishna-santosh

@krishna-santosh krishna-santosh commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Added account personal access tokens with fine-grained scopes, one-time secret reveal, listing, and revocation.
    • Introduced deployment triggers and automated deployment jobs with history, cooldown/quota controls, and “Deploy now” support.
    • Added configurable storage profiles (with probing) and destination selection for sites, backups, and scheduled backups.
    • Added deployment and MCP connection/admin screens, plus expanded MCP multi-site access.
  • Enhancements
    • Updated token authorization to enforce access via scopes (including new deployment-related permissions).
    • Improved UI behavior for role-based navigation and read-only site settings.
    • Restored interrupted deployment jobs on startup.

New migration creating tables for personal access tokens, deployments (triggers/jobs), and storage profiles across all three backends.
Replace simple read/write permission model with fine-grained TokenScope enum supporting site, content, files, schema, webhooks, deployments, and MCP scopes. Add PersonalAccessToken types, encode/decode helpers, and backward-compatible 'read'/'write' aliases. Update authorization model with new Action variants (WebhooksTrigger, Deployments*) and token_hard_denied helper. Extend repository traits with personal token storage interface and implement across all three backends.
…ment

Introduce PersonalTokenActor with scope-based auth enforcement. Support vcms_pat_ token prefix in API auth middleware. Resolve personal token site context via X-VCMS-Site header. Add PAT CRUD (create/list/revoke) service, handler, and /account/tokens routes. Update GraphQL context to pass requested site and scopes. Provide in-memory PAT repository stubs for testing.
Register deployment and storage_profile modules in lib.rs and services. Add deployment and storage profile routes in router. Wire storage profile registration and deployment reconcilation at server startup. Add storage_profile_id to CLI backup create options.
Introduce StorageProfile model, service (CRUD + probe), and HTTP handler. Add storage_profile_id optional column to Site model and include it in all site queries across backends. Assign storage profile on site creation. Expose profile ID in site listing responses. Wire storage profile CRUD and probe routes under /instance/storage-profiles. Update file storage resolution to COALESCE storage profile over legacy storage_provider.
Add DestinationSpec::Profile variant for routing backups through configured storage profiles. Include storage_profile_id in backup and schedule creation payloads. Persist profile ID in backup metadata tables.
Add DeploymentTrigger and DeploymentJob models, service with reconcile_interrupted for crash recovery, HTTP handler, and routes under /sites/{site_id}/deployments. Extend WebhookService with protect/reveal_deployment_config helpers for encrypting deployment URL and headers.
Add list_sites tool returning sites accessible to the authenticated user. Enforce that all site-scoped MCP tools require an explicit site_id parameter (except list_sites). Clean up whitespace (CRLF→LF) and update test assertions to match.
Add API functions and TypeScript types for scoped site tokens, personal access tokens (PAT), storage profiles (CRUD + probe), deployments (triggers, jobs, history), and storage_profile_id on backups and sites.
Add PersonalTokensCard component for creating, listing, and revoking personal access tokens. Integrate into the account page.
…ge profiles

Rebuild storage settings page with storage profile CRUD (create, list, probe, delete). Add storage profile picker to the create-site dialog. Require operator role instead of instance_owner only.
…authorization service

Pass X-VCMS-Site header from GraphQL router to GqlContext for personal token site resolution. Update AuthorizationService to use scope_for_action and PersonalTokenActor for scope-based enforcement.
…backup selector, and update routing

Add deployments management page under site settings. Add MCP configuration page. Add storage profile selector to backup creation form. Update sidebar with new navigation items. Regenerate route tree. Pass role prop to GeneralSection. Update site settings layout and routing.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4de3679a-52b1-434d-8ae8-47330dd21620

📥 Commits

Reviewing files that changed from the base of the PR and between 434500c and f2c3f28.

📒 Files selected for processing (6)
  • apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql
  • apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql
  • apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql
  • apps/backend/src/graphql/context.rs
  • apps/backend/src/services/deployment.rs
  • apps/backend/src/services/webhook.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/backend/migrations/sqlite/20260721000000_tokens_deployments_storage.sql
  • apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql
  • apps/backend/src/graphql/context.rs
  • apps/backend/src/services/webhook.rs
  • apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql
  • apps/backend/src/services/deployment.rs

📝 Walkthrough

Walkthrough

This change adds scoped personal access tokens, configurable storage profiles, deployment trigger execution, and multi-site MCP support across database migrations, backend services and routes, authentication, backup flows, and dashboard interfaces.

Changes

Scoped token authentication

Layer / File(s) Summary
Token contracts and persistence
apps/backend/migrations/*, apps/backend/src/models/access_token.rs, apps/backend/src/repository/*/access_token.rs, apps/backend/src/repository/traits.rs
Adds scope sets, personal-token models, database storage, lookup, revocation, and usage tracking across SQLite, PostgreSQL, and MySQL.
Authentication and account APIs
apps/backend/src/middleware/*, apps/backend/src/graphql/context.rs, apps/backend/src/handlers/access_token_handler.rs, apps/backend/src/router/access_tokens.rs, apps/backend/src/services/access_token.rs
Adds personal-token verification, scope-based authorization, account token endpoints, and scoped site-token handling.
Token management UI
apps/dashboard/src/components/account/personal-tokens-card.tsx, apps/dashboard/src/routes/_admin/_shell/account.tsx, apps/dashboard/src/lib/api.ts
Provides token creation, scope selection, one-time secret copying, listing, and revocation.

Storage profile integration

Layer / File(s) Summary
Storage persistence and service
apps/backend/migrations/*, apps/backend/src/models/storage_profile.rs, apps/backend/src/services/storage_profile.rs
Adds storage profile tables, encrypted credentials, CRUD, probing, provider construction, registry registration, and site assignment.
Backup and site propagation
apps/backend/src/services/backup/*, apps/backend/src/handlers/backup_handler.rs, apps/backend/src/handlers/site_handler.rs, apps/backend/src/repository/*/{file,site}.rs
Persists selected storage profiles for sites, backups, and schedules, and resolves profile-backed backup destinations.
Storage dashboard controls
apps/dashboard/src/routes/_admin/_shell/index.tsx, apps/dashboard/src/routes/_admin/_shell/settings/storage.tsx, apps/dashboard/src/components/backups/backups-section.tsx
Adds storage profile administration and destination selectors for site creation and backup workflows.

Deployment automation

Layer / File(s) Summary
Deployment schema and service
apps/backend/migrations/*, apps/backend/src/models/deployment.rs, apps/backend/src/services/deployment.rs, apps/backend/src/services/webhook.rs
Adds deployment triggers and jobs, encrypted webhook configuration, quotas, cooldowns, asynchronous execution, status tracking, and startup reconciliation.
HTTP routes and wiring
apps/backend/src/handlers/deployment_handler.rs, apps/backend/src/router/deployments.rs, apps/backend/src/router/mod.rs, apps/backend/src/services/mod.rs
Exposes deployment CRUD, triggering, history, authorization, and service initialization.
Deployment dashboard
apps/dashboard/src/routes/_admin/sites.$siteId/deployments.tsx, apps/dashboard/src/components/sidebar/app-sidebar.tsx, apps/dashboard/src/lib/api.ts
Adds trigger creation, deployment execution, history polling, status rendering, and role-gated navigation.

Multi-site MCP access

Layer / File(s) Summary
MCP authentication and tool inputs
apps/backend/src/mcp/auth.rs, apps/backend/src/mcp/tools/*
Accepts personal tokens with mcp.use and requires explicit site_id parameters for MCP operations.
MCP discovery
apps/backend/src/mcp/resources/site_schema.rs, apps/backend/src/mcp/server.rs, apps/backend/src/services/site.rs
Lists accessible sites and produces authorized schema resources for each site.
MCP dashboard guidance
apps/dashboard/src/routes/_admin/sites.$siteId/settings/mcp.tsx, apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx, apps/dashboard/src/routeTree.gen.ts
Adds MCP connection instructions and generated route-tree entries.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • velopulent/cms#3: Both changes modify backend authentication and token plumbing.
  • velopulent/cms#10: Both changes modify access-token prefix handling in GraphQL authentication.
  • velopulent/cms#34: Both changes modify backend runtime wiring and request handling around site and service behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main additions in the changeset: personal access tokens, storage profiles, and deployment management.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/backend/src/handlers/site_handler.rs (1)

69-84: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Site is left orphaned if assign_site fails after creation.

create_site creates the site, then separately assigns the storage profile. If assign_site fails, the handler returns a bare 400 error — the site row already exists (with the default payload.storage_provider, not the requested profile) but the client has no indication of that or a reference to clean it up.

♻️ Suggested direction

Either delete the just-created site on assignment failure, or include the created site's id in the error response so the caller can reconcile/retry the assignment instead of re-creating the site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/handlers/site_handler.rs` around lines 69 - 84, Update the
error branch in the site creation handler around create_site and assign_site so
a failed storage profile assignment does not leave the client without
reconciliation information: either delete the newly created site before
returning the error, or include the created site’s id in the error response.
Preserve the successful response and existing assignment behavior.
🟠 Major comments (18)
apps/dashboard/src/components/site-settings/general-section.tsx-41-45 (1)

41-45: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve owner/admin roles and separate management capabilities.

apps/dashboard/src/components/site-settings/use-site-role.ts:12-21 maps every role other than "editor" to "viewer" and sets canManage only for instance operators. The new branch therefore displays site owners/admins as viewers and removes their allowed site-management UI. Widen the role contract and use separate capability flags so operator-only actions, such as site deletion, remain restricted.

As per coding guidelines, instance roles and site roles must follow the documented RBAC model, including owner/admin powers, implicit site authority for instance operators, and operator-only access to schema/webhooks/API keys/member management/site deletion.

Also applies to: 102-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/site-settings/general-section.tsx` around lines
41 - 45, Update the site-role flow centered on use-site-role.ts and the
GeneralSection role contract to preserve owner and admin roles instead of
mapping them to viewer. Widen the role type to include the documented RBAC
roles, derive site-management capability separately from operator-only
capabilities, and ensure instance operators retain implicit site authority while
schema, webhooks, API keys, member management, and site deletion remain
restricted to operators.

Source: Coding guidelines

apps/backend/src/mcp/resources/site_schema.rs-55-58 (1)

55-58: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce resource-specific scopes.

Line 55 checks only SiteRead, so a token lacking schema.read or content.read can enumerate these resources; read_resource repeats that same check and allows direct reads. Filter listed resources by their required action and authorize /schema and collections with SchemaRead, and singletons with ContentRead before loading them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/mcp/resources/site_schema.rs` around lines 55 - 58, Update
the resource listing and read paths around the existing SiteRead authorization
and read_resource flow to enforce resource-specific actions: filter enumerated
resources using SchemaRead for /schema and ContentRead for collections or
singletons, and require SchemaRead or ContentRead respectively before loading
direct resources. Retain SiteRead as the general site-level check, but do not
allow it alone to list or read protected resources.
apps/backend/src/mcp/server.rs-51-57 (1)

51-57: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Filter site discovery by site.read.

Line 54 returns every membership for a personal token after only the mcp.use middleware check. A token without site.read can therefore enumerate site IDs and names. Filter each result through self.authorizer.require_site_action(&actor, site_id, Action::SiteRead) before serializing it.

Suggested fix
+use crate::models::authorization::Action;
+
     async fn list_sites(&self, ctx: RequestContext<RoleServer>) -> Result<CallToolResult, McpError> {
         let actor = self.resolve_actor(&ctx)?;
-        match self.services.site.list_sites_for_actor(&actor).await {
-            Ok(v) => crate::mcp::auth::ok_result(&v),
+        match self.services.site.list_sites_for_actor(&actor).await {
+            Ok(sites) => {
+                let mut visible_sites = Vec::new();
+                for site in sites {
+                    let Some(site_id) = site.get("id").and_then(|value| value.as_str()) else {
+                        continue;
+                    };
+                    if self.authorizer.require_site_action(&actor, site_id, Action::SiteRead).await.is_ok() {
+                        visible_sites.push(site);
+                    }
+                }
+                crate::mcp::auth::ok_result(&visible_sites)
+            }
             Err(e) => Err(crate::mcp::auth::map_err(e)),
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/mcp/server.rs` around lines 51 - 57, Update list_sites to
enforce Action::SiteRead for each site returned by list_sites_for_actor before
serialization. Use self.authorizer.require_site_action with the resolved actor
and each site ID, retain only authorized sites, and pass the filtered results to
ok_result while preserving existing error mapping.
apps/backend/src/handlers/storage_profile_handler.rs-43-48 (1)

43-48: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make profile persistence and registry replacement atomic.

A failed register_all returns an error after the database mutation has already succeeded. Creation leaves an unregistered profile persisted; update first removes the working provider, so failure leaves the persisted update without a usable registry entry.

  • apps/backend/src/handlers/storage_profile_handler.rs#L43-L48: roll back the newly created profile, or atomically validate/register it before reporting success.
  • apps/backend/src/handlers/storage_profile_handler.rs#L80-L86: retain or restore the previous provider and persisted profile when replacement registration fails.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/handlers/storage_profile_handler.rs` around lines 43 - 48,
Make storage profile persistence and registry replacement atomic in the create
flow around storage_profile.create and register_all: validate/register before
committing, or roll back the newly persisted profile if registration fails. In
the update flow around the replacement logic at lines 80-86, retain or restore
the previous provider and persisted profile when register_all fails, so failed
replacements leave the original working state intact.
apps/backend/src/server.rs-73-80 (1)

73-80: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A single bad storage profile blocks the entire server from starting.

register_all propagates the first build_provider error via ? for every enabled S3 profile (services/storage_profile.rs lines 211-224), and here that error aborts run() entirely via .map_err(...)?. One profile with stale/undecryptable credentials or an unreachable endpoint prevents the whole instance from booting — even for sites/backups unrelated to that profile. Contrast with initialize_storage() just above, which warns and continues on provider init failure, and with reconcile_interrupted() right below, which logs and continues rather than failing hard.

♻️ Suggested direction

Have register_all collect and log per-profile failures instead of short-circuiting the whole loop, so one broken profile doesn't prevent unrelated storage from being registered/the server from starting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/server.rs` around lines 73 - 80, Update
StorageProfile::register_all so it handles each profile’s build_provider failure
independently: log the failing profile and error, then continue registering the
remaining profiles instead of propagating the first error. Adjust the server
startup call in run to avoid aborting on register_all failure while preserving
startup and the existing reconciliation flow.
apps/backend/src/services/storage_profile.rs-22-27 (1)

22-27: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Storage-profile credentials reuse the webhook encryption key.

StorageProfileService::new derives its AES key from config.webhook_encryption_key (per services/mod.rs wiring: storage_profile::StorageProfileService::new(pool.clone(), &config.webhook_encryption_key)), the same secret that seeds WebhookService's encryption key. Reusing one secret to derive keys for two independent encryption domains removes cryptographic separation between them — compromise or rotation of one affects the other.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/storage_profile.rs` around lines 22 - 27, Update
StorageProfileService::new and its services/mod.rs wiring to accept and derive
from a dedicated storage-profile encryption secret instead of
config.webhook_encryption_key. Add or use the corresponding configuration key,
pass it only to StorageProfileService, and keep WebhookService on its existing
webhook-specific secret.
apps/backend/src/services/storage_profile.rs-112-138 (1)

112-138: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

reference_count ignores completed backups, letting a profile with historical backups be deleted/disabled.

The backups sub-query only counts status IN ('pending','running'). A profile that has many completed backups but no active jobs, sites, or schedules will report reference_count() == 0, so delete() (line 139) and disabling in update() (line 65) both succeed. Once the profile is removed from the registry, restoring any of those completed backups later fails at resolve_destination (services/backup/mod.rs) because self.storage.get(&id) can't find the profile anymore — with no warning given at delete/disable time.

♻️ Suggested direction

Count all backups referencing the profile regardless of status (or surface a distinct warning/blocking message when historical backups exist), e.g.:

-SELECT (SELECT COUNT(*) FROM sites WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backup_schedules WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backups WHERE storage_profile_id=? AND status IN ('pending','running'))
+SELECT (SELECT COUNT(*) FROM sites WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backup_schedules WHERE storage_profile_id=?) + (SELECT COUNT(*) FROM backups WHERE storage_profile_id=?)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/storage_profile.rs` around lines 112 - 138, Update
reference_count to count every backup whose storage_profile_id matches, removing
the pending/running status filter in the SQLite, PostgreSQL, and MySQL
subqueries. Preserve the existing counts for sites and backup_schedules and the
delete/update callers’ handling of the returned total.
apps/backend/src/services/deployment.rs-52-54 (1)

52-54: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Race on is_primary invariant (clear-then-insert/update, not transactional).

clear_primary and the subsequent insert/update are two separate statements with no transaction. Two concurrent create/update calls with is_primary=true can both pass the clear step before either writes its row, leaving more than one primary trigger for the site.

Also applies to: 91-93

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/deployment.rs` around lines 52 - 54, Make the
primary-trigger mutation in the create/update flow atomic so concurrent calls
cannot leave multiple primary rows for a site. Update the logic around
clear_primary and the subsequent insert/update to execute as one transaction,
preserving the is_primary behavior while ensuring the clear and write are
serialized.
apps/backend/src/services/deployment.rs-10-22 (1)

10-22: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

DeploymentService bypasses the repository layer.

DeploymentService holds DbPool directly and issues raw sqlx queries throughout (reconcile_interrupted, list, create, update, delete, history, insert_job, load_secret, finish), unlike WebhookService in the same PR, which delegates all persistence to a webhook_repo: Arc<dyn WebhookRepository>. The repo's stated architecture puts data access in apps/backend/src/repository/.

As per coding guidelines, "Data access code must live in apps/backend/src/repository/."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/deployment.rs` around lines 10 - 22, Refactor
DeploymentService so it no longer stores DbPool or performs direct sqlx queries
in reconcile_interrupted and the other deployment persistence methods (list,
create, update, delete, history, insert_job, load_secret, and finish). Introduce
and inject an Arc-backed deployment repository from the repository layer, then
delegate all data access through that repository while preserving the existing
service API and behavior.

Source: Coding guidelines

apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql-4-4 (1)

4-4: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Missing FK/CHECK constraints present in the Postgres/SQLite siblings.

storage_profiles.kind has no CHECK(kind IN ('filesystem','s3')) here (Postgres/SQLite both enforce it), and sites.storage_profile_id is added without a FOREIGN KEY REFERENCES storage_profiles(id) (Postgres/SQLite add it inline). MySQL 8.0.16+ supports CHECK constraints, so both gaps are avoidable — leaving MySQL as the only backend where an invalid kind or an orphaned storage_profile_id can be persisted.

🛡️ Proposed fix
-CREATE TABLE IF NOT EXISTS storage_profiles (id VARCHAR(36) PRIMARY KEY,name VARCHAR(255) NOT NULL UNIQUE,kind VARCHAR(20) NOT NULL,endpoint TEXT,region VARCHAR(255),bucket VARCHAR(255),public_url TEXT,credentials_encrypted TEXT,enabled BOOLEAN NOT NULL DEFAULT TRUE,immutable BOOLEAN NOT NULL DEFAULT FALSE,created_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY(created_by) REFERENCES users(id));
+CREATE TABLE IF NOT EXISTS storage_profiles (id VARCHAR(36) PRIMARY KEY,name VARCHAR(255) NOT NULL UNIQUE,kind VARCHAR(20) NOT NULL CHECK(kind IN ('filesystem','s3')),endpoint TEXT,region VARCHAR(255),bucket VARCHAR(255),public_url TEXT,credentials_encrypted TEXT,enabled BOOLEAN NOT NULL DEFAULT TRUE,immutable BOOLEAN NOT NULL DEFAULT FALSE,created_by VARCHAR(36),created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,FOREIGN KEY(created_by) REFERENCES users(id));
...
-ALTER TABLE sites ADD COLUMN storage_profile_id VARCHAR(36) NULL;
+ALTER TABLE sites ADD COLUMN storage_profile_id VARCHAR(36) NULL;
+ALTER TABLE sites ADD CONSTRAINT fk_sites_storage_profile FOREIGN KEY (storage_profile_id) REFERENCES storage_profiles(id);

Also applies to: 6-6

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql`
at line 4, Update the MySQL migration’s storage_profiles definition to enforce
kind as either filesystem or s3 with a CHECK constraint, and update the
sites.storage_profile_id addition to include a foreign key referencing
storage_profiles(id). Keep the constraints aligned with the corresponding
Postgres and SQLite migrations.
apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql-12-12 (1)

12-12: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

No DB-level guarantee of a single primary trigger per site in MySQL.

Postgres (CREATE UNIQUE INDEX ... ON deployment_triggers(site_id) WHERE is_primary=TRUE) and SQLite enforce "only one primary trigger per site" at the schema level. MySQL has no equivalent partial/unique constraint here, so concurrent create/update calls setting is_primary=true on this backend rely entirely on the service-layer clear_primary step (a separate statement, not shown to be transactional with the following insert/update) — a TOCTOU window that could leave two primary triggers for the same site on MySQL only.

MySQL doesn't support partial unique indexes, but a generated-column workaround achieves the same guarantee:

ALTER TABLE deployment_triggers ADD COLUMN primary_site_id VARCHAR(36) GENERATED ALWAYS AS (IF(is_primary, site_id, NULL)) STORED;
CREATE UNIQUE INDEX idx_deployment_primary ON deployment_triggers(primary_site_id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/migrations/mysql/20260721000000_tokens_deployments_storage.sql`
at line 12, The MySQL deployment_triggers schema lacks a database-level
guarantee of one primary trigger per site. Update the deployment_triggers
definition to add a stored generated primary_site_id column that contains
site_id only when is_primary is true, then add a unique index on that column so
concurrent create/update operations cannot create duplicate primaries.
apps/backend/src/middleware/auth.rs-253-279 (1)

253-279: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Personal-token auth pays a bcrypt hash on every request — no HMAC fast path. The site-API-key path checks token_hmac first, falling back to bcrypt only for legacy rows; the personal-token path always calls bcrypt::verify because the lookup row never carries token_hmac, even though the schema and NewPersonalToken store it.

  • apps/backend/src/middleware/auth.rs#L253-L279: extend the personal-token branch to compare compute_key_hmac(token, hmac_secret) against a returned token_hmac first, matching the ApiKey branch's pattern, falling back to bcrypt only when hmac is absent.
  • apps/backend/src/repository/mysql/access_token.rs#L94-L120: add token_hmac to the find_personal_by_prefix SELECT list (and update the shared PersonalTokenLookupRow tuple type plus the Postgres/SQLite equivalents accordingly).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/middleware/auth.rs` around lines 253 - 279, Update the
personal-token authentication branch around the personal-token lookup to compare
compute_key_hmac(token, hmac_secret) with the returned token_hmac before
verifying with bcrypt, falling back to bcrypt only when token_hmac is absent;
preserve rejection for mismatches and existing validation behavior. Extend
find_personal_by_prefix and the shared PersonalTokenLookupRow tuple, including
the Postgres and SQLite implementations, to select and return token_hmac.
Affected sites: apps/backend/src/middleware/auth.rs:253-279 requires the
HMAC-first authentication change;
apps/backend/src/repository/mysql/access_token.rs:94-120 requires adding
token_hmac to the SELECT and lookup tuple, with equivalent updates in the
Postgres and SQLite implementations.
apps/backend/src/repository/postgres/access_token.rs-94-120 (1)

94-120: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

find_personal_by_prefix doesn't project token_hmac.

Unlike the site-token find_by_prefix (which selects token_hmac), this query only selects id,user_id,token_hash,expires_at,revoked_at,scopes_json,last_used_at. The personal_access_tokens row does have a token_hmac (written by create_personal above), it's just never read back here. See the consolidated note anchored at apps/backend/src/repository/traits.rs lines 292-312.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/repository/postgres/access_token.rs` around lines 94 - 120,
Update find_personal_by_prefix to include token_hmac in its SELECT projection,
matching the PersonalTokenLookupRow fields and the site-token find_by_prefix
query. Preserve the existing filters and returned columns while adding the
persisted HMAC value.
apps/backend/src/repository/sqlite/access_token.rs-94-113 (1)

94-113: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

find_personal_by_prefix omits token_hmac, same as postgres/access_token.rs.

See consolidated note anchored at apps/backend/src/repository/traits.rs lines 292-312.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/repository/sqlite/access_token.rs` around lines 94 - 113,
Update find_personal_by_prefix and its PersonalTokenLookupRow query mapping to
select and return token_hmac alongside token_hash, matching the corresponding
PostgreSQL implementation and the repository trait contract.
apps/backend/src/repository/sqlite/access_token.rs-47-56 (1)

47-56: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Same legacy-permission derivation issue as postgres/access_token.rs.

Identical .contains(".write") heuristic defaults an empty scope set to "read" for the legacy gRPC-consumed permission column. See consolidated note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/repository/sqlite/access_token.rs` around lines 47 - 56,
Update the access-token insertion logic in the SQLite repository to derive the
legacy permission value from the token’s scopes rather than using
permission.contains(".write"). Ensure empty scopes preserve the expected legacy
gRPC permission behavior, while retaining the correct write/read mapping for
applicable scoped tokens.
apps/backend/src/models/access_token.rs-147-148 (1)

147-148: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

No validation that scopes is non-empty.

CreateSiteToken.scopes accepts any TokenScopes, including an empty set. Combined with the repository-layer .write substring heuristic (see postgres/sqlite access_token.rs), a token created with zero scopes is persisted with a legacy permission = "read", granting unintended read access via the legacy gRPC auth path. Consider rejecting empty scope sets at creation time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/models/access_token.rs` around lines 147 - 148, Validate
that CreateSiteToken.scopes is non-empty before creating the token, and reject
empty TokenScopes values with the existing validation/error mechanism. Keep
valid scope handling unchanged and prevent empty scopes from reaching
persistence or legacy authorization.
apps/backend/src/repository/postgres/access_token.rs-47-56 (1)

47-56: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fragile legacy-permission derivation defaults to "read" for empty/no-write scope sets.

permission here is the JSON-encoded scopes string (encode_scopes output), and .contains(".write") is used to derive the legacy read/write column consumed by the gRPC interceptor (AccessTokenPermission::from_str). This works for the currently-defined *.write scope variants, but a token created with an empty scope set (not currently rejected anywhere — see models/access_token.rs::CreateSiteToken) serializes to "[]", which doesn't contain .write, so it's stored as "read" — granting unintended read access via the legacy gRPC path to a token that was meant to have zero scopes. This same logic is duplicated verbatim in sqlite/access_token.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/repository/postgres/access_token.rs` around lines 47 - 56,
Replace the `.contains(".write")` fallback used to derive the legacy
`permission` value in the Postgres access-token insert with logic that
distinguishes write, non-empty read, and empty scope sets, ensuring empty scopes
do not become `"read"` and grant legacy access. Apply the same change to the
duplicated derivation in the SQLite access-token insert, reusing the existing
scope representation or `encode_scopes` result consistently.
apps/backend/src/handlers/access_token_handler.rs-71-115 (1)

71-115: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Centralize personal-token mint policy

create_personal_token is still enforcing operator/scope rules locally instead of through the shared auth policy, so this mint-time RBAC can drift from models/authorization.rs. The current allowlist also blocks webhooks.trigger but still lets non-operators mint deployments.trigger; if that split is intentional, it should be encoded in the central policy, not here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/handlers/access_token_handler.rs` around lines 71 - 115,
Remove the local operator lookup and scope allowlist from create_personal_token,
and delegate mint-time RBAC to the shared authorization policy in
models/authorization.rs. Ensure the central policy explicitly defines the
intended permissions for WebhooksTrigger and DeploymentsTrigger so this handler
cannot drift from the shared rules.

Source: Coding guidelines

🟡 Minor comments (10)
apps/dashboard/src/routes/_admin/_shell/index.tsx-251-262 (1)

251-262: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude disabled profiles from site creation.

The backend only assigns enabled profiles, but this selector offers every profile. Selecting a disabled profile makes site creation fail with storage_profile_not_found.

Proposed fix
-                        {storageProfiles.map((profile) => (
+                        {storageProfiles
+                          .filter((profile) => profile.enabled)
+                          .map((profile) => (
                           <SelectItem key={profile.id} value={profile.id}>
                             <div className="flex items-center gap-2">
                               {profile.kind === "filesystem" ? (
@@
                             </div>
                           </SelectItem>
-                        ))}
+                          ))}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/routes/_admin/_shell/index.tsx` around lines 251 - 262,
Filter storageProfiles to exclude disabled profiles before rendering the
selector in the profile map, so site creation can only choose enabled profiles.
Preserve the existing SelectItem rendering and profile display for eligible
profiles.
apps/dashboard/src/routes/_admin/sites.$siteId/settings.tsx-35-43 (1)

35-43: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the active General tab reachable for non-managers.

For non-managers, the base route still resolves to active === "general" (Lines 20-22), but this change removes the only TabsTrigger with that value. The read-only page can render without a selected tab, and users have no tab navigation back to General. Keep the trigger visible for read-only users, or redirect/fallback to a valid tab.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/routes/_admin/sites`.$siteId/settings.tsx around lines 35
- 43, Update the General TabsTrigger visibility condition in the settings route
so it remains rendered for non-managers and stays reachable when the base route
resolves to active === "general". Preserve the existing canManage behavior and
navigation target while ensuring read-only users retain a valid selected tab.
apps/dashboard/src/routes/_admin/sites.$siteId/deployments.tsx-147-154 (1)

147-154: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Apply the editor/operator gate to the deployment action.

Direct navigation lets viewers invoke triggerDeployment, while app-sidebar.tsx only exposes Deploy to operators or editors. Reuse that same condition here to avoid an unauthorized action that only fails after clicking.

Proposed fix
-  const { canManage } = useSiteRole(siteId);
+  const { canManage, role } = useSiteRole(siteId);
+  const canDeploy = canManage || role === "editor";
...
-                <Button
-                  className="w-fit"
-                  disabled={!item.enabled || trigger.isPending}
-                  onClick={() => trigger.mutate(item.id)}
-                >
-                  <Rocket data-icon="inline-start" />
-                  Deploy now
-                </Button>
+                {canDeploy ? (
+                  <Button
+                    className="w-fit"
+                    disabled={!item.enabled || trigger.isPending}
+                    onClick={() => trigger.mutate(item.id)}
+                  >
+                    <Rocket data-icon="inline-start" />
+                    Deploy now
+                  </Button>
+                ) : null}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/routes/_admin/sites`.$siteId/deployments.tsx around lines
147 - 154, Update the deployment action in the deployments route so the Button
is disabled unless the current user satisfies the same operator-or-editor
permission condition used by app-sidebar.tsx. Preserve the existing item.enabled
and trigger.isPending checks, and ensure unauthorized viewers cannot invoke
trigger.mutate through this action.
apps/dashboard/src/components/account/personal-tokens-card.tsx-114-126 (1)

114-126: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Checkbox and its label aren't programmatically associated.

Neither Checkbox gets an id nor does FieldLabel get a matching htmlFor, so clicking the label text won't toggle the checkbox and screen readers won't announce the label for it.

♿ Proposed fix
                   <Field key={scope} orientation="horizontal">
                     <Checkbox
+                      id={`scope-${scope}`}
                       checked={scopes.includes(scope)}
                       onCheckedChange={(checked) => ... }
                     />
-                    <FieldLabel>{scope}</FieldLabel>
+                    <FieldLabel htmlFor={`scope-${scope}`}>{scope}</FieldLabel>
                   </Field>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/account/personal-tokens-card.tsx` around lines
114 - 126, Associate each scope checkbox with its label in the scopes rendering:
assign a unique, stable id to the Checkbox and pass that same value as
FieldLabel’s htmlFor. Keep the existing checked state and setScopes behavior
unchanged.
apps/dashboard/src/components/account/personal-tokens-card.tsx-186-195 (1)

186-195: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clipboard write isn't awaited/handled; success toast fires unconditionally.

navigator.clipboard.writeText(...) returns a promise that is neither awaited nor caught, so toast.success("Token copied") runs even if the copy fails (e.g. permission denied, insecure context, older browser).

🐛 Proposed fix
             <Button
               size="icon"
               variant="outline"
-              onClick={() => {
-                navigator.clipboard.writeText(secret ?? "");
-                toast.success("Token copied");
-              }}
+              onClick={async () => {
+                try {
+                  await navigator.clipboard.writeText(secret ?? "");
+                  toast.success("Token copied");
+                } catch {
+                  toast.error("Couldn't copy token");
+                }
+              }}
             >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/account/personal-tokens-card.tsx` around lines
186 - 195, Update the Button onClick handler in personal-tokens-card to await
navigator.clipboard.writeText and handle rejected promises before showing
feedback. Trigger the success toast only after a successful write, and add
failure handling that reports the copy error appropriately without leaving the
rejection unhandled.
apps/dashboard/src/components/account/personal-tokens-card.tsx-158-165 (1)

158-165: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

No confirmation before revoking a token.

Clicking the trash icon immediately calls revoke.mutate(token.id) with no confirm step. Revoking is irreversible and could break an in-use integration (CI, MCP client) with a single misclick.

🛡️ Suggested fix: gate the destructive action behind a confirm dialog/AlertDialog
-                <Button
-                  size="icon"
-                  variant="ghost"
-                  aria-label={`Revoke ${token.name}`}
-                  onClick={() => revoke.mutate(token.id)}
-                >
-                  <Trash2 />
-                </Button>
+                <Button
+                  size="icon"
+                  variant="ghost"
+                  aria-label={`Revoke ${token.name}`}
+                  onClick={() => {
+                    if (window.confirm(`Revoke "${token.name}"? This cannot be undone.`)) {
+                      revoke.mutate(token.id);
+                    }
+                  }}
+                >
+                  <Trash2 />
+                </Button>

(Prefer the project's AlertDialog component over window.confirm for consistency with the rest of the UI.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/account/personal-tokens-card.tsx` around lines
158 - 165, Update the revoke action in the personal tokens card to require
confirmation through the project’s AlertDialog before invoking
revoke.mutate(token.id). Keep the current revoke call as the confirmed action
and ensure cancelling or dismissing the dialog does not revoke the token.
apps/dashboard/src/components/account/personal-tokens-card.tsx-140-167 (1)

140-167: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show revoked tokens as revoked or hide them from the list
list_personal_tokens returns revoked_at, but this card ignores it, so revoked tokens can stay in the list looking active with a live revoke button. Either filter them out or render a revoked state and disable the action.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/account/personal-tokens-card.tsx` around lines
140 - 167, Update the token rendering in the data map to handle
token.revoked_at: either exclude revoked tokens from the list or visibly mark
them as revoked and disable the revoke Button for those entries. Ensure revoked
tokens cannot appear active or have an enabled revoke action.
apps/backend/src/services/storage_profile.rs-35-54 (1)

35-54: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Empty credentials aren't rejected at creation time.

create() validates name/bucket non-empty and the endpoint URL, but never checks that access_key_id/secret_access_key are non-empty. An empty-credential profile will be created successfully and only fail later, confusingly, at probe/build_provider time.

💡 Proposed fix
     pub async fn create(&self, v: CreateStorageProfile, by: &str) -> Result<StorageProfile, String> {
         if v.name.trim().is_empty() || v.bucket.trim().is_empty() {
             return Err("name_and_bucket_required".into());
         }
+        if v.access_key_id.trim().is_empty() || v.secret_access_key.trim().is_empty() {
+            return Err("credentials_required".into());
+        }
         url::Url::parse(&v.endpoint).map_err(|_| "invalid_endpoint")?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/storage_profile.rs` around lines 35 - 54, Add
validation in StorageProfileService::create for trimmed access_key_id and
secret_access_key, returning the existing required-fields error before URL
parsing, encryption, or database insertion when either credential is empty.
Preserve the current validation and creation flow for complete credentials.
apps/backend/src/services/deployment.rs-157-163 (1)

157-163: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Daily quota can be undercounted for high-volume triggers.

history() is capped at LIMIT 100, but trigger()'s daily-quota check counts matches within that capped set (history.iter().filter(...).count()). If a trigger accumulates more than 100 jobs in a rolling day (an admin can set daily_quota above 100, since only >= 0 is validated), older-than-100 jobs within the last 24h are silently excluded from the count, allowing more triggers than the configured quota. Also note history() has no site_id filter — it currently relies on callers (e.g. the history handler) to pre-check trigger/site membership.

Also applies to: 202-210

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/deployment.rs` around lines 157 - 163, The
daily-quota calculation in trigger() must not rely on the 100-row cap of
history(). Add a dedicated count/query for jobs matching the trigger within the
rolling 24-hour window, scoped to the appropriate site, and use that count for
quota enforcement so quotas above 100 are honored. Keep history()’s pagination
behavior unchanged, and ensure the history handler’s trigger/site membership
validation remains explicit.
apps/backend/src/graphql/context.rs-58-63 (1)

58-63: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Misleading error when a token has scopes but lacks ContentRead.

When permission is Some(scopes) but scopes doesn't contain ContentRead, the guard on the first match arm fails and control falls through to the catch-all _ arm, returning "Site token authentication required" — the same message used for missing authentication — instead of a permission-denied message. require_write correctly distinguishes these two cases via its Some(_) => Err("... does not have write permission") arm; require_read should do the same.

🐛 Proposed fix
     pub fn require_read(&self) -> async_graphql::Result<()> {
         match (&self.actor, &self.permission) {
-            (Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), Some(scopes))
-                if scopes.contains(&TokenScope::ContentRead) =>
-            {
-                Ok(())
-            }
+            (Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), Some(scopes)) => {
+                if scopes.contains(&TokenScope::ContentRead) {
+                    Ok(())
+                } else {
+                    Err(async_graphql::Error::new(
+                        "Access token does not have required permission",
+                    ))
+                }
+            }
             (Some(Actor::ApiKey(_) | Actor::PersonalToken(_)), None) => Err(async_graphql::Error::new(
                 "Access token does not have required permission",
             )),
             _ => Err(async_graphql::Error::new("Site token authentication required")),
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/graphql/context.rs` around lines 58 - 63, Update
require_read in the permission match to add a Some(scopes) case for API key or
personal token actors whose scopes lack TokenScope::ContentRead, returning a
permission-denied error analogous to require_write. Keep the existing success
path for scopes containing ContentRead and the missing-scope authentication
error unchanged.
🧹 Nitpick comments (12)
apps/dashboard/src/routeTree.gen.ts (1)

25-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Regenerate this file using the repository quote style.

These new imports use single quotes, contrary to the dashboard’s required Biome double-quote convention. Configure the route-tree generator or its post-generation formatting so regeneration does not reintroduce the mismatch.

As per coding guidelines, “Frontend formatting should follow Biome conventions: 2-space indent, double quotes, and organized imports.”

Also applies to: 35-35

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/routeTree.gen.ts` at line 25, Update the route-tree
generation or post-generation formatting workflow so imports emitted in
routeTree.gen.ts use Biome’s double-quote style, including the imports
represented by AdminSitesSiteIdDeploymentsRouteImport and the additional
affected import. Regenerate the file and ensure organized imports and existing
frontend formatting conventions are preserved.

Source: Coding guidelines

apps/dashboard/src/components/account/personal-tokens-card.tsx (2)

70-73: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

No loading/error state for the token list query.

Unlike the sessions query in account.tsx (which shows a Skeleton and error handling), this query silently shows an empty list on failure/first load, with no toast.error or loading indicator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/account/personal-tokens-card.tsx` around lines
70 - 73, Update the personal-tokens query in the component using useQuery to
track loading and error states instead of defaulting failures to an empty list;
render a Skeleton while loading and call toast.error with the query error when
it fails, matching the sessions handling in account.tsx while preserving the
successful token list rendering.

75-75: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

No UI control to set a token expiration; always creates non-expiring tokens.

expires_at is hardcoded to null even though the type/API supports an expiry. Personal access tokens with no way to opt into expiration is a weaker default for a credential meant for MCP/CLI/API use.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/components/account/personal-tokens-card.tsx` at line 75,
Update the personal-token creation flow around the mutationFn to expose a UI
control for selecting an expiration and pass the chosen value as expires_at
instead of always sending null. Preserve the existing token name and scopes
handling, and ensure the selected option can still represent a non-expiring
token when explicitly chosen.
apps/dashboard/src/lib/api.ts (1)

1063-1072: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Deployment/storage-profile write helpers use value: unknown for request bodies.

createDeployment, updateDeployment, createStorageProfile, and updateStorageProfile all accept unknown and just JSON.stringify it, losing compile-time validation of the payload shape at all call sites.

Also applies to: 1093-1102

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dashboard/src/lib/api.ts` around lines 1063 - 1072, Replace the value:
unknown parameters in createDeployment, updateDeployment, createStorageProfile,
and updateStorageProfile with the established request payload types for
deployment and storage-profile writes. Keep JSON serialization unchanged, and
ensure all call sites pass the corresponding typed payload so compile-time shape
validation is enforced.
apps/backend/src/services/site.rs (1)

86-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Format the API-key response construction.

Line 86 exceeds the repository’s 120-column rustfmt width; expand this mapping into a formatted closure or helper.

As per coding guidelines, Rust code should use the repository toolchain settings, including rustfmt width 120 and 4-space indentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/site.rs` at line 86, Reformat the Actor::ApiKey
response mapping in the get_site flow so the JSON construction is expanded
across multiple lines and remains within the repository’s 120-column rustfmt
width, using 4-space indentation. Preserve the existing fields and "site_key"
role value.

Source: Coding guidelines

apps/backend/src/handlers/storage_profile_handler.rs (1)

19-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use AppError-based Result handlers.

These handlers manually construct error responses on every path instead of using the backend’s shared HTTP-error contract.

As per coding guidelines, Rust backend code should use idiomatic Result/error handling, Axum extractors, and the custom AppError enum for HTTP errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/handlers/storage_profile_handler.rs` around lines 19 - 108,
Refactor the storage profile handlers list, create, delete, update, and probe to
return the backend’s AppError-based Result response type instead of manually
constructing error responses. Propagate authorization and service failures with
the appropriate AppError variants, preserve each handler’s existing success
status and JSON payloads, and keep Axum extractors unchanged.

Source: Coding guidelines

apps/backend/src/handlers/backup_handler.rs (1)

263-301: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Schedules aren't validated against an existing/enabled storage profile at create/update time.

Unlike the instant-backup path (create_backup, which surfaces an invalid storage_profile_id immediately via run_backup), create_schedule/update_schedule persist body.storage_profile_id without checking it exists and is enabled. An invalid/deleted id silently sits on the schedule until the next scheduled run fails (logged only via tracing::error! in scheduler.rs).

Also applies to: 313-347

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/handlers/backup_handler.rs` around lines 263 - 301, Validate
body.storage_profile_id in both create_schedule and update_schedule before
persisting the schedule, ensuring the referenced storage profile exists and is
enabled; return the same appropriate error used by the instant-backup path when
validation fails. Preserve schedules without a storage profile and only call
meta::create_schedule or the corresponding update operation after validation
succeeds.
apps/backend/src/services/deployment.rs (1)

60-66: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Re-fetch entire list/history to locate the just-written row.

create, update, and insert_job each re-run list()/history() (fetching all triggers for the site, or up to 100 jobs) and then .find() client-side just to return the row that was just written, instead of selecting by id directly. This adds an avoidable full round trip per write and scales with the number of triggers/jobs.

Also applies to: 103-108, 220-262

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/deployment.rs` around lines 60 - 66, Update the
write methods around create, update, and insert_job to return the newly written
row by selecting it directly with its known id, rather than calling list() or
history() and searching with find(). Preserve the existing not-found error
behavior where applicable while avoiding the full collection/history fetch.
apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql (1)

6-6: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider NOT VALID + separate VALIDATE CONSTRAINT for the new FKs on potentially large tables.

Static analysis flags that adding these foreign keys inline with ADD COLUMN takes a SHARE ROW EXCLUSIVE lock blocking writes on sites/backups/backup_schedules while a table scan runs. For production tables of meaningful size, splitting into ADD COLUMNADD CONSTRAINT ... NOT VALIDVALIDATE CONSTRAINT (in a separate transaction) avoids the write-blocking window.

Also applies to: 8-8, 9-9

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/backend/migrations/postgres/20260721000000_tokens_deployments_storage.sql`
at line 6, Update the foreign-key additions for sites.storage_profile_id,
backups.storage_profile_id, and backup_schedules.storage_profile_id to add the
columns without inline REFERENCES, then create separately named foreign-key
constraints with NOT VALID and validate each constraint in a separate
transaction or migration step. Preserve the existing storage_profiles(id)
relationship while avoiding the write-blocking inline table scan.

Source: Linters/SAST tools

apps/backend/src/models/authorization.rs (1)

174-189: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove allows_api_key. Nothing in this codebase calls it, so it can drift from the new scope rules without providing any benefit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/models/authorization.rs` around lines 174 - 189, Remove the
unused allows_api_key function from the authorization model, along with any
imports or related code that become unnecessary after its deletion; leave the
surrounding authorization scope rules unchanged.
apps/backend/src/services/access_token.rs (1)

130-200: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No unit tests for the new personal-token service methods.

list_personal_tokens, create_personal_token, and revoke_personal_token are new, security-relevant functionality with no accompanying tests in this module's tests submodule (only site-token tests were updated). This is compounded by InMemoryAccessTokenRepository's personal-token methods being no-op stubs (see test_helpers.rs), which would need fixing first to make such tests meaningful.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/services/access_token.rs` around lines 130 - 200, The new
personal-token service methods lack meaningful test coverage because
InMemoryAccessTokenRepository personal-token operations are no-op stubs.
Implement the repository test-helper methods first, then add tests for
list_personal_tokens, create_personal_token, and revoke_personal_token covering
successful behavior and relevant validation/error paths, following the existing
site-token tests’ structure.
apps/backend/src/test_helpers.rs (1)

1197-1220: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Personal-token stubs don't actually store anything.

list_personal, create_personal, revoke_personal, find_personal_by_prefix, and touch_personal are all no-op (empty vec / Ok(0) / Ok(())), unlike the sibling list/create/delete/find_by_prefix methods just above, which back onto self.tokens. Any test exercising AccessTokenService's personal-token flows against InMemoryAccessTokenRepository would silently pass with a fake empty result set rather than genuinely testing create → list → revoke round-trips.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/test_helpers.rs` around lines 1197 - 1220, Implement the
personal-token methods in InMemoryAccessTokenRepository using self.tokens,
matching the behavior of the sibling list/create/delete/find_by_prefix methods
above. Update list_personal, create_personal, revoke_personal,
find_personal_by_prefix, and touch_personal to persist, query, mutate, and
return personal-token data so create → list → revoke round-trips are exercised
rather than returning no-op results.

Comment thread apps/backend/src/graphql/context.rs
Comment thread apps/backend/src/services/deployment.rs
Comment thread apps/backend/src/services/deployment.rs
…ment jobs

Add partial unique index on deployment_jobs to enforce at most one queued or running job per trigger. MySQL uses a generated column approach, Postgres and SQLite use filtered unique indexes.
…oyment service

Extract build_protected_client from deliver_to_url as a reusable method for deployments. Refactor deployment execute_job to use it. Add map_job_insert_error returning 'deployment_in_progress' on unique violation and a unit test for active job uniqueness.
…ns in GraphQL

When a personal token supplies X-VCMS-Site, verify the user has SiteRead access before trusting the header, rather than blindly accepting the requested site.
@velopulent velopulent deleted a comment from coderabbitai Bot Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant