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 -->

[![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)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-05-14 18:25:03 +08:00
committed by GitHub
parent 542da0b347
commit c8cdc488db
12 changed files with 1371 additions and 50 deletions
+137 -45
View File
@@ -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")