mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-31 05:39:28 +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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user