Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
55e51c5
chore(deps): update Rust crates
krishna-santosh Aug 10, 2026
186ec28
refactor(mcp): migrate server to rmcp 3
krishna-santosh Aug 10, 2026
23f7ae1
chore(deps): update JavaScript packages
krishna-santosh Aug 10, 2026
a56b3c0
refactor(dashboard): migrate TanStack Table 9
krishna-santosh Aug 10, 2026
6c12d40
refactor(web): migrate Fumadocs search client
krishna-santosh Aug 10, 2026
0e19375
fix(web): remove unsupported style jsx prop
krishna-santosh Aug 10, 2026
2447dbf
fix(dashboard): use Vite native path metadata
krishna-santosh Aug 10, 2026
514a132
chore(dashboard): refresh generated route tree
krishna-santosh Aug 10, 2026
f6bfe0d
rm devtools
krishna-santosh Aug 10, 2026
062457c
docs(wip): update docs
krishna-santosh Aug 10, 2026
c7dbb8b
rm devtools
krishna-santosh Aug 10, 2026
9136358
feat(api): site-scoped public API contract across REST, GraphQL, gRPC…
krishna-santosh Oct 8, 2026
25df591
fix(backend): keep request headers out of tracing spans
krishna-santosh Oct 8, 2026
57b5790
fix(sqlite): take the write lock when write transactions begin
krishna-santosh Oct 8, 2026
a62f922
refactor(db): fold API invariants into the schema baseline
krishna-santosh Oct 8, 2026
47c2107
refactor(auth): remove token scopes no public API accepts
krishna-santosh Oct 8, 2026
97749e6
fix(mcp): serve 2025-06-18 and 2025-11-25 clients alongside 2026-07-28
krishna-santosh Oct 8, 2026
0ea2abc
refactor(api): tighten public REST list and authorization errors
krishna-santosh Oct 8, 2026
804ee3c
refactor(grpc): clean typed content contract
krishna-santosh Oct 8, 2026
674ed89
refactor(graphql): scope every content read under site(id:)
krishna-santosh Oct 8, 2026
e843703
fix(api): rate-limit every content API with its own budget
krishna-santosh Oct 8, 2026
a753d70
style(dashboard): apply Biome formatting and import order
krishna-santosh Oct 8, 2026
17b4e5e
refactor(backend): remove unreachable code
krishna-santosh Oct 8, 2026
127b6e0
refactor(mcp): name the draft flag include_drafts on list_entries
krishna-santosh Oct 8, 2026
a1ea720
feat(graphql): expose singleton version for conditional updates
krishna-santosh Oct 8, 2026
11a34f7
docs(api): document the current API contract
krishna-santosh Oct 8, 2026
2cd3793
docs(agents): make AGENTS.md the single agent guide
krishna-santosh Oct 8, 2026
ccdef00
fix(deps): update dependencies for RUSTSEC-2026-0258 and RUSTSEC-2026…
krishna-santosh Oct 8, 2026
8ef7207
chore(deps): upgrade croner to 4 and zstd to 0.14
krishna-santosh Oct 8, 2026
842ed29
chore(deps): upgrade utoipa to 6 and utoipa-scalar to 0.4
krishna-santosh Oct 8, 2026
50cb18c
chore(deny): drop quick-xml advisory ignores
krishna-santosh Oct 8, 2026
95ea5fd
fix(web): upgrade Next.js to 16.4.0
krishna-santosh Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 127 additions & 78 deletions AGENTS.md

Large diffs are not rendered by default.

323 changes: 1 addition & 322 deletions CLAUDE.md

Large diffs are not rendered by default.

1,222 changes: 632 additions & 590 deletions Cargo.lock

Large diffs are not rendered by default.

23 changes: 12 additions & 11 deletions apps/backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ async-trait = "0.1"
uuid = { version = "1", features = ["v4", "v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
bcrypt = "0.19"
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-scalar = { version = "0.3", features = ["axum"] }
utoipa = { version = "6", features = ["axum_extras"] }
utoipa-scalar = { version = "0.4", features = ["axum"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "avif"] }
object_store = { version = "0.14", features = ["aws"] }
bytes = "1"
futures-util = "0.3"
infer = "0.19"
infer = "0.22"
http = "1"
regex = "1"
json-patch = "4"
Expand All @@ -53,28 +53,29 @@ sha2 = "0.11"
hmac = "0.13"
hex = "0.4"
cookie = "0.18"
base64 = "0.22"
base64 = "0.23"
aes-gcm = "0.11"
rand = "0.10"
dashmap = "6"
url = "2"
email_address = "0.2"
reqwest = { version = "0.13", features = ["json", "cookies", "multipart"] }
prost = "0.14"
prost-types = "0.14"
tonic = "0.14"
tonic-prost = "0.14.5"
tonic-prost = "0.14.6"
tonic-reflection = "0.14"
tonic-health = "0.14"
http-body-util = "0.1.3"
rmcp = { version = "2.0", features = ["server", "transport-io", "transport-streamable-http-server", "macros", "schemars"] }
http-body-util = "0.1.4"
rmcp = { version = "3.5.1", features = ["server", "transport-io", "transport-streamable-http-server", "macros", "schemars"] }
tokio-util = { version = "0.7", features = ["rt"] }
tokio-stream = { version = "0.1", features = ["net"] }
schemars = "1"
clap = { version = "4.6.1", features = ["derive"] }
toml = "1.1.2"
zstd = "0.13"
clap = { version = "4.6.6", features = ["derive"] }
toml = "1.1.4"
zstd = "0.14"
tar = "0.4"
croner = "3"
croner = "4"
tantivy = "0.26"

# Windows: native Service Control Manager host for `vcms service run`.
Expand Down
53 changes: 47 additions & 6 deletions apps/backend/migrations/postgres/20260804000000_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,25 @@ CREATE TABLE IF NOT EXISTS collections (
definition JSONB NOT NULL,
is_singleton BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE (id, site_id)
);

CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY NOT NULL,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
collection_id TEXT NOT NULL,
data JSONB NOT NULL,
slug TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'published')),
singleton_collection_id TEXT REFERENCES collections(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
published_at TIMESTAMP WITH TIME ZONE,
-- Opaque optimistic-concurrency version; bumped by entries_version on every update.
version BIGINT NOT NULL DEFAULT 1,
UNIQUE (id, site_id),
FOREIGN KEY (collection_id, site_id) REFERENCES collections(id, site_id) ON DELETE CASCADE,
CONSTRAINT entries_singleton_consistency CHECK (singleton_collection_id IS NULL OR singleton_collection_id = collection_id)
);

Expand Down Expand Up @@ -118,17 +123,21 @@ CREATE TABLE IF NOT EXISTS files (
height INTEGER,
deleted_at TIMESTAMP WITH TIME ZONE,
created_by TEXT REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE (id, site_id)
);

CREATE INDEX IF NOT EXISTS idx_files_site ON files(site_id);
CREATE INDEX IF NOT EXISTS idx_files_created_by ON files(created_by);

CREATE TABLE IF NOT EXISTS entry_file_references (
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL,
file_id TEXT NOT NULL,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
PRIMARY KEY (entry_id, file_id)
field_name TEXT NOT NULL DEFAULT '',
PRIMARY KEY (entry_id, file_id, field_name),
FOREIGN KEY (entry_id, site_id) REFERENCES entries(id, site_id) ON DELETE CASCADE,
FOREIGN KEY (file_id, site_id) REFERENCES files(id, site_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_efr_file ON entry_file_references(file_id);
CREATE INDEX IF NOT EXISTS idx_efr_entry ON entry_file_references(entry_id);
Expand Down Expand Up @@ -359,3 +368,35 @@ CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger

CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger
ON deployment_jobs(trigger_id) WHERE status IN ('queued', 'running');

CREATE OR REPLACE FUNCTION vcms_entry_version() RETURNS trigger AS $$
BEGIN
NEW.version := OLD.version + 1;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER entries_version BEFORE UPDATE ON entries
FOR EACH ROW EXECUTE FUNCTION vcms_entry_version();

CREATE OR REPLACE FUNCTION vcms_entry_singleton() RETURNS trigger AS $$
BEGIN
IF NEW.singleton_collection_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM collections WHERE id = NEW.singleton_collection_id AND is_singleton
) THEN
RAISE EXCEPTION 'invalid singleton collection';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER entries_singleton BEFORE INSERT ON entries
FOR EACH ROW EXECUTE FUNCTION vcms_entry_singleton();

-- Single-use record for signed upload URLs. Security state, not content:
-- deliberately excluded from logical backups.
CREATE TABLE IF NOT EXISTS signed_upload_uses (
file_id TEXT PRIMARY KEY,
expires_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_signed_upload_uses_expiry ON signed_upload_uses(expires_at);
43 changes: 37 additions & 6 deletions apps/backend/migrations/sqlite/20260804000000_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,42 @@ CREATE TABLE IF NOT EXISTS collections (
definition JSON NOT NULL,
is_singleton INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (id, site_id)
);

CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY NOT NULL,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
collection_id TEXT NOT NULL,
data JSON NOT NULL,
slug TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'published')),
singleton_collection_id TEXT REFERENCES collections(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
published_at TEXT,
-- Opaque optimistic-concurrency version; bumped by entries_version on every update.
version INTEGER NOT NULL DEFAULT 1,
UNIQUE (id, site_id),
FOREIGN KEY (collection_id, site_id) REFERENCES collections(id, site_id) ON DELETE CASCADE,
CHECK (singleton_collection_id IS NULL OR singleton_collection_id = collection_id)
);

CREATE TRIGGER IF NOT EXISTS entries_version AFTER UPDATE ON entries
WHEN NEW.version = OLD.version
BEGIN
UPDATE entries SET version = OLD.version + 1 WHERE id = NEW.id;
END;

CREATE TRIGGER IF NOT EXISTS entries_singleton_insert BEFORE INSERT ON entries
WHEN NEW.singleton_collection_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM collections WHERE id = NEW.singleton_collection_id AND is_singleton = 1
)
BEGIN
SELECT RAISE(ABORT, 'invalid singleton collection');
END;

CREATE INDEX IF NOT EXISTS idx_site_members_user ON site_members(user_id);
CREATE INDEX IF NOT EXISTS idx_site_members_site ON site_members(site_id);
CREATE INDEX IF NOT EXISTS idx_collections_site ON collections(site_id);
Expand Down Expand Up @@ -117,17 +136,21 @@ CREATE TABLE IF NOT EXISTS files (
height INTEGER,
deleted_at TEXT,
created_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (id, site_id)
);

CREATE INDEX IF NOT EXISTS idx_files_site ON files(site_id);
CREATE INDEX IF NOT EXISTS idx_files_created_by ON files(created_by);

CREATE TABLE IF NOT EXISTS entry_file_references (
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL,
file_id TEXT NOT NULL,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
PRIMARY KEY (entry_id, file_id)
field_name TEXT NOT NULL DEFAULT '',
PRIMARY KEY (entry_id, file_id, field_name),
FOREIGN KEY (entry_id, site_id) REFERENCES entries(id, site_id) ON DELETE CASCADE,
FOREIGN KEY (file_id, site_id) REFERENCES files(id, site_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_efr_file ON entry_file_references(file_id);
CREATE INDEX IF NOT EXISTS idx_efr_entry ON entry_file_references(entry_id);
Expand Down Expand Up @@ -366,3 +389,11 @@ CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger

CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger
ON deployment_jobs(trigger_id) WHERE status IN ('queued', 'running');

-- Single-use record for signed upload URLs. Security state, not content:
-- deliberately excluded from logical backups.
CREATE TABLE IF NOT EXISTS signed_upload_uses (
file_id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_signed_upload_uses_expiry ON signed_upload_uses(expires_at);
5 changes: 5 additions & 0 deletions apps/backend/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ pub struct Config {
pub db_min_connections: u32,
pub db_acquire_timeout_secs: u64,
pub db_idle_timeout_secs: u64,
/// Login and registration attempts per client per window.
pub rate_limit_max_requests: u32,
pub rate_limit_window_secs: u64,
/// Content API requests (REST, GraphQL, gRPC, MCP) per client per minute.
pub api_rate_limit_max_requests: u32,
pub bcrypt_cost: u32,
pub trust_proxy_headers: bool,
pub webhook_allow_private_targets: bool,
Expand Down Expand Up @@ -162,6 +165,7 @@ impl Config {
db_idle_timeout_secs: 600,
rate_limit_max_requests: 100,
rate_limit_window_secs: 60,
api_rate_limit_max_requests: 1200,
bcrypt_cost: bcrypt::DEFAULT_COST,
trust_proxy_headers: false,
webhook_allow_private_targets: false,
Expand Down Expand Up @@ -258,6 +262,7 @@ impl Default for Config {
db_idle_timeout_secs: 600,
rate_limit_max_requests: 100,
rate_limit_window_secs: 60,
api_rate_limit_max_requests: 1200,
bcrypt_cost: bcrypt::DEFAULT_COST,
trust_proxy_headers: false,
webhook_allow_private_targets: false,
Expand Down
49 changes: 37 additions & 12 deletions apps/backend/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,57 @@ pub enum AppError {
Forbidden(String),
NotFound(String),
Conflict(String),
PreconditionFailed(String),
TooManyRequests(String),
BadGateway(String),
Internal(String),
}

/// RFC 9457-compatible public problem envelope. The legacy `error` member is
/// retained as human-readable text for existing dashboard clients. The `code`
/// member provides a stable machine-readable value.
pub fn problem_response(status: StatusCode, code: &str, detail: impl Into<String>) -> Response {
let detail = detail.into();
(
status,
[(axum::http::header::CONTENT_TYPE, "application/problem+json")],
Json(json!({
"type": "about:blank",
"title": status.canonical_reason().unwrap_or("Request error"),
"status": status.as_u16(),
"code": code,
"error": detail,
"detail": detail,
})),
)
.into_response()
}

impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg),
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg),
AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, msg),
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
let (status, code, message) = match self {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg),
AppError::PreconditionFailed(msg) => (StatusCode::PRECONDITION_FAILED, "precondition_failed", msg),
AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg),
AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg),
AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, "bad_gateway", msg),
AppError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"Internal server error".to_string(),
),
};

let body = Json(json!({"error": message}));
(status, body).into_response()
problem_response(status, code, message)
}
}

impl From<RepositoryError> for AppError {
fn from(err: RepositoryError) -> Self {
match err {
RepositoryError::PreconditionFailed => AppError::PreconditionFailed("Version precondition failed".into()),
RepositoryError::NotFound => AppError::NotFound("Resource not found".into()),
RepositoryError::UniqueViolation(msg) => AppError::Conflict(msg),
RepositoryError::Database(msg) => AppError::Internal(msg),
Expand Down
Loading
Loading