From c8cdc488db15344a696e9f9a2bd4e2750ea45742 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 14 May 2026 18:25:03 +0800 Subject: [PATCH] feat(server): entitlement primitive (#14964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #14964** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Added entitlement resolution to validate licenses and derive plan, quotas, expiry and flags. * Introduced persistent quota/entitlement state for users and workspaces with legacy sync behavior. * Real-time quota-state operations and change events for monitoring usage. * **Chores** * Updated workspace dependencies to add cryptography/hash crates. * **Tests** * Added native entitlement tests covering validation, quantity handling, and signature/expiry cases. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14964) --- Cargo.lock | 195 +++++ Cargo.toml | 4 + packages/backend/native/Cargo.toml | 4 + packages/backend/native/index.d.ts | 41 + packages/backend/native/src/entitlement.rs | 726 ++++++++++++++++++ packages/backend/native/src/lib.rs | 1 + .../migration.sql | 10 +- .../migration.sql | 184 +++++ packages/backend/server/schema.prisma | 182 +++-- .../src/__tests__/native-entitlement.spec.ts | 50 ++ packages/backend/server/src/native.ts | 8 + packages/common/realtime/src/index.ts | 16 + 12 files changed, 1371 insertions(+), 50 deletions(-) create mode 100644 packages/backend/native/src/entitlement.rs create mode 100644 packages/backend/server/migrations/20260514000000_entitlement_quota_states/migration.sql create mode 100644 packages/backend/server/src/__tests__/native-entitlement.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 28994bf484..d24e95220d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,16 @@ dependencies = [ "pom", ] +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -33,6 +43,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "affine_common" version = "0.1.0" @@ -189,11 +213,13 @@ dependencies = [ name = "affine_server_native" version = "1.0.0" dependencies = [ + "aes-gcm", "affine_common", "anyhow", "base64-simd", "chrono", "file-format", + "hex", "image", "infer", "jsonschema", @@ -207,12 +233,14 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "p256", "rand 0.9.4", "rayon", "reqwest", "schemars", "serde", "serde_json", + "sha2", "sha3", "tiktoken-rs", "tokio", @@ -576,6 +604,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -1537,6 +1571,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1544,6 +1590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1584,6 +1631,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "dary_heap" version = "0.3.8" @@ -1806,6 +1862,20 @@ dependencies = [ "cipher", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ecow" version = "0.2.6" @@ -1824,6 +1894,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -2013,6 +2103,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "file-format" version = "0.28.0" @@ -2324,6 +2424,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2375,6 +2476,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.1" @@ -2408,6 +2519,17 @@ dependencies = [ "scroll", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -4352,6 +4474,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4391,6 +4519,18 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "palette" version = "0.7.6" @@ -4752,6 +4892,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "pom" version = "1.1.0" @@ -4836,6 +4988,15 @@ dependencies = [ "num-integer", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5334,6 +5495,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5699,6 +5870,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7815,6 +8000,16 @@ dependencies = [ "weedle2", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index b4a863578f..fc9e9160f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ resolver = "3" affine_common = { path = "./packages/common/native" } affine_nbstore = { path = "./packages/frontend/native/nbstore" } ahash = "0.8" + aes-gcm = "0.10" anyhow = "1" arbitrary = { version = "1.3", features = ["derive"] } assert-json-diff = "2.0" @@ -98,6 +99,9 @@ resolver = "3" screencapturekit = "0.3" serde = "1" serde_json = "1" + hex = "0.4" + p256 = { version = "0.13", features = ["ecdsa", "pem"] } + sha2 = "0.10" sha3 = "0.10" smol_str = "0.3" sqlx = { version = "0.8", default-features = false, features = [ diff --git a/packages/backend/native/Cargo.toml b/packages/backend/native/Cargo.toml index c2a093f593..0431dbdfeb 100644 --- a/packages/backend/native/Cargo.toml +++ b/packages/backend/native/Cargo.toml @@ -15,10 +15,12 @@ affine_common = { workspace = true, features = [ "napi", "ydoc-loader", ] } +aes-gcm = { workspace = true } anyhow = { workspace = true } base64-simd = { workspace = true } chrono = { workspace = true } file-format = { workspace = true } +hex = { workspace = true } image = { workspace = true } infer = { workspace = true } jsonschema = "0.46" @@ -30,6 +32,7 @@ matroska = { workspace = true } mp4parse = { workspace = true } napi = { workspace = true, features = ["async", "serde-json"] } napi-derive = { workspace = true } +p256 = { workspace = true } rand = { workspace = true } reqwest = { version = "0.13.3", default-features = false, features = [ "blocking", @@ -38,6 +41,7 @@ reqwest = { version = "0.13.3", default-features = false, features = [ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } sha3 = { workspace = true } tiktoken-rs = { workspace = true } url = { workspace = true } diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index 348f8a0912..aa98677b87 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -641,6 +641,47 @@ export interface RerankCandidate { text: string } +export interface ResolvedEntitlement { + plan: string + valid: boolean + status: string + quantity?: number + expiresAt?: string + subjectId?: string + targetId?: string + recurring?: string + issuedAt?: string + entity?: string + issuer?: string + quota: ResolvedQuota + flags: Record + errorCode?: string + errorMessage?: string +} + +export interface ResolvedQuota { + blobLimit: number + storageQuota: number + seatLimit?: number + seatQuota?: number + historyPeriod: number + copilotActionLimit?: number +} + +export interface ResolveEntitlementInput { + deploymentType: string + targetType: string + targetId?: string + plan?: string + quantity?: number + signedPayload?: Buffer + publicKey?: string + licenseAesKey?: string + now: string +} + +export declare function resolveEntitlementV1(input: ResolveEntitlementInput): ResolvedEntitlement + export declare function runNativeActionRecipePreparedStream(input: ActionRuntimeInput, callback: ((err: Error | null, arg: string) => void)): LlmStreamHandle export declare function safeFetch(request: SafeFetchRequest): Promise diff --git a/packages/backend/native/src/entitlement.rs b/packages/backend/native/src/entitlement.rs new file mode 100644 index 0000000000..0aeccaa206 --- /dev/null +++ b/packages/backend/native/src/entitlement.rs @@ -0,0 +1,726 @@ +use std::collections::HashMap; + +use aes_gcm::{ + AesGcm, KeyInit, + aead::{ + Aead, + generic_array::{GenericArray, typenum::U12}, + }, + aes::Aes256, +}; +use chrono::{DateTime, Utc}; +use napi::{Error as NapiError, Result, Status, bindgen_prelude::Buffer}; +use napi_derive::napi; +use p256::{ + ecdsa::{Signature, VerifyingKey, signature::Verifier}, + pkcs8::DecodePublicKey, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +type Aes256Gcm12 = AesGcm; +type LicenseError = (&'static str, &'static str); +type LicenseResult = std::result::Result; + +const ONE_MB: i64 = 1024 * 1024; +const ONE_GB: i64 = 1024 * ONE_MB; +const ONE_DAY_SECONDS: i64 = 24 * 60 * 60; +const MAX_SEAT_QUANTITY: i32 = 100_000; + +#[napi(object)] +pub struct ResolveEntitlementInput { + pub deployment_type: String, + pub target_type: String, + pub target_id: Option, + pub plan: Option, + #[napi(ts_type = "number")] + pub quantity: Option, + pub signed_payload: Option, + pub public_key: Option, + pub license_aes_key: Option, + pub now: String, +} + +#[derive(Debug)] +#[napi(object)] +pub struct ResolvedQuota { + pub blob_limit: i64, + pub storage_quota: i64, + pub seat_limit: Option, + pub seat_quota: Option, + pub history_period: i64, + pub copilot_action_limit: Option, +} + +#[derive(Debug)] +#[napi(object)] +pub struct ResolvedEntitlement { + pub plan: String, + pub valid: bool, + pub status: String, + pub quantity: Option, + pub expires_at: Option, + pub subject_id: Option, + pub target_id: Option, + pub recurring: Option, + pub issued_at: Option, + pub entity: Option, + pub issuer: Option, + pub quota: ResolvedQuota, + pub flags: HashMap, + pub error_code: Option, + pub error_message: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct LicenseEnvelope { + payload: String, + signature: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LicensePayload { + entity: String, + issuer: String, + issued_at: String, + expires_at: String, + data: LicenseData, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LicenseData { + id: String, + workspace_id: String, + plan: String, + recurring: String, + quantity: i32, + end_at: String, +} + +struct PlanQuota { + name: &'static str, + blob_limit: i64, + storage_quota: i64, + history_period: i64, + member_limit: Option, + seat_quota: Option, + copilot_action_limit: Option, + unlimited_copilot: bool, +} + +#[napi] +pub fn resolve_entitlement_v1(input: ResolveEntitlementInput) -> Result { + validate_input(&input)?; + let now = parse_time(&input.now)?; + + if input.signed_payload.is_some() { + if input.deployment_type != "selfhosted" || input.target_type != "workspace" { + return invalid_arg("signedPayload is only supported for selfhosted workspace entitlements"); + } + return resolve_selfhost_license(input, now); + } + + let plan = input.plan.as_deref().unwrap_or_else(|| { + if input.deployment_type == "selfhosted" { + "selfhost_free" + } else { + "free" + } + }); + if input.deployment_type == "selfhosted" && plan != "selfhost_free" { + return invalid_arg("selfhosted commercial entitlements require signedPayload"); + } + let quantity = parse_quantity(input.quantity.as_ref())?; + Ok(active(plan, quantity, None)) +} + +fn validate_input(input: &ResolveEntitlementInput) -> Result<()> { + if !matches!(input.deployment_type.as_str(), "cloud" | "selfhosted") { + return invalid_arg("deploymentType must be cloud or selfhosted"); + } + if !matches!(input.target_type.as_str(), "user" | "workspace" | "instance") { + return invalid_arg("targetType must be user, workspace, or instance"); + } + parse_quantity(input.quantity.as_ref())?; + Ok(()) +} + +fn parse_quantity(quantity: Option<&Value>) -> Result> { + let Some(quantity) = quantity else { + return Ok(None); + }; + let Some(quantity) = quantity.as_i64() else { + return invalid_arg("quantity must be an integer"); + }; + if quantity <= 0 || quantity > MAX_SEAT_QUANTITY as i64 { + return invalid_arg("quantity must be between 1 and 100000"); + } + Ok(Some(quantity as i32)) +} + +fn resolve_selfhost_license(input: ResolveEntitlementInput, now: DateTime) -> Result { + let Some(payload) = input.signed_payload else { + return Ok(active("selfhost_free", None, None)); + }; + let Some(public_key) = input.public_key else { + return invalid_arg("publicKey is required for signed payload verification"); + }; + let Some(license_aes_key) = input.license_aes_key else { + return invalid_arg("licenseAesKey is required for signed payload verification"); + }; + + let payload = match decrypt_license(payload.as_ref(), &license_aes_key) + .and_then(|decrypted| verify_license(&decrypted, &public_key)) + { + Ok(payload) => payload, + Err((code, message)) => return Ok(invalid_license(code, message)), + }; + + if let Err((code, message)) = validate_license_payload(&payload) { + return Ok(invalid_license(code, message)); + } + + if payload.data.plan != "selfhostedteam" { + return Ok(invalid_license("invalid_payload", "license plan is not selfhostedteam")); + } + + if let Some(target_id) = input.target_id.as_deref() + && target_id != payload.data.workspace_id.as_str() + { + return Ok(invalid_license( + "workspace_mismatch", + "workspace mismatched with license", + )); + } + + if payload.issued_at.is_empty() || payload.entity.is_empty() || payload.issuer.is_empty() { + return Ok(invalid_license("invalid_payload", "license payload is incomplete")); + } + + let file_expires_at = match parse_time(&payload.expires_at) { + Ok(time) => time, + Err(_) => return Ok(invalid_license("invalid_payload", "invalid expiresAt")), + }; + let license_expires_at = match parse_time(&payload.data.end_at) { + Ok(time) => time, + Err(_) => return Ok(invalid_license("invalid_payload", "invalid endAt")), + }; + + let expires_at = file_expires_at.min(license_expires_at); + if expires_at < now { + let mut entitlement = expired( + "selfhost_team", + Some(payload.data.quantity), + Some(expires_at.to_rfc3339()), + ); + entitlement.error_code = Some( + if license_expires_at < now && license_expires_at <= file_expires_at { + "expired_end_at" + } else { + "expired" + } + .to_string(), + ); + fill_license_metadata(&mut entitlement, &payload); + return Ok(entitlement); + } + + let mut entitlement = active( + "selfhost_team", + Some(payload.data.quantity), + Some(expires_at.to_rfc3339()), + ); + fill_license_metadata(&mut entitlement, &payload); + Ok(entitlement) +} + +fn fill_license_metadata(entitlement: &mut ResolvedEntitlement, payload: &LicensePayload) { + entitlement.subject_id = Some(payload.data.id.clone()); + entitlement.target_id = Some(payload.data.workspace_id.clone()); + entitlement.recurring = Some(payload.data.recurring.clone()); + entitlement.issued_at = Some(payload.issued_at.clone()); + entitlement.entity = Some(payload.entity.clone()); + entitlement.issuer = Some(payload.issuer.clone()); +} + +fn validate_license_payload(payload: &LicensePayload) -> LicenseResult<()> { + if payload.data.id.is_empty() + || payload.data.workspace_id.is_empty() + || !matches!(payload.data.recurring.as_str(), "monthly" | "yearly" | "lifetime") + || payload.data.quantity <= 0 + || payload.data.quantity > MAX_SEAT_QUANTITY + { + return Err(("invalid_payload", "license payload is incomplete")); + } + + Ok(()) +} + +fn decrypt_license(buf: &[u8], aes_key: &str) -> LicenseResult<(Vec, Vec)> { + if buf.len() < 2 { + return Err(("invalid_file", "invalid license file")); + } + + let iv_len = buf[0] as usize; + let tag_len = buf[1] as usize; + let payload_start = 2 + iv_len + tag_len; + if iv_len != 12 || tag_len != 12 || buf.len() <= payload_start { + return Err(("invalid_file", "invalid license file")); + } + + let iv = &buf[2..2 + iv_len]; + let tag = &buf[2 + iv_len..payload_start]; + let payload = &buf[payload_start..]; + let key = license_aes_key(aes_key)?; + let cipher = Aes256Gcm12::new_from_slice(&key).map_err(|_| ("invalid_key", "invalid aes key"))?; + let nonce = GenericArray::from_slice(iv); + let mut encrypted = Vec::with_capacity(payload.len() + tag.len()); + encrypted.extend_from_slice(payload); + encrypted.extend_from_slice(tag); + let decrypted = cipher + .decrypt(nonce, encrypted.as_ref()) + .map_err(|_| ("decrypt_failed", "failed to verify the license"))?; + + Ok((iv.to_vec(), decrypted)) +} + +fn license_aes_key(aes_key: &str) -> LicenseResult<[u8; 32]> { + if aes_key.len() == 64 + && let Ok(decoded) = hex::decode(aes_key) + && decoded.len() == 32 + { + let mut key = [0; 32]; + key.copy_from_slice(&decoded); + return Ok(key); + } + + Ok(Sha256::digest(aes_key.as_bytes()).into()) +} + +fn verify_license(decrypted: &(Vec, Vec), public_key: &str) -> LicenseResult { + let (iv, decrypted) = decrypted; + let envelope: LicenseEnvelope = + serde_json::from_slice(decrypted).map_err(|_| ("invalid_file", "invalid license file"))?; + let signature = hex::decode(&envelope.signature).map_err(|_| ("invalid_signature", "invalid license signature"))?; + let signature = Signature::from_der(&signature).map_err(|_| ("invalid_signature", "invalid license signature"))?; + let verifying_key = + VerifyingKey::from_public_key_pem(public_key).map_err(|_| ("invalid_public_key", "invalid public key"))?; + let mut message = Vec::with_capacity(iv.len() + envelope.payload.len()); + message.extend_from_slice(iv); + message.extend_from_slice(envelope.payload.as_bytes()); + verifying_key + .verify(&message, &signature) + .map_err(|_| ("invalid_signature", "invalid license signature"))?; + + serde_json::from_str::(&envelope.payload).map_err(|_| ("invalid_payload", "invalid license payload")) +} + +fn active(plan: &str, quantity: Option, expires_at: Option) -> ResolvedEntitlement { + let quantity = quantity_for_plan(plan, quantity); + let catalog = plan_catalog(plan, quantity); + ResolvedEntitlement { + plan: catalog.name.to_string(), + valid: true, + status: "active".to_string(), + quantity, + expires_at, + subject_id: None, + target_id: None, + recurring: None, + issued_at: None, + entity: None, + issuer: None, + quota: quota(&catalog), + flags: flags(&catalog), + error_code: None, + error_message: None, + } +} + +fn expired(plan: &str, quantity: Option, expires_at: Option) -> ResolvedEntitlement { + let quantity = quantity_for_plan(plan, quantity); + let catalog = plan_catalog(plan, quantity); + ResolvedEntitlement { + plan: catalog.name.to_string(), + valid: false, + status: "expired".to_string(), + quantity, + expires_at, + subject_id: None, + target_id: None, + recurring: None, + issued_at: None, + entity: None, + issuer: None, + quota: quota(&catalog), + flags: flags(&catalog), + error_code: Some("expired".to_string()), + error_message: Some("license expired".to_string()), + } +} + +fn invalid_license(code: &'static str, message: &'static str) -> ResolvedEntitlement { + let catalog = plan_catalog("selfhost_free", None); + ResolvedEntitlement { + plan: catalog.name.to_string(), + valid: false, + status: "needs_reupload".to_string(), + quantity: None, + expires_at: None, + subject_id: None, + target_id: None, + recurring: None, + issued_at: None, + entity: None, + issuer: None, + quota: quota(&catalog), + flags: flags(&catalog), + error_code: Some(code.to_string()), + error_message: Some(message.to_string()), + } +} + +fn quantity_for_plan(plan: &str, quantity: Option) -> Option { + if matches!(plan, "team" | "selfhost_team") { + quantity + } else { + None + } +} + +fn plan_catalog(plan: &str, quantity: Option) -> PlanQuota { + let seats = quantity.unwrap_or(1); + match plan { + "pro" => PlanQuota { + name: "pro", + blob_limit: 100 * ONE_MB, + storage_quota: 100 * ONE_GB, + history_period: 30 * ONE_DAY_SECONDS, + member_limit: Some(10), + seat_quota: None, + copilot_action_limit: Some(10), + unlimited_copilot: false, + }, + "lifetime_pro" => PlanQuota { + name: "lifetime_pro", + blob_limit: 100 * ONE_MB, + storage_quota: 1024 * ONE_GB, + history_period: 30 * ONE_DAY_SECONDS, + member_limit: Some(10), + seat_quota: None, + copilot_action_limit: Some(10), + unlimited_copilot: false, + }, + "ai" => PlanQuota { + name: "ai", + blob_limit: 10 * ONE_MB, + storage_quota: 10 * ONE_GB, + history_period: 7 * ONE_DAY_SECONDS, + member_limit: Some(3), + seat_quota: None, + copilot_action_limit: None, + unlimited_copilot: true, + }, + "team" | "selfhost_team" => { + let seat_quota = 20 * ONE_GB; + let storage_quota = (seats as i64) + .checked_mul(seat_quota) + .and_then(|storage| storage.checked_add(100 * ONE_GB)) + .unwrap_or(i64::MAX); + PlanQuota { + name: if plan == "team" { "team" } else { "selfhost_team" }, + blob_limit: 500 * ONE_MB, + storage_quota, + history_period: 30 * ONE_DAY_SECONDS, + member_limit: Some(seats), + seat_quota: Some(seat_quota), + copilot_action_limit: None, + unlimited_copilot: false, + } + } + "selfhost_free" => PlanQuota { + name: "selfhost_free", + blob_limit: 100 * ONE_MB, + storage_quota: 100 * ONE_GB, + history_period: 30 * ONE_DAY_SECONDS, + member_limit: Some(10), + seat_quota: None, + copilot_action_limit: Some(10), + unlimited_copilot: false, + }, + _ => PlanQuota { + name: "free", + blob_limit: 10 * ONE_MB, + storage_quota: 10 * ONE_GB, + history_period: 7 * ONE_DAY_SECONDS, + member_limit: Some(3), + seat_quota: None, + copilot_action_limit: Some(10), + unlimited_copilot: false, + }, + } +} + +fn quota(catalog: &PlanQuota) -> ResolvedQuota { + ResolvedQuota { + blob_limit: catalog.blob_limit, + storage_quota: catalog.storage_quota, + seat_limit: catalog.member_limit, + seat_quota: catalog.seat_quota, + history_period: catalog.history_period, + copilot_action_limit: catalog.copilot_action_limit, + } +} + +fn flags(catalog: &PlanQuota) -> HashMap { + let mut flags = HashMap::new(); + flags.insert("unlimitedCopilot".to_string(), catalog.unlimited_copilot); + flags +} + +fn parse_time(value: &str) -> Result> { + DateTime::parse_from_rfc3339(value) + .map(|value| value.with_timezone(&Utc)) + .map_err(|err| NapiError::new(Status::InvalidArg, err.to_string())) +} + +fn invalid_arg(message: &'static str) -> Result { + Err(NapiError::new(Status::InvalidArg, message)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_WORKSPACE_ID: &str = "d6f52bc7-d62a-4822-804a-335fa7dfe5a6"; + #[rustfmt::skip] + const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\n\ +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqrxlczPknUuj4q4xx1VGr063Cgu7\n\ +Hc3w7v4FGmoA5MNzzhrkho1ckDYw2wrX6zBnehFzcivURv80HherE2GQjg==\n\ +-----END PUBLIC KEY-----"; + const TEST_LICENSE_AES_KEY: &str = "TEST_LICENSE_AES_KEY"; + + fn input(plan: Option<&str>, quantity: Option) -> ResolveEntitlementInput { + ResolveEntitlementInput { + deployment_type: "cloud".to_string(), + target_type: "workspace".to_string(), + target_id: Some("workspace".to_string()), + plan: plan.map(str::to_string), + quantity: quantity.map(Value::from), + signed_payload: None, + public_key: None, + license_aes_key: None, + now: "2026-05-14T00:00:00Z".to_string(), + } + } + + fn license_input(file: &str, workspace_id: &str) -> ResolveEntitlementInput { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../server/src/__tests__/e2e/license/__fixtures__") + .join(file); + ResolveEntitlementInput { + deployment_type: "selfhosted".to_string(), + target_type: "workspace".to_string(), + target_id: Some(workspace_id.to_string()), + plan: None, + quantity: None, + signed_payload: Some(std::fs::read(fixture).unwrap().into()), + public_key: Some(TEST_PUBLIC_KEY.to_string()), + license_aes_key: Some(TEST_LICENSE_AES_KEY.to_string()), + now: "2026-05-14T00:00:00Z".to_string(), + } + } + + fn decrypted_license(file: &str) -> (Vec, Vec) { + let input = license_input(file, TEST_WORKSPACE_ID); + let payload = input.signed_payload.unwrap(); + decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap() + } + + #[test] + fn decrypts_license_with_raw_or_hashed_aes_key() { + let input = license_input("valid.license", TEST_WORKSPACE_ID); + let payload = input.signed_payload.unwrap(); + let hashed_key = hex::encode(Sha256::digest(TEST_LICENSE_AES_KEY.as_bytes())); + + let raw = decrypt_license(payload.as_ref(), TEST_LICENSE_AES_KEY).unwrap(); + let hashed = decrypt_license(payload.as_ref(), &hashed_key).unwrap(); + + assert_eq!(raw.0, hashed.0); + assert_eq!(raw.1, hashed.1); + } + + #[test] + fn derives_plan_quota() { + let cases = [ + ("free", None, 3, 10 * ONE_GB, Some(10)), + ("pro", None, 10, 100 * ONE_GB, Some(10)), + ("lifetime_pro", None, 10, 1024 * ONE_GB, Some(10)), + ("team", Some(5), 5, 200 * ONE_GB, None), + ("selfhost_team", Some(20), 20, 500 * ONE_GB, None), + ("selfhost_free", None, 10, 100 * ONE_GB, Some(10)), + ]; + + for (plan, quantity, seat_limit, storage_quota, copilot_limit) in cases { + let mut input = input(Some(plan), quantity); + if plan == "selfhost_free" { + input.deployment_type = "selfhosted".to_string(); + } + let resolved = resolve_entitlement_v1(input).unwrap(); + assert!(resolved.valid, "{plan}"); + assert_eq!( + resolved.quantity, + if matches!(plan, "team" | "selfhost_team") { + quantity + } else { + None + }, + "{plan}" + ); + assert_eq!(resolved.quota.seat_limit, Some(seat_limit), "{plan}"); + assert_eq!(resolved.quota.storage_quota, storage_quota, "{plan}"); + assert_eq!(resolved.quota.copilot_action_limit, copilot_limit, "{plan}"); + } + } + + #[test] + fn ignores_quantity_for_fixed_catalog_plans() { + for plan in ["free", "pro", "lifetime_pro", "ai", "selfhost_free"] { + let mut input = input(Some(plan), Some(50)); + if plan == "selfhost_free" { + input.deployment_type = "selfhosted".to_string(); + } + + let resolved = resolve_entitlement_v1(input).unwrap(); + + assert_eq!(resolved.quantity, None, "{plan}"); + assert_ne!(resolved.quota.seat_limit, Some(50), "{plan}"); + } + } + + #[test] + fn rejects_invalid_quantity() { + for quantity in [0, -1, MAX_SEAT_QUANTITY + 1] { + let err = resolve_entitlement_v1(input(Some("team"), Some(quantity))).unwrap_err(); + assert_eq!(err.status, Status::InvalidArg, "{quantity}"); + } + } + + #[test] + fn rejects_unsigned_selfhosted_commercial_entitlements() { + for plan in ["pro", "lifetime_pro", "ai", "team", "selfhost_team"] { + let mut input = input(Some(plan), Some(50)); + input.deployment_type = "selfhosted".to_string(); + + let err = resolve_entitlement_v1(input).unwrap_err(); + + assert_eq!(err.status, Status::InvalidArg, "{plan}"); + } + } + + #[test] + fn rejects_schema_errors() { + let mut input = input(Some("free"), None); + input.deployment_type = "local".to_string(); + let err = resolve_entitlement_v1(input).unwrap_err(); + assert_eq!(err.status, Status::InvalidArg); + } + + #[test] + fn rejects_signed_payload_outside_selfhost_workspace_boundary() { + let cases = [ + ("cloud", "workspace"), + ("selfhosted", "user"), + ("selfhosted", "instance"), + ]; + + for (deployment_type, target_type) in cases { + let mut input = license_input("valid.license", TEST_WORKSPACE_ID); + input.deployment_type = deployment_type.to_string(); + input.target_type = target_type.to_string(); + let err = resolve_entitlement_v1(input).unwrap_err(); + assert_eq!(err.status, Status::InvalidArg, "{deployment_type}/{target_type}"); + } + } + + #[test] + fn verifies_selfhost_license_files() { + let cases = [ + ("valid.license", TEST_WORKSPACE_ID, true, "active", None, Some(20)), + ( + "valid.license", + "other-workspace", + false, + "needs_reupload", + Some("workspace_mismatch"), + None, + ), + ( + "expired.license", + TEST_WORKSPACE_ID, + false, + "expired", + Some("expired"), + Some(20), + ), + ( + "expired-end-at.license", + TEST_WORKSPACE_ID, + false, + "expired", + Some("expired_end_at"), + Some(20), + ), + ]; + + for (file, workspace_id, valid, status, error_code, quantity) in cases { + let resolved = resolve_entitlement_v1(license_input(file, workspace_id)).unwrap(); + assert_eq!(resolved.valid, valid, "{file}"); + assert_eq!(resolved.status, status, "{file}"); + assert_eq!(resolved.error_code.as_deref(), error_code, "{file}"); + assert_eq!(resolved.quantity, quantity, "{file}"); + if valid { + assert_eq!(resolved.plan, "selfhost_team", "{file}"); + assert_eq!(resolved.quota.seat_limit, quantity, "{file}"); + assert_eq!(resolved.quota.storage_quota, 500 * ONE_GB, "{file}"); + assert_eq!(resolved.quota.blob_limit, 500 * ONE_MB, "{file}"); + } + } + } + + #[test] + fn verifies_signature_branch() { + let (iv, decrypted) = decrypted_license("valid.license"); + let mut envelope: LicenseEnvelope = serde_json::from_slice(&decrypted).unwrap(); + envelope.signature = "00".to_string(); + let decrypted = serde_json::to_vec(&envelope).unwrap(); + let err = verify_license(&(iv, decrypted), TEST_PUBLIC_KEY).unwrap_err(); + + assert_eq!(err.0, "invalid_signature"); + } + + #[test] + fn rejects_license_payload_schema_and_quantity_errors() { + let mut payload: LicensePayload = serde_json::from_str( + &serde_json::from_slice::(&decrypted_license("valid.license").1) + .unwrap() + .payload, + ) + .unwrap(); + + for quantity in [0, -1] { + payload.data.quantity = quantity; + let err = validate_license_payload(&payload).unwrap_err(); + assert_eq!(err.0, "invalid_payload"); + } + + payload.data.quantity = 20; + payload.data.workspace_id.clear(); + let err = validate_license_payload(&payload).unwrap_err(); + assert_eq!(err.0, "invalid_payload"); + } +} diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index de38586786..3130bd0f50 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -4,6 +4,7 @@ mod utils; pub mod doc; pub mod doc_loader; +pub mod entitlement; pub mod file_type; pub mod hashcash; pub mod html_sanitize; diff --git a/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql b/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql index d499488cc5..6561f60dc3 100644 --- a/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql +++ b/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql @@ -141,7 +141,7 @@ CREATE INDEX "workspace_invitations_workspace_id_status_idx" ON "workspace_invit CREATE INDEX "workspace_invitations_invitee_user_id_status_idx" ON "workspace_invitations"("invitee_user_id", "status"); -- CreateIndex -CREATE INDEX "workspace_invitations_email_status_idx" ON "workspace_invitations"("workspace_id", "normalized_email", "status"); +CREATE INDEX "workspace_invitations_workspace_id_normalized_email_status_idx" ON "workspace_invitations"("workspace_id", "normalized_email", "status"); -- CreateIndex CREATE UNIQUE INDEX "workspace_invitations_workspace_id_invitee_user_id_key" ON "workspace_invitations"("workspace_id", "invitee_user_id"); @@ -150,13 +150,13 @@ CREATE UNIQUE INDEX "workspace_invitations_workspace_id_invitee_user_id_key" ON CREATE INDEX "workspace_access_policies_visibility_idx" ON "workspace_access_policies"("visibility"); -- CreateIndex -CREATE INDEX "workspace_access_policies_preview_idx" ON "workspace_access_policies"("url_preview_enabled", "sharing_enabled"); +CREATE INDEX "workspace_access_policies_url_preview_enabled_sharing_enabl_idx" ON "workspace_access_policies"("url_preview_enabled", "sharing_enabled"); -- CreateIndex CREATE INDEX "doc_access_policies_public_idx" ON "doc_access_policies"("workspace_id", "visibility", "published_at") WHERE "visibility" = 'public'; -- CreateIndex -CREATE INDEX "doc_access_policies_doc_idx" ON "doc_access_policies"("workspace_id", "doc_id"); +CREATE INDEX "doc_access_policies_workspace_id_doc_id_idx" ON "doc_access_policies"("workspace_id", "doc_id"); -- CreateIndex CREATE UNIQUE INDEX "doc_grants_owner_key" ON "doc_grants"("workspace_id", "doc_id") WHERE "principal_type" = 'user' AND "role" = 'owner'; @@ -165,10 +165,10 @@ CREATE UNIQUE INDEX "doc_grants_owner_key" ON "doc_grants"("workspace_id", "doc_ CREATE UNIQUE INDEX "doc_grants_legacy_key" ON "doc_grants"("legacy_workspace_id", "legacy_doc_id", "legacy_user_id") WHERE "legacy_workspace_id" IS NOT NULL AND "legacy_doc_id" IS NOT NULL AND "legacy_user_id" IS NOT NULL; -- CreateIndex -CREATE INDEX "doc_grants_principal_idx" ON "doc_grants"("principal_type", "principal_id", "role"); +CREATE INDEX "doc_grants_principal_type_principal_id_role_idx" ON "doc_grants"("principal_type", "principal_id", "role"); -- CreateIndex -CREATE INDEX "doc_grants_doc_role_idx" ON "doc_grants"("workspace_id", "doc_id", "role"); +CREATE INDEX "doc_grants_workspace_id_doc_id_role_idx" ON "doc_grants"("workspace_id", "doc_id", "role"); -- AddForeignKey ALTER TABLE "workspace_runtime_states" ADD CONSTRAINT "workspace_runtime_states_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/migrations/20260514000000_entitlement_quota_states/migration.sql b/packages/backend/server/migrations/20260514000000_entitlement_quota_states/migration.sql new file mode 100644 index 0000000000..9a682f6fb0 --- /dev/null +++ b/packages/backend/server/migrations/20260514000000_entitlement_quota_states/migration.sql @@ -0,0 +1,184 @@ +-- CreateTable +CREATE TABLE "entitlements" ( + "id" VARCHAR NOT NULL, + "target_type" TEXT NOT NULL, + "target_id" VARCHAR, + "source" TEXT NOT NULL, + "plan" TEXT NOT NULL, + "status" TEXT NOT NULL, + "subject_id" VARCHAR, + "issuer" TEXT, + "quantity" INTEGER, + "signed_payload" BYTEA, + "token_hash" TEXT, + "metadata" JSONB NOT NULL DEFAULT '{}', + "issued_at" TIMESTAMPTZ(3), + "starts_at" TIMESTAMPTZ(3), + "expires_at" TIMESTAMPTZ(3), + "validated_at" TIMESTAMPTZ(3), + "grace_until" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id"), + CONSTRAINT "entitlements_target_type_check" CHECK ("target_type" IN ('user', 'workspace', 'instance')), + CONSTRAINT "entitlements_source_check" CHECK ("source" IN ('builtin', 'cloud_subscription', 'selfhost_license', 'admin_grant')), + CONSTRAINT "entitlements_status_check" CHECK ("status" IN ('active', 'grace', 'expired', 'revoked', 'needs_reupload')), + CONSTRAINT "entitlements_quantity_check" CHECK ("quantity" IS NULL OR ("quantity" > 0 AND "quantity" <= 100000)) +); + +-- CreateTable +CREATE TABLE "effective_user_quota_states" ( + "user_id" VARCHAR NOT NULL, + "plan" TEXT NOT NULL, + "source_entitlement_id" VARCHAR, + "blob_limit" BIGINT NOT NULL, + "storage_quota" BIGINT NOT NULL, + "used_storage_quota" BIGINT NOT NULL DEFAULT 0, + "history_period_seconds" INTEGER NOT NULL, + "copilot_action_limit" INTEGER, + "flags" JSONB NOT NULL DEFAULT '{}', + "known" BOOLEAN NOT NULL DEFAULT false, + "stale" BOOLEAN NOT NULL DEFAULT false, + "last_reconciled_at" TIMESTAMPTZ(3), + "stale_after" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "effective_user_quota_states_pkey" PRIMARY KEY ("user_id"), + CONSTRAINT "effective_user_quota_states_blob_limit_check" CHECK ("blob_limit" >= 0), + CONSTRAINT "effective_user_quota_states_storage_quota_check" CHECK ("storage_quota" >= 0), + CONSTRAINT "effective_user_quota_states_used_storage_quota_check" CHECK ("used_storage_quota" >= 0), + CONSTRAINT "effective_user_quota_states_history_period_check" CHECK ("history_period_seconds" >= 0), + CONSTRAINT "effective_user_quota_states_copilot_limit_check" CHECK ("copilot_action_limit" IS NULL OR "copilot_action_limit" >= 0) +); + +-- CreateTable +CREATE TABLE "effective_workspace_quota_states" ( + "workspace_id" VARCHAR NOT NULL, + "plan" TEXT NOT NULL, + "source_entitlement_id" VARCHAR, + "owner_user_id" VARCHAR, + "uses_owner_quota" BOOLEAN NOT NULL DEFAULT false, + "seat_limit" INTEGER NOT NULL, + "member_count" INTEGER NOT NULL DEFAULT 0, + "overcapacity_member_count" INTEGER NOT NULL DEFAULT 0, + "blob_limit" BIGINT NOT NULL, + "storage_quota" BIGINT NOT NULL, + "used_storage_quota" BIGINT NOT NULL DEFAULT 0, + "history_period_seconds" INTEGER NOT NULL, + "readonly" BOOLEAN NOT NULL DEFAULT false, + "readonly_reasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "flags" JSONB NOT NULL DEFAULT '{}', + "known" BOOLEAN NOT NULL DEFAULT false, + "stale" BOOLEAN NOT NULL DEFAULT false, + "last_reconciled_at" TIMESTAMPTZ(3), + "stale_after" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "effective_workspace_quota_states_pkey" PRIMARY KEY ("workspace_id"), + CONSTRAINT "effective_workspace_quota_states_seat_limit_check" CHECK ("seat_limit" >= 0), + CONSTRAINT "effective_workspace_quota_states_member_count_check" CHECK ("member_count" >= 0), + CONSTRAINT "effective_workspace_quota_states_overcapacity_check" CHECK ("overcapacity_member_count" >= 0), + CONSTRAINT "effective_workspace_quota_states_blob_limit_check" CHECK ("blob_limit" >= 0), + CONSTRAINT "effective_workspace_quota_states_storage_quota_check" CHECK ("storage_quota" >= 0), + CONSTRAINT "effective_workspace_quota_states_used_storage_quota_check" CHECK ("used_storage_quota" >= 0), + CONSTRAINT "effective_workspace_quota_states_history_period_check" CHECK ("history_period_seconds" >= 0), + CONSTRAINT "effective_workspace_quota_states_readonly_reasons_check" CHECK ("readonly_reasons" <@ ARRAY['member_overflow', 'storage_overflow']::TEXT[]) +); + +-- CreateIndex +CREATE INDEX "entitlements_target_type_target_id_status_idx" ON "entitlements"("target_type", "target_id", "status"); + +-- CreateIndex +CREATE INDEX "entitlements_status_expires_at_idx" ON "entitlements"("status", "expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "entitlements_active_subject_key" ON "entitlements"("source", "subject_id") +WHERE "subject_id" IS NOT NULL AND "status" IN ('active', 'grace'); + +-- CreateIndex +CREATE INDEX "effective_user_quota_states_known_stale_idx" ON "effective_user_quota_states"("known", "stale"); + +-- CreateIndex +CREATE INDEX "effective_user_quota_states_stale_after_idx" ON "effective_user_quota_states"("stale_after"); + +-- CreateIndex +CREATE INDEX "effective_workspace_quota_states_owner_user_id_idx" ON "effective_workspace_quota_states"("owner_user_id"); + +-- CreateIndex +CREATE INDEX "effective_workspace_quota_states_known_stale_idx" ON "effective_workspace_quota_states"("known", "stale"); + +-- CreateIndex +CREATE INDEX "effective_workspace_quota_states_readonly_stale_idx" ON "effective_workspace_quota_states"("readonly", "stale"); + +-- CreateIndex +CREATE INDEX "effective_workspace_quota_states_stale_after_idx" ON "effective_workspace_quota_states"("stale_after"); + +-- AddForeignKey +ALTER TABLE "effective_user_quota_states" ADD CONSTRAINT "effective_user_quota_states_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "effective_user_quota_states" ADD CONSTRAINT "effective_user_quota_states_source_entitlement_id_fkey" FOREIGN KEY ("source_entitlement_id") REFERENCES "entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "effective_workspace_quota_states" ADD CONSTRAINT "effective_workspace_quota_states_source_entitlement_id_fkey" FOREIGN KEY ("source_entitlement_id") REFERENCES "entitlements"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE OR REPLACE FUNCTION "project_legacy_workspace_readonly_feature"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + DELETE FROM "workspace_features" + WHERE "workspace_id" = OLD."workspace_id" + AND "name" = 'quota_exceeded_readonly_workspace_v1'; + RETURN OLD; + END IF; + + IF NEW."readonly" THEN + UPDATE "workspace_features" + SET "reason" = 'legacy quota state projection trigger', + "activated" = true + WHERE "workspace_id" = NEW."workspace_id" + AND "name" = 'quota_exceeded_readonly_workspace_v1'; + + IF NOT FOUND THEN + INSERT INTO "workspace_features"( + "workspace_id", + "name", + "type", + "configs", + "reason", + "activated" + ) + VALUES ( + NEW."workspace_id", + 'quota_exceeded_readonly_workspace_v1', + 0, + '{}', + 'legacy quota state projection trigger', + true + ); + END IF; + ELSE + DELETE FROM "workspace_features" + WHERE "workspace_id" = NEW."workspace_id" + AND "name" = 'quota_exceeded_readonly_workspace_v1'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER "project_legacy_workspace_readonly_feature_trigger" +AFTER INSERT OR UPDATE OF "readonly" OR DELETE ON "effective_workspace_quota_states" +FOR EACH ROW +EXECUTE FUNCTION "project_legacy_workspace_readonly_feature"(); diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index d9e4c62edc..abe3f2bf03 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -29,10 +29,10 @@ model User { userStripeCustomer UserStripeCustomer? workspaces WorkspaceUserRole[] workspaceMembers WorkspaceMember[] - workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee") - createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter") + workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee") + createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter") // Invite others to join the workspace - WorkspaceInvitations WorkspaceUserRole[] @relation("inviter") + WorkspaceInvitations WorkspaceUserRole[] @relation("inviter") docPermissions WorkspaceDocUserRole[] connectedAccounts ConnectedAccount[] calendarAccounts CalendarAccount[] @@ -40,20 +40,22 @@ model User { aiSessions AiSession[] appConfigs AppConfig[] userSnapshots UserSnapshot[] - createdSnapshot Snapshot[] @relation("createdSnapshot") - updatedSnapshot Snapshot[] @relation("updatedSnapshot") - createdUpdate Update[] @relation("createdUpdate") - createdHistory SnapshotHistory[] @relation("createdHistory") - createdAiJobs AiJobs[] @relation("createdAiJobs") + createdSnapshot Snapshot[] @relation("createdSnapshot") + updatedSnapshot Snapshot[] @relation("updatedSnapshot") + createdUpdate Update[] @relation("createdUpdate") + createdHistory SnapshotHistory[] @relation("createdHistory") + createdAiJobs AiJobs[] @relation("createdAiJobs") // receive notifications - notifications Notification[] @relation("user_notifications") + notifications Notification[] @relation("user_notifications") settings UserSettings? comments Comment[] replies Reply[] - commentAttachments CommentAttachment[] @relation("createdCommentAttachments") + commentAttachments CommentAttachment[] @relation("createdCommentAttachments") AccessToken AccessToken[] workspaceCalendars WorkspaceCalendar[] workspaceMemberLastAccesses WorkspaceMemberLastAccess[] + quotaState EffectiveUserQuotaState? + ownedQuotaStates EffectiveWorkspaceQuotaState[] @@index([email]) @@map("users") @@ -161,6 +163,7 @@ model Workspace { workspaceDocViewDaily WorkspaceDocViewDaily[] workspaceMemberLastAccess WorkspaceMemberLastAccess[] runtimeState WorkspaceRuntimeState? + quotaState EffectiveWorkspaceQuotaState? accessPolicy WorkspaceAccessPolicy? projectedMembers WorkspaceMember[] projectedInvitations WorkspaceInvitation[] @@ -173,22 +176,110 @@ model Workspace { } model WorkspaceRuntimeState { - workspaceId String @id @map("workspace_id") @db.VarChar - known Boolean @default(false) - readonly Boolean @default(false) - readonlyReasons String[] @default([]) @map("readonly_reasons") - lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3) - staleAfter DateTime? @map("stale_after") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + workspaceId String @id @map("workspace_id") @db.VarChar + known Boolean @default(false) + readonly Boolean @default(false) + readonlyReasons String[] @default([]) @map("readonly_reasons") + lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3) + staleAfter DateTime? @map("stale_after") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@map("workspace_runtime_states") } +model Entitlement { + id String @id @default(uuid()) @db.VarChar + targetType String @map("target_type") @db.Text + targetId String? @map("target_id") @db.VarChar + source String @db.Text + plan String @db.Text + status String @db.Text + subjectId String? @map("subject_id") @db.VarChar + issuer String? @db.Text + quantity Int? @db.Integer + signedPayload Bytes? @map("signed_payload") @db.ByteA + tokenHash String? @map("token_hash") @db.Text + metadata Json @default("{}") @db.JsonB + issuedAt DateTime? @map("issued_at") @db.Timestamptz(3) + startsAt DateTime? @map("starts_at") @db.Timestamptz(3) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + validatedAt DateTime? @map("validated_at") @db.Timestamptz(3) + graceUntil DateTime? @map("grace_until") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + userQuotaStates EffectiveUserQuotaState[] + workspaceQuotaStates EffectiveWorkspaceQuotaState[] + + @@index([targetType, targetId, status]) + @@index([status, expiresAt]) + @@map("entitlements") +} + +model EffectiveUserQuotaState { + userId String @id @map("user_id") @db.VarChar + plan String @db.Text + sourceEntitlementId String? @map("source_entitlement_id") @db.VarChar + blobLimit BigInt @map("blob_limit") @db.BigInt + storageQuota BigInt @map("storage_quota") @db.BigInt + usedStorageQuota BigInt @default(0) @map("used_storage_quota") @db.BigInt + historyPeriodSeconds Int @map("history_period_seconds") @db.Integer + copilotActionLimit Int? @map("copilot_action_limit") @db.Integer + flags Json @default("{}") @db.JsonB + known Boolean @default(false) + stale Boolean @default(false) + lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3) + staleAfter DateTime? @map("stale_after") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sourceEntitlement Entitlement? @relation(fields: [sourceEntitlementId], references: [id], onDelete: SetNull) + + @@index([known, stale]) + @@index([staleAfter]) + @@map("effective_user_quota_states") +} + +model EffectiveWorkspaceQuotaState { + workspaceId String @id @map("workspace_id") @db.VarChar + plan String @db.Text + sourceEntitlementId String? @map("source_entitlement_id") @db.VarChar + ownerUserId String? @map("owner_user_id") @db.VarChar + usesOwnerQuota Boolean @default(false) @map("uses_owner_quota") + seatLimit Int @map("seat_limit") @db.Integer + memberCount Int @default(0) @map("member_count") @db.Integer + overcapacityMemberCount Int @default(0) @map("overcapacity_member_count") @db.Integer + blobLimit BigInt @map("blob_limit") @db.BigInt + storageQuota BigInt @map("storage_quota") @db.BigInt + usedStorageQuota BigInt @default(0) @map("used_storage_quota") @db.BigInt + historyPeriodSeconds Int @map("history_period_seconds") @db.Integer + readonly Boolean @default(false) + readonlyReasons String[] @default([]) @map("readonly_reasons") @db.Text + flags Json @default("{}") @db.JsonB + known Boolean @default(false) + stale Boolean @default(false) + lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3) + staleAfter DateTime? @map("stale_after") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + owner User? @relation(fields: [ownerUserId], references: [id], onDelete: SetNull) + sourceEntitlement Entitlement? @relation(fields: [sourceEntitlementId], references: [id], onDelete: SetNull) + + @@index([ownerUserId]) + @@index([known, stale]) + @@index([readonly, stale]) + @@index([staleAfter]) + @@map("effective_workspace_quota_states") +} + model WorkspaceMember { - id String @id @default(uuid()) @db.VarChar + id String @id @default(dbgenerated()) @db.VarChar workspaceId String @map("workspace_id") @db.VarChar userId String @map("user_id") @db.VarChar role String @db.Text @@ -201,36 +292,37 @@ model WorkspaceMember { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([workspaceId, userId, state]) @@index([userId, state]) @@index([workspaceId, role, state]) - @@unique([workspaceId, userId, state]) @@map("workspace_members") } model WorkspaceInvitation { - id String @id @default(uuid()) @db.VarChar - workspaceId String @map("workspace_id") @db.VarChar - inviteeUserId String? @map("invitee_user_id") @db.VarChar - normalizedEmail String? @map("normalized_email") @db.VarChar - inviterUserId String? @map("inviter_user_id") @db.VarChar - requestedRole String @default("member") @map("requested_role") @db.Text - status String @db.Text - kind String @default("email") @db.Text - tokenHash String? @unique(map: "workspace_invitations_token_hash_key") @map("token_hash") @db.Text - legacyPermissionId String? @map("legacy_permission_id") @db.VarChar + id String @id @default(dbgenerated()) @db.VarChar + workspaceId String @map("workspace_id") @db.VarChar + inviteeUserId String? @map("invitee_user_id") @db.VarChar + normalizedEmail String? @map("normalized_email") @db.VarChar + inviterUserId String? @map("inviter_user_id") @db.VarChar + requestedRole String @default("member") @map("requested_role") @db.Text + status String @db.Text + kind String @default("email") @db.Text + // Partial unique index exists in migration: token_hash WHERE token_hash IS NOT NULL. + tokenHash String? @map("token_hash") @db.Text + legacyPermissionId String? @map("legacy_permission_id") @db.VarChar expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull) inviter User? @relation("workspace_invitation_inviter", fields: [inviterUserId], references: [id], onDelete: SetNull) + @@unique([workspaceId, inviteeUserId]) @@index([workspaceId, status]) @@index([inviteeUserId, status]) @@index([workspaceId, normalizedEmail, status]) - @@unique([workspaceId, inviteeUserId]) @@map("workspace_invitations") } @@ -269,22 +361,22 @@ model DocAccessPolicy { } model DocGrant { - workspaceId String @map("workspace_id") @db.VarChar - docId String @map("doc_id") @db.VarChar - principalType String @map("principal_type") @db.Text - principalId String @map("principal_id") @db.VarChar - role String @db.Text - grantedBy String? @map("granted_by") @db.VarChar - legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar - legacyDocId String? @map("legacy_doc_id") @db.VarChar - legacyUserId String? @map("legacy_user_id") @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + workspaceId String @map("workspace_id") @db.VarChar + docId String @map("doc_id") @db.VarChar + principalType String @map("principal_type") @db.Text + principalId String @map("principal_id") @db.VarChar + role String @db.Text + grantedBy String? @map("granted_by") @db.VarChar + legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar + legacyDocId String? @map("legacy_doc_id") @db.VarChar + legacyUserId String? @map("legacy_user_id") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@id([workspaceId, docId, principalType, principalId]) - @@unique([legacyWorkspaceId, legacyDocId, legacyUserId]) + // Partial unique index exists in migration for non-null legacy ids. @@index([principalType, principalId, role]) @@index([workspaceId, docId, role]) @@map("doc_grants") diff --git a/packages/backend/server/src/__tests__/native-entitlement.spec.ts b/packages/backend/server/src/__tests__/native-entitlement.spec.ts new file mode 100644 index 0000000000..4083118209 --- /dev/null +++ b/packages/backend/server/src/__tests__/native-entitlement.spec.ts @@ -0,0 +1,50 @@ +import test from 'ava'; + +import { resolveEntitlementV1 } from '../native'; + +test('native entitlement wrapper maps schema errors to invalid argument', t => { + const error = t.throws(() => + resolveEntitlementV1({ + deploymentType: 'local', + targetType: 'workspace', + now: '2026-05-14T00:00:00Z', + }) + ); + + t.is((error as Error & { code?: string })?.code, 'InvalidArg'); +}); + +test('native entitlement wrapper maps unsafe JS quantity to invalid argument', t => { + const base = { + deploymentType: 'cloud', + targetType: 'workspace', + plan: 'team', + now: '2026-05-14T00:00:00Z', + } as const; + + for (const quantity of [4294967297, 1.5, 100001]) { + const error = t.throws(() => resolveEntitlementV1({ ...base, quantity })); + + t.is( + (error as Error & { code?: string })?.code, + 'InvalidArg', + String(quantity) + ); + } +}); + +test('native entitlement wrapper does not trust forged signed payload buffers', t => { + const resolved = resolveEntitlementV1({ + deploymentType: 'selfhosted', + targetType: 'workspace', + targetId: 'workspace-id', + signedPayload: Buffer.from('not-a-valid-license'), + publicKey: 'not-a-valid-public-key', + licenseAesKey: 'not-a-valid-aes-key', + now: '2026-05-14T00:00:00Z', + }); + + t.false(resolved.valid); + t.is(resolved.status, 'needs_reupload'); + t.is(resolved.plan, 'selfhost_free'); +}); diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index e18da3df03..5045c8413d 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -34,6 +34,8 @@ import serverNativeModule, { type RemoteAttachmentFetchResponse, type RemoteMimeTypeRequest, type RequestedModelMatchResponse, + type ResolvedEntitlement, + type ResolveEntitlementInput, type SafeFetchRequest, type SafeFetchResponse, type Tokenizer, @@ -51,6 +53,8 @@ export type { RemoteAttachmentFetchRequest, RemoteAttachmentFetchResponse, RemoteMimeTypeRequest, + ResolvedEntitlement, + ResolveEntitlementInput, SafeFetchRequest, SafeFetchResponse, }; @@ -250,6 +254,10 @@ export const permissionActionRoleMatrixV1 = (): unknown => export const permissionActionRoleMatrixV1Json = serverNativeModule.permissionActionRoleMatrixV1Json; +export const resolveEntitlementV1 = ( + input: ResolveEntitlementInput +): ResolvedEntitlement => serverNativeModule.resolveEntitlementV1(input); + // MCP write tools exports export const createDocWithMarkdown = serverNativeModule.createDocWithMarkdown; export const updateDocWithMarkdown = serverNativeModule.updateDocWithMarkdown; diff --git a/packages/common/realtime/src/index.ts b/packages/common/realtime/src/index.ts index 0b19e4421a..d7312788bd 100644 --- a/packages/common/realtime/src/index.ts +++ b/packages/common/realtime/src/index.ts @@ -34,6 +34,14 @@ export interface RealtimeRequestMap { }; output: { task: unknown | null }; }; + 'user.quota-state.get': { + input: Record; + output: { state: unknown }; + }; + 'workspace.quota-state.get': { + input: { workspaceId: string }; + output: { state: unknown }; + }; } export type NotificationCountChangedReason = @@ -87,6 +95,14 @@ export interface RealtimeTopicMap { error?: string; }; }; + 'user.quota-state.changed': { + input: Record; + event: { changed: true }; + }; + 'workspace.quota-state.changed': { + input: { workspaceId: string }; + event: { changed: true }; + }; } export type RealtimeRequestInputOf =