mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-01 17:50:50 +08:00
feat(server): entitlement primitive (#14964)
#### PR Dependency Tree * **PR #14964** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14964) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Generated
+195
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Vendored
+41
@@ -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<string, boolean>
|
||||
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<SafeFetchResponse>
|
||||
|
||||
@@ -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<Aes256, U12, U12>;
|
||||
type LicenseError = (&'static str, &'static str);
|
||||
type LicenseResult<T> = std::result::Result<T, LicenseError>;
|
||||
|
||||
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<String>,
|
||||
pub plan: Option<String>,
|
||||
#[napi(ts_type = "number")]
|
||||
pub quantity: Option<Value>,
|
||||
pub signed_payload: Option<Buffer>,
|
||||
pub public_key: Option<String>,
|
||||
pub license_aes_key: Option<String>,
|
||||
pub now: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct ResolvedQuota {
|
||||
pub blob_limit: i64,
|
||||
pub storage_quota: i64,
|
||||
pub seat_limit: Option<i32>,
|
||||
pub seat_quota: Option<i64>,
|
||||
pub history_period: i64,
|
||||
pub copilot_action_limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[napi(object)]
|
||||
pub struct ResolvedEntitlement {
|
||||
pub plan: String,
|
||||
pub valid: bool,
|
||||
pub status: String,
|
||||
pub quantity: Option<i32>,
|
||||
pub expires_at: Option<String>,
|
||||
pub subject_id: Option<String>,
|
||||
pub target_id: Option<String>,
|
||||
pub recurring: Option<String>,
|
||||
pub issued_at: Option<String>,
|
||||
pub entity: Option<String>,
|
||||
pub issuer: Option<String>,
|
||||
pub quota: ResolvedQuota,
|
||||
pub flags: HashMap<String, bool>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i32>,
|
||||
seat_quota: Option<i64>,
|
||||
copilot_action_limit: Option<i32>,
|
||||
unlimited_copilot: bool,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn resolve_entitlement_v1(input: ResolveEntitlementInput) -> Result<ResolvedEntitlement> {
|
||||
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<Option<i32>> {
|
||||
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<Utc>) -> Result<ResolvedEntitlement> {
|
||||
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<u8>, Vec<u8>)> {
|
||||
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<u8>, Vec<u8>), public_key: &str) -> LicenseResult<LicensePayload> {
|
||||
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::<LicensePayload>(&envelope.payload).map_err(|_| ("invalid_payload", "invalid license payload"))
|
||||
}
|
||||
|
||||
fn active(plan: &str, quantity: Option<i32>, expires_at: Option<String>) -> 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<i32>, expires_at: Option<String>) -> 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<i32>) -> Option<i32> {
|
||||
if matches!(plan, "team" | "selfhost_team") {
|
||||
quantity
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_catalog(plan: &str, quantity: Option<i32>) -> 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<String, bool> {
|
||||
let mut flags = HashMap::new();
|
||||
flags.insert("unlimitedCopilot".to_string(), catalog.unlimited_copilot);
|
||||
flags
|
||||
}
|
||||
|
||||
fn parse_time(value: &str) -> Result<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))
|
||||
}
|
||||
|
||||
fn invalid_arg<T>(message: &'static str) -> Result<T> {
|
||||
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<i32>) -> 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<u8>, Vec<u8>) {
|
||||
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::<LicenseEnvelope>(&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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
+184
@@ -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"();
|
||||
@@ -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")
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,14 @@ export interface RealtimeRequestMap {
|
||||
};
|
||||
output: { task: unknown | null };
|
||||
};
|
||||
'user.quota-state.get': {
|
||||
input: Record<string, never>;
|
||||
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<string, never>;
|
||||
event: { changed: true };
|
||||
};
|
||||
'workspace.quota-state.changed': {
|
||||
input: { workspaceId: string };
|
||||
event: { changed: true };
|
||||
};
|
||||
}
|
||||
|
||||
export type RealtimeRequestInputOf<Op extends RealtimeRequestName> =
|
||||
|
||||
Reference in New Issue
Block a user