diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 37c881270a..936926fa2e 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -384,22 +384,6 @@ } } }, - "permission": { - "type": "object", - "description": "Configuration for permission module", - "properties": { - "readModel": { - "type": "string", - "description": "Permission data source for Rust evaluation\n@default \"projection\"\n@environment `AFFINE_PERMISSION_READ_MODEL`", - "default": "projection" - }, - "fallbackLegacyLoader": { - "type": "boolean", - "description": "Fallback from projection loader to legacy loader when projection input loading fails\n@default false\n@environment `AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER`", - "default": false - } - } - }, "storages": { "type": "object", "description": "Configuration for storages module", diff --git a/packages/backend/server/migrations/20260714000000_backfill_legacy_permission_and_subscription/migration.sql b/packages/backend/server/migrations/20260714000000_backfill_legacy_permission_and_subscription/migration.sql new file mode 100644 index 0000000000..302e973d2b --- /dev/null +++ b/packages/backend/server/migrations/20260714000000_backfill_legacy_permission_and_subscription/migration.sql @@ -0,0 +1,494 @@ +SET affine.permission_projection.enabled = 'off'; + +-- Final copy. Existing rows are authoritative and are never overwritten. +INSERT INTO "workspace_access_policies" ( + "workspace_id", "visibility", "sharing_enabled", "url_preview_enabled" +) +SELECT + w."id", + CASE WHEN w."public" THEN 'public' ELSE 'private' END, + w."enable_sharing", + w."enable_url_preview" +FROM "workspaces" w +ON CONFLICT ("workspace_id") DO NOTHING; + +INSERT INTO "workspace_members" ( + "workspace_id", "user_id", "role", "state", "source", "created_at", "updated_at" +) +SELECT + p."workspace_id", + p."user_id", + affine_permission_legacy_workspace_role(p."type"), + 'active', + CASE p."source"::text WHEN 'Link' THEN 'link' ELSE 'email' END, + p."created_at", + p."updated_at" +FROM "workspace_user_permissions" p +WHERE p."status" = 'Accepted'::"WorkspaceMemberStatus" + AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO "workspace_invitations" ( + "workspace_id", "invitee_user_id", "inviter_user_id", "requested_role", + "status", "kind", "created_at", "updated_at" +) +SELECT + p."workspace_id", + p."user_id", + p."inviter_id", + CASE affine_permission_legacy_workspace_role(p."type") + WHEN 'admin' THEN 'admin' + ELSE 'member' + END, + affine_permission_workspace_invitation_state(p."status"), + CASE p."source"::text WHEN 'Link' THEN 'link' ELSE 'email' END, + p."created_at", + p."updated_at" +FROM "workspace_user_permissions" p +WHERE p."status" <> 'Accepted'::"WorkspaceMemberStatus" + AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL + AND affine_permission_workspace_invitation_state(p."status") IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO "doc_access_policies" ( + "workspace_id", "doc_id", "visibility", "public_role", + "member_default_role", "published_at" +) +SELECT + p."workspace_id", + p."page_id", + CASE WHEN p."public" THEN 'public' ELSE 'private' END, + CASE WHEN p."public" THEN 'external' ELSE NULL END, + affine_permission_legacy_default_doc_role(p."defaultRole"), + p."published_at" +FROM "workspace_pages" p +ON CONFLICT ("workspace_id", "doc_id") DO NOTHING; + +INSERT INTO "doc_grants" ( + "workspace_id", "doc_id", "principal_type", "principal_id", "role", "created_at" +) +SELECT + p."workspace_id", + p."page_id", + 'user', + p."user_id", + affine_permission_legacy_doc_role(p."type"), + p."created_at" +FROM "workspace_page_user_permissions" p +WHERE affine_permission_legacy_doc_role(p."type") IS NOT NULL +ON CONFLICT DO NOTHING; + +INSERT INTO "effective_workspace_quota_states" ( + "workspace_id", "plan", "seat_limit", "member_count", + "overcapacity_member_count", "blob_limit", "storage_quota", + "history_period_seconds", "readonly", "readonly_reasons", "known", + "stale", "last_reconciled_at", "stale_after", "created_at", "updated_at" +) +SELECT + s."workspace_id", + 'free', + 3, + 0, + 0, + 10485760, + 10737418240, + 604800, + s."readonly", + s."readonly_reasons", + s."known", + NOT s."known", + s."last_reconciled_at", + s."stale_after", + s."created_at", + s."updated_at" +FROM "workspace_runtime_states" s +ON CONFLICT ("workspace_id") DO NOTHING; + +INSERT INTO "provider_subscriptions" ( + "id", "provider", "target_type", "target_id", "plan", "recurring", + "status", "external_customer_id", "external_subscription_id", + "external_product_id", "iap_store", "external_ref", "quantity", + "period_start", "period_end", "trial_start", "trial_end", "canceled_at", + "metadata", "created_at", "updated_at" +) +SELECT + gen_random_uuid()::text, + s."provider", + CASE + WHEN s."plan" = 'team' THEN 'workspace' + WHEN s."plan" IN ('selfhosted', 'selfhostedteam') THEN 'instance' + ELSE 'user' + END, + s."target_id", + s."plan", + s."recurring", + s."status", + CASE WHEN s."provider" = 'revenuecat'::"Provider" THEN s."target_id" END, + CASE + WHEN s."provider" = 'stripe'::"Provider" + THEN COALESCE(s."stripe_subscription_id", 'legacy_subscription:' || s."id"::text) + END, + CASE + WHEN s."provider" = 'revenuecat'::"Provider" + THEN COALESCE(s."rc_product_id", 'legacy_product:' || s."id"::text) + END, + CASE + WHEN s."provider" = 'revenuecat'::"Provider" + THEN COALESCE(s."iap_store", 'app_store'::"IapStore") + END, + CASE + WHEN s."provider" = 'revenuecat'::"Provider" + THEN COALESCE(s."rc_external_ref", 'legacy_subscription:' || s."id"::text) + END, + s."quantity", + s."start", + s."end", + s."trial_start", + s."trial_end", + s."canceled_at", + jsonb_strip_nulls(jsonb_build_object( + 'variant', s."variant", + 'stripeScheduleId', s."stripe_schedule_id", + 'nextBillAt', s."next_bill_at", + 'entitlement', s."rc_entitlement", + 'legacySubscriptionId', s."id", + 'legacyRevenueCatIdentityIncomplete', + s."provider" = 'revenuecat'::"Provider" + )), + s."created_at", + s."updated_at" +FROM "subscriptions" s +ON CONFLICT DO NOTHING; + +INSERT INTO "entitlements" ( + "id", "target_type", "target_id", "source", "plan", "status", + "subject_id", "quantity", "metadata", "starts_at", "expires_at", + "validated_at", "created_at", "updated_at" +) +SELECT + gen_random_uuid()::text, + ps."target_type", + ps."target_id", + 'cloud_subscription', + CASE + WHEN ps."plan" = 'pro' AND ps."recurring" = 'lifetime' THEN 'lifetime_pro' + WHEN ps."plan" = 'selfhostedteam' THEN 'selfhost_team' + ELSE ps."plan" + END, + CASE + WHEN ps."status" IN ('active', 'trialing') THEN 'active' + WHEN ps."status" = 'past_due' THEN 'grace' + WHEN ps."status" = 'canceled' THEN 'revoked' + ELSE 'expired' + END, + CASE + WHEN ps."provider" = 'stripe'::"Provider" THEN ps."external_subscription_id" + ELSE ps."id" + END, + CASE WHEN ps."target_type" = 'workspace' THEN GREATEST(ps."quantity", 1) END, + jsonb_build_object('providerSubscriptionId', ps."id", 'recurring', ps."recurring"), + ps."period_start", + ps."period_end", + CURRENT_TIMESTAMP, + ps."created_at", + ps."updated_at" +FROM "provider_subscriptions" ps +WHERE ps."target_type" <> 'instance' + AND NOT EXISTS ( + SELECT 1 + FROM "entitlements" e + WHERE e."source" = 'cloud_subscription' + AND e."subject_id" = CASE + WHEN ps."provider" = 'stripe'::"Provider" THEN ps."external_subscription_id" + ELSE ps."id" + END +) +ON CONFLICT DO NOTHING; + +INSERT INTO "entitlements" ( + "id", "target_type", "target_id", "source", "plan", "status", + "subject_id", "metadata", "validated_at", "created_at", "updated_at" +) +SELECT + gen_random_uuid()::text, + 'user', + f."user_id", + 'admin_grant', + CASE f."name" + WHEN 'pro_plan_v1' THEN 'pro' + WHEN 'lifetime_pro_plan_v1' THEN 'lifetime_pro' + WHEN 'unlimited_copilot' THEN 'ai' + ELSE 'free' + END, + 'active', + 'legacy_user_feature:' || f."id"::text, + jsonb_build_object('legacyFeature', f."name"), + CURRENT_TIMESTAMP, + f."created_at", + CURRENT_TIMESTAMP +FROM "user_features" f +WHERE f."activated" + AND (f."expired_at" IS NULL OR f."expired_at" > CURRENT_TIMESTAMP) + AND f."name" IN ('free_plan_v1', 'pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot') + AND NOT EXISTS ( + SELECT 1 FROM "entitlements" e + WHERE e."target_type" = 'user' + AND e."target_id" = f."user_id" + AND e."plan" = CASE f."name" + WHEN 'pro_plan_v1' THEN 'pro' + WHEN 'lifetime_pro_plan_v1' THEN 'lifetime_pro' + WHEN 'unlimited_copilot' THEN 'ai' + ELSE 'free' + END + AND e."status" IN ('active', 'grace') + ) +ON CONFLICT DO NOTHING; + +INSERT INTO "entitlements" ( + "id", "target_type", "target_id", "source", "plan", "status", + "subject_id", "quantity", "metadata", "validated_at", "created_at", "updated_at" +) +SELECT + gen_random_uuid()::text, + 'workspace', + f."workspace_id", + 'admin_grant', + 'team', + 'active', + 'legacy_workspace_feature:' || f."id"::text, + GREATEST(COALESCE((f."configs" ->> 'memberLimit')::integer, 1), 1), + jsonb_build_object('legacyFeature', f."name"), + CURRENT_TIMESTAMP, + f."created_at", + CURRENT_TIMESTAMP +FROM "workspace_features" f +WHERE f."activated" + AND (f."expired_at" IS NULL OR f."expired_at" > CURRENT_TIMESTAMP) + AND f."name" = 'team_plan_v1' + AND NOT EXISTS ( + SELECT 1 FROM "entitlements" e + WHERE e."target_type" = 'workspace' + AND e."target_id" = f."workspace_id" + AND e."plan" = 'team' + AND e."status" IN ('active', 'grace') + ) +ON CONFLICT DO NOTHING; + +DO $$ +DECLARE + member_conflicts BIGINT; + invitation_conflicts BIGINT; + workspace_policy_conflicts BIGINT; + doc_policy_conflicts BIGINT; + doc_grant_conflicts BIGINT; + runtime_conflicts BIGINT; + subscription_conflicts BIGINT; + missing_members BIGINT; + missing_invitations BIGINT; + missing_workspace_policies BIGINT; + missing_doc_policies BIGINT; + missing_doc_grants BIGINT; + missing_runtime_states BIGINT; + missing_provider_subscriptions BIGINT; + missing_cloud_entitlements BIGINT; +BEGIN + SELECT COUNT(*) INTO member_conflicts + FROM "workspace_user_permissions" p + JOIN "workspace_members" m + ON m."workspace_id" = p."workspace_id" + AND m."user_id" = p."user_id" + AND m."state" = 'active' + WHERE p."status" = 'Accepted'::"WorkspaceMemberStatus" + AND affine_permission_legacy_workspace_role(p."type") IS DISTINCT FROM m."role"; + + SELECT COUNT(*) INTO invitation_conflicts + FROM "workspace_user_permissions" p + JOIN "workspace_invitations" i + ON i."workspace_id" = p."workspace_id" + AND i."invitee_user_id" = p."user_id" + WHERE p."status" <> 'Accepted'::"WorkspaceMemberStatus" + AND ( + affine_permission_workspace_invitation_state(p."status") IS DISTINCT FROM i."status" + OR CASE affine_permission_legacy_workspace_role(p."type") + WHEN 'admin' THEN 'admin' + ELSE 'member' + END IS DISTINCT FROM i."requested_role" + ); + + SELECT COUNT(*) INTO workspace_policy_conflicts + FROM "workspaces" w + JOIN "workspace_access_policies" p ON p."workspace_id" = w."id" + WHERE p."visibility" IS DISTINCT FROM CASE WHEN w."public" THEN 'public' ELSE 'private' END + OR p."sharing_enabled" IS DISTINCT FROM w."enable_sharing" + OR p."url_preview_enabled" IS DISTINCT FROM w."enable_url_preview"; + + SELECT COUNT(*) INTO doc_policy_conflicts + FROM "workspace_pages" d + JOIN "doc_access_policies" p + ON p."workspace_id" = d."workspace_id" AND p."doc_id" = d."page_id" + WHERE p."visibility" IS DISTINCT FROM CASE WHEN d."public" THEN 'public' ELSE 'private' END + OR p."member_default_role" IS DISTINCT FROM affine_permission_legacy_default_doc_role(d."defaultRole"); + + SELECT COUNT(*) INTO doc_grant_conflicts + FROM "workspace_page_user_permissions" d + JOIN "doc_grants" g + ON g."workspace_id" = d."workspace_id" + AND g."doc_id" = d."page_id" + AND g."principal_type" = 'user' + AND g."principal_id" = d."user_id" + WHERE affine_permission_legacy_doc_role(d."type") IS DISTINCT FROM g."role"; + + SELECT COUNT(*) INTO runtime_conflicts + FROM "workspace_runtime_states" r + JOIN "effective_workspace_quota_states" q ON q."workspace_id" = r."workspace_id" + WHERE q."readonly" IS DISTINCT FROM r."readonly" + OR q."readonly_reasons" IS DISTINCT FROM r."readonly_reasons"; + + SELECT COUNT(*) INTO subscription_conflicts + FROM "subscriptions" s + JOIN "provider_subscriptions" ps + ON ps."provider" = s."provider" + AND ( + ( + s."provider" = 'stripe'::"Provider" + AND ps."external_subscription_id" = COALESCE( + s."stripe_subscription_id", + 'legacy_subscription:' || s."id"::text + ) + ) + OR ( + s."provider" = 'revenuecat'::"Provider" + AND ps."iap_store" = COALESCE(s."iap_store", 'app_store'::"IapStore") + AND ps."external_ref" = COALESCE( + s."rc_external_ref", + 'legacy_subscription:' || s."id"::text + ) + AND ps."external_product_id" = COALESCE( + s."rc_product_id", + 'legacy_product:' || s."id"::text + ) + AND ps."external_customer_id" = s."target_id" + ) + ) + WHERE ps."status" IS DISTINCT FROM s."status"; + + SELECT COUNT(*) INTO missing_members + FROM "workspace_user_permissions" p + WHERE p."status" = 'Accepted'::"WorkspaceMemberStatus" + AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "workspace_members" m + WHERE m."workspace_id" = p."workspace_id" + AND m."user_id" = p."user_id" + AND m."state" = 'active' + ); + + SELECT COUNT(*) INTO missing_invitations + FROM "workspace_user_permissions" p + WHERE p."status" <> 'Accepted'::"WorkspaceMemberStatus" + AND affine_permission_legacy_workspace_role(p."type") IS NOT NULL + AND affine_permission_workspace_invitation_state(p."status") IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "workspace_invitations" i + WHERE i."workspace_id" = p."workspace_id" + AND i."invitee_user_id" = p."user_id" + ); + + SELECT COUNT(*) INTO missing_workspace_policies + FROM "workspaces" w + WHERE NOT EXISTS ( + SELECT 1 FROM "workspace_access_policies" p + WHERE p."workspace_id" = w."id" + ); + + SELECT COUNT(*) INTO missing_doc_policies + FROM "workspace_pages" d + WHERE NOT EXISTS ( + SELECT 1 FROM "doc_access_policies" p + WHERE p."workspace_id" = d."workspace_id" + AND p."doc_id" = d."page_id" + ); + + SELECT COUNT(*) INTO missing_doc_grants + FROM "workspace_page_user_permissions" d + WHERE affine_permission_legacy_doc_role(d."type") IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "doc_grants" g + WHERE g."workspace_id" = d."workspace_id" + AND g."doc_id" = d."page_id" + AND g."principal_type" = 'user' + AND g."principal_id" = d."user_id" + ); + + SELECT COUNT(*) INTO missing_runtime_states + FROM "workspace_runtime_states" r + WHERE NOT EXISTS ( + SELECT 1 FROM "effective_workspace_quota_states" q + WHERE q."workspace_id" = r."workspace_id" + ); + + SELECT COUNT(*) INTO missing_provider_subscriptions + FROM "subscriptions" s + WHERE NOT EXISTS ( + SELECT 1 FROM "provider_subscriptions" ps + WHERE ps."provider" = s."provider" + AND ( + ( + s."provider" = 'stripe'::"Provider" + AND ps."external_subscription_id" = COALESCE( + s."stripe_subscription_id", + 'legacy_subscription:' || s."id"::text + ) + ) + OR ( + s."provider" = 'revenuecat'::"Provider" + AND ps."iap_store" = COALESCE(s."iap_store", 'app_store'::"IapStore") + AND ps."external_ref" = COALESCE( + s."rc_external_ref", + 'legacy_subscription:' || s."id"::text + ) + AND ps."external_product_id" = COALESCE( + s."rc_product_id", + 'legacy_product:' || s."id"::text + ) + AND ps."external_customer_id" = s."target_id" + ) + ) + ); + + SELECT COUNT(*) INTO missing_cloud_entitlements + FROM "provider_subscriptions" ps + WHERE ps."target_type" <> 'instance' + AND NOT EXISTS ( + SELECT 1 FROM "entitlements" e + WHERE e."source" = 'cloud_subscription' + AND e."subject_id" = CASE + WHEN ps."provider" = 'stripe'::"Provider" THEN ps."external_subscription_id" + ELSE ps."id" + END + ); + + RAISE NOTICE 'final legacy backfill retained new-table values for member=% invitation=% workspace_policy=% doc_policy=% doc_grant=% runtime=% subscription=% conflicts', + member_conflicts, + invitation_conflicts, + workspace_policy_conflicts, + doc_policy_conflicts, + doc_grant_conflicts, + runtime_conflicts, + subscription_conflicts; + + IF missing_members + missing_invitations + missing_workspace_policies + + missing_doc_policies + missing_doc_grants + missing_runtime_states + + missing_provider_subscriptions + missing_cloud_entitlements > 0 THEN + RAISE EXCEPTION 'legacy backfill incomplete: member=% invitation=% workspace_policy=% doc_policy=% doc_grant=% runtime=% provider_subscription=% cloud_entitlement=% missing facts', + missing_members, + missing_invitations, + missing_workspace_policies, + missing_doc_policies, + missing_doc_grants, + missing_runtime_states, + missing_provider_subscriptions, + missing_cloud_entitlements; + END IF; +END +$$; diff --git a/packages/backend/server/migrations/20260714000001_drop_legacy_permission_and_subscription/migration.sql b/packages/backend/server/migrations/20260714000001_drop_legacy_permission_and_subscription/migration.sql new file mode 100644 index 0000000000..409321abb1 --- /dev/null +++ b/packages/backend/server/migrations/20260714000001_drop_legacy_permission_and_subscription/migration.sql @@ -0,0 +1,73 @@ +SET affine.permission_projection.enabled = 'off'; + +DROP TRIGGER IF EXISTS "affine_permission_project_workspace_user_permission" ON "workspace_user_permissions"; +DROP TRIGGER IF EXISTS "affine_permission_project_workspace_page" ON "workspace_pages"; +DROP TRIGGER IF EXISTS "affine_permission_project_workspace_page_user_permission" ON "workspace_page_user_permissions"; +DROP TRIGGER IF EXISTS "affine_permission_project_workspace_policy" ON "workspaces"; +DROP TRIGGER IF EXISTS "affine_permission_project_new_workspace_member" ON "workspace_members"; +DROP TRIGGER IF EXISTS "affine_permission_project_new_workspace_invitation" ON "workspace_invitations"; +DROP TRIGGER IF EXISTS "affine_permission_project_new_workspace_access_policy" ON "workspace_access_policies"; +DROP TRIGGER IF EXISTS "affine_permission_project_new_doc_access_policy" ON "doc_access_policies"; +DROP TRIGGER IF EXISTS "affine_permission_project_new_doc_grant" ON "doc_grants"; +DROP TRIGGER IF EXISTS "project_legacy_workspace_readonly_feature_trigger" ON "effective_workspace_quota_states"; + +DROP FUNCTION IF EXISTS affine_permission_project_workspace_user_permission() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_workspace_page() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_workspace_page_user_permission() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_workspace_policy() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_new_workspace_member() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_new_workspace_invitation() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_new_workspace_access_policy() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_new_doc_access_policy() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_project_new_doc_grant() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_projection_error_category(TEXT, TEXT) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_lock_workspace(VARCHAR) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_lock_doc(VARCHAR, VARCHAR) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_should_project_from_legacy() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_should_project_from_new() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_projection_enabled() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_sync_origin() CASCADE; +DROP FUNCTION IF EXISTS affine_permission_new_workspace_role(TEXT) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_new_workspace_source(TEXT) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_new_invitation_status(TEXT) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_new_doc_role(TEXT) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_legacy_workspace_role(INTEGER) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_legacy_doc_role(INTEGER) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_legacy_default_doc_role(INTEGER) CASCADE; +DROP FUNCTION IF EXISTS affine_permission_workspace_invitation_state("WorkspaceMemberStatus") CASCADE; +DROP FUNCTION IF EXISTS project_legacy_workspace_readonly_feature() CASCADE; + +DELETE FROM "user_features" +WHERE "name" IN ('free_plan_v1', 'pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'); + +DELETE FROM "workspace_features" +WHERE "name" IN ('team_plan_v1', 'quota_exceeded_readonly_workspace_v1', 'unlimited_workspace'); + +DROP TABLE "workspace_user_permissions"; +DROP TABLE "workspace_page_user_permissions"; +DROP TABLE "workspace_runtime_states"; +DROP TABLE "subscriptions"; + +DROP INDEX IF EXISTS "workspace_pages_workspace_id_public_idx"; +DROP INDEX IF EXISTS "workspace_pages_public_published_at_idx"; +DROP INDEX IF EXISTS "workspace_members_legacy_permission_id_key"; +DROP INDEX IF EXISTS "workspace_invitations_legacy_permission_id_key"; +DROP INDEX IF EXISTS "doc_grants_legacy_key"; + +ALTER TABLE "workspaces" + DROP COLUMN "public", + DROP COLUMN "enable_sharing", + DROP COLUMN "enable_url_preview"; + +ALTER TABLE "workspace_pages" + DROP COLUMN "public", + DROP COLUMN "defaultRole"; + +ALTER TABLE "workspace_members" DROP COLUMN "legacy_permission_id"; +ALTER TABLE "workspace_invitations" DROP COLUMN "legacy_permission_id"; +ALTER TABLE "doc_grants" + DROP COLUMN "legacy_workspace_id", + DROP COLUMN "legacy_doc_id", + DROP COLUMN "legacy_user_id"; + +ALTER TABLE "workspace_admin_stats" DROP COLUMN "features"; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 1f6d27984b..cf2332bf10 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -27,13 +27,10 @@ model User { features UserFeature[] userStripeCustomer UserStripeCustomer? - workspaces WorkspaceUserRole[] workspaceMembers WorkspaceMember[] workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee") createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter") // Invite others to join the workspace - WorkspaceInvitations WorkspaceUserRole[] @relation("inviter") - docPermissions WorkspaceDocUserRole[] connectedAccounts ConnectedAccount[] calendarAccounts CalendarAccount[] mcpCredentials McpCredential[] @@ -181,12 +178,9 @@ model Workspace { // NOTE: manually set this column type to identity in migration file sid Int @unique @default(autoincrement()) id String @id @default(uuid()) @db.VarChar - public Boolean createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) // workspace level feature flags enableAi Boolean @default(true) @map("enable_ai") - enableSharing Boolean @default(true) @map("enable_sharing") - enableUrlPreview Boolean @default(false) @map("enable_url_preview") enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding") name String? @db.VarChar avatarKey String? @map("avatar_key") @db.VarChar @@ -195,8 +189,6 @@ model Workspace { features WorkspaceFeature[] docs WorkspaceDoc[] - permissions WorkspaceUserRole[] - docPermissions WorkspaceDocUserRole[] blobs Blob[] ignoredDocs AiWorkspaceIgnoredDocs[] embedFiles AiWorkspaceFiles[] @@ -210,11 +202,10 @@ model Workspace { workspaceAdminStatsDaily WorkspaceAdminStatsDaily[] workspaceDocViewDaily WorkspaceDocViewDaily[] workspaceMemberLastAccess WorkspaceMemberLastAccess[] - runtimeState WorkspaceRuntimeState? quotaState EffectiveWorkspaceQuotaState? accessPolicy WorkspaceAccessPolicy? - projectedMembers WorkspaceMember[] - projectedInvitations WorkspaceInvitation[] + members WorkspaceMember[] + invitations WorkspaceInvitation[] docAccessPolicies DocAccessPolicy[] docGrants DocGrant[] mcpCredentials McpCredential[] @@ -224,21 +215,6 @@ model Workspace { @@map("workspaces") } -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) - - 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 @@ -328,15 +304,14 @@ model EffectiveWorkspaceQuotaState { } model WorkspaceMember { - 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 - state String @default("active") @db.Text - source String @default("legacy") @db.Text - legacyPermissionId String? @map("legacy_permission_id") @db.VarChar - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) + 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 + state String @default("active") @db.Text + source String @default("legacy") @db.Text + 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) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -348,21 +323,20 @@ model WorkspaceMember { } model WorkspaceInvitation { - 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 + 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) + tokenHash String? @map("token_hash") @db.Text + 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) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull) @@ -462,22 +436,18 @@ model MailDelivery { } 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 + 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]) - // Partial unique index exists in migration for non-null legacy ids. @@index([principalType, principalId, role]) @@index([workspaceId, docId, role]) @@map("doc_grants") @@ -491,9 +461,6 @@ model DocGrant { model WorkspaceDoc { workspaceId String @map("workspace_id") @db.VarChar docId String @map("page_id") @db.VarChar - public Boolean @default(false) - // Workspace user's default role in this page, default is `Manager` - defaultRole Int @default(30) @db.SmallInt // Page/Edgeless mode Int @default(0) @db.SmallInt // Whether the doc is blocked @@ -505,8 +472,6 @@ model WorkspaceDoc { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@id([workspaceId, docId]) - @@index([workspaceId, public]) - @@index([public, publishedAt]) @@map("workspace_pages") } @@ -532,45 +497,6 @@ enum WorkspaceMemberSource { Link } -model WorkspaceUserRole { - id String @id @default(uuid()) @db.VarChar - workspaceId String @map("workspace_id") @db.VarChar - userId String @map("user_id") @db.VarChar - // Workspace Role, Owner/Admin/Collaborator/External - type Int @db.SmallInt - /// the invite status of the workspace member - status WorkspaceMemberStatus @default(Pending) - source WorkspaceMemberSource @default(Email) - inviterId String? @map("inviter_id") @db.VarChar - 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) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - inviter User? @relation(name: "inviter", fields: [inviterId], references: [id], onDelete: SetNull) - - @@unique([workspaceId, userId]) - // optimize for querying user's workspace permissions - @@index([workspaceId, type, status]) - @@index(userId) - @@map("workspace_user_permissions") -} - -model WorkspaceDocUserRole { - workspaceId String @map("workspace_id") @db.VarChar - docId String @map("page_id") @db.VarChar - userId String @map("user_id") @db.VarChar - // External/Reader/Editor/Manager/Owner - type Int @db.SmallInt - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - - @@id([workspaceId, docId, userId]) - @@map("workspace_page_user_permissions") -} - model UserFeature { id Int @id @default(autoincrement()) userId String @map("user_id") @db.VarChar @@ -623,7 +549,6 @@ model WorkspaceAdminStats { blobSize BigInt @default(0) @map("blob_size") @db.BigInt memberCount BigInt @default(0) @map("member_count") @db.BigInt publicPageCount BigInt @default(0) @map("public_page_count") @db.BigInt - features String[] @default([]) @db.Text updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@ -1174,50 +1099,6 @@ model UserStripeCustomer { @@map("user_stripe_customers") } -model Subscription { - id Int @id @default(autoincrement()) @db.Integer - targetId String @map("target_id") @db.VarChar - plan String @db.VarChar(20) - // yearly/monthly/lifetime - recurring String @db.VarChar(20) - // onetime subscription or anything else - variant String? @db.VarChar(20) - quantity Int @default(1) @db.Integer - // subscription.id, null for lifetime payment or one time payment subscription - stripeSubscriptionId String? @unique @map("stripe_subscription_id") - // stripe schedule id - stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar - // subscription provider: stripe or revenuecat - provider Provider @default(stripe) - // iap store for revenuecat subscriptions - iapStore IapStore? @map("iap_store") - // revenuecat entitlement name like "Pro" / "AI" - rcEntitlement String? @map("rc_entitlement") @db.VarChar - // revenuecat product id like "app.affine.pro.Annual" - rcProductId String? @map("rc_product_id") @db.VarChar - // external reference, appstore originalTransactionId or play purchaseToken - rcExternalRef String? @map("rc_external_ref") @db.VarChar - // subscription.status, active/past_due/canceled/unpaid... - status String @db.VarChar(20) - // subscription.current_period_start - start DateTime @map("start") @db.Timestamptz(3) - // subscription.current_period_end, null for lifetime payment - end DateTime? @map("end") @db.Timestamptz(3) - // subscription.billing_cycle_anchor - nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3) - // subscription.canceled_at - canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3) - // subscription.trial_start - trialStart DateTime? @map("trial_start") @db.Timestamptz(3) - // subscription.trial_end - trialEnd DateTime? @map("trial_end") @db.Timestamptz(3) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) - - @@unique([targetId, plan]) - @@map("subscriptions") -} - model ProviderSubscription { id String @id @default(uuid()) @db.VarChar provider Provider diff --git a/packages/backend/server/src/__tests__/auth/service.spec.ts b/packages/backend/server/src/__tests__/auth/service.spec.ts index 82b6dcad99..3ce708e18c 100644 --- a/packages/backend/server/src/__tests__/auth/service.spec.ts +++ b/packages/backend/server/src/__tests__/auth/service.spec.ts @@ -4,6 +4,7 @@ import Sinon from 'sinon'; import { AuthSessionService, CurrentUser } from '../../core/auth'; import { AuthService } from '../../core/auth/service'; +import { EntitlementModule } from '../../core/entitlement'; import { FeatureModule } from '../../core/features'; import { QuotaModule } from '../../core/quota'; import { UserModule } from '../../core/user'; @@ -21,7 +22,7 @@ const test = ava as TestFn<{ test.before(async t => { const m = await createTestingModule({ - imports: [QuotaModule, FeatureModule, UserModule], + imports: [EntitlementModule, QuotaModule, FeatureModule, UserModule], providers: [AuthService], }); diff --git a/packages/backend/server/src/__tests__/copilot/byok.spec.ts b/packages/backend/server/src/__tests__/copilot/byok.spec.ts index bf30b2f5e1..10f8634509 100644 --- a/packages/backend/server/src/__tests__/copilot/byok.spec.ts +++ b/packages/backend/server/src/__tests__/copilot/byok.spec.ts @@ -4,7 +4,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import ava, { type ExecutionContext, type TestFn } from 'ava'; import Sinon from 'sinon'; -import { Cache, CryptoHelper } from '../../base'; +import { Cache, ConfigFactory, CryptoHelper } from '../../base'; import { EntitlementService } from '../../core/entitlement'; import { Models, WorkspaceRole } from '../../models'; import { CopilotAccessPolicy } from '../../plugins/copilot/access'; @@ -553,11 +553,16 @@ test('local leases are short lived and do not persist keys to server configs', a }); test('local leases persist normalized custom endpoints', async t => { - const customEndpointSupported = Sinon.stub( - t.context.byok, - 'customEndpointSupported' - ).get(() => true); - t.teardown(() => customEndpointSupported.restore()); + const config = t.context.module.get(ConfigFactory); + const deploymentType = globalThis.env.DEPLOYMENT_TYPE; + Object.assign(globalThis.env, { DEPLOYMENT_TYPE: 'selfhosted' }); + t.false(t.context.byok.customEndpointSupported); + config.override({ copilot: { byok: { allowCustomEndpoint: true } } }); + t.true(t.context.byok.customEndpointSupported); + t.teardown(() => { + config.override({ copilot: { byok: { allowCustomEndpoint: false } } }); + Object.assign(globalThis.env, { DEPLOYMENT_TYPE: deploymentType }); + }); const { user, workspace } = await createUserWorkspace(t); await grantUserPlan(t, user.id); diff --git a/packages/backend/server/src/__tests__/doc/workspace.spec.ts b/packages/backend/server/src/__tests__/doc/workspace.spec.ts index cb94f08519..99b49ef56d 100644 --- a/packages/backend/server/src/__tests__/doc/workspace.spec.ts +++ b/packages/backend/server/src/__tests__/doc/workspace.spec.ts @@ -140,7 +140,7 @@ test('should be able to merge updates as snapshot', async t => { await db.workspace.create({ data: { id: '1', - public: false, + accessPolicy: { create: {} }, }, }); diff --git a/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts index 566e7c40b7..526e6b1ff8 100644 --- a/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/license/resolver.spec.ts @@ -2,8 +2,14 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql'; +import { PrismaClient } from '@prisma/client'; import { Workspace, WorkspaceRole } from '../../../models'; +import { LicenseService } from '../../../plugins/license/service'; +import { + SubscriptionRecurring, + SubscriptionVariant as PaymentSubscriptionVariant, +} from '../../../plugins/payment/types'; import { createApp, e2e, @@ -70,6 +76,59 @@ e2e('should install file license', async t => { }); t.is(res.installLicense.variant, SubscriptionVariant.Onetime); + + const db = app.get(PrismaClient); + const installed = await db.installedLicense.findUniqueOrThrow({ + where: { workspaceId: workspace.id }, + }); + + const staleAt = new Date(0); + await db.installedLicense.update({ + where: { key: installed.key }, + data: { + quantity: installed.quantity + 10, + recurring: + installed.recurring === SubscriptionRecurring.Monthly + ? SubscriptionRecurring.Yearly + : SubscriptionRecurring.Monthly, + validatedAt: staleAt, + expiredAt: staleAt, + }, + }); + await db.entitlement.updateMany({ + where: { + source: 'selfhost_license', + targetType: 'workspace', + targetId: workspace.id, + }, + data: { + quantity: installed.quantity + 20, + validatedAt: staleAt, + expiresAt: staleAt, + }, + }); + + await app.get(LicenseService).licensesHealthCheck(); + + const revalidated = await db.installedLicense.findUniqueOrThrow({ + where: { key: installed.key }, + }); + t.is(revalidated.variant, PaymentSubscriptionVariant.Onetime); + t.is(revalidated.quantity, installed.quantity); + t.is(revalidated.recurring, installed.recurring); + t.is(revalidated.expiredAt?.getTime(), installed.expiredAt?.getTime()); + t.true(revalidated.validatedAt.getTime() > staleAt.getTime()); + + const entitlement = await db.entitlement.findFirstOrThrow({ + where: { + source: 'selfhost_license', + targetType: 'workspace', + targetId: workspace.id, + }, + }); + t.is(entitlement.quantity, installed.quantity); + t.is(entitlement.expiresAt?.getTime(), installed.expiredAt?.getTime()); + t.true((entitlement.validatedAt?.getTime() ?? 0) > staleAt.getTime()); }); e2e('should not allow to install license if not owner', async t => { diff --git a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts index 37d0fcb5d7..71d1b2499f 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts @@ -298,9 +298,9 @@ e2e( await db.$executeRaw` INSERT INTO workspace_admin_stats ( - workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at + workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at ) - VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW()) + VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, NOW()) ON CONFLICT (workspace_id) DO UPDATE SET snapshot_count = EXCLUDED.snapshot_count, @@ -309,7 +309,6 @@ e2e( blob_size = EXCLUDED.blob_size, member_count = EXCLUDED.member_count, public_page_count = EXCLUDED.public_page_count, - features = EXCLUDED.features, updated_at = EXCLUDED.updated_at `; @@ -479,9 +478,9 @@ e2e( await db.$executeRaw` INSERT INTO workspace_admin_stats ( - workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at + workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at ) - VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, ARRAY[]::text[], NOW()) + VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, NOW()) ON CONFLICT (workspace_id) DO UPDATE SET snapshot_count = EXCLUDED.snapshot_count, @@ -490,7 +489,6 @@ e2e( blob_size = EXCLUDED.blob_size, member_count = EXCLUDED.member_count, public_page_count = EXCLUDED.public_page_count, - features = EXCLUDED.features, updated_at = EXCLUDED.updated_at `; diff --git a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts index bbda0e523a..d2b70c5aba 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts @@ -6,8 +6,9 @@ import { revokePublicPageMutation, WorkspaceMemberStatus, } from '@affine/graphql'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, WorkspaceMemberSource } from '@prisma/client'; +import { WorkspacePolicyService } from '../../../core/permission'; import { QuotaService } from '../../../core/quota/service'; import { WorkspaceRole } from '../../../models'; import { @@ -109,8 +110,9 @@ const cancelTeamWorkspace = async (workspaceId: string) => { }, data: { status: 'revoked' }, }); - await db.subscription.updateMany({ + await db.providerSubscription.updateMany({ where: { + targetType: 'workspace', targetId: workspaceId, plan: SubscriptionPlan.Team, }, @@ -176,16 +178,16 @@ e2e('should allocate seats', async t => { userId: u1.id, workspaceId: workspace.id, status: WorkspaceMemberStatus.AllocatingSeat, - source: 'Email', }); const u2 = await app.createUser(); - await app.create(Mockers.WorkspaceUser, { + const linkInvitation = await app.create(Mockers.WorkspaceUser, { userId: u2.id, workspaceId: workspace.id, status: WorkspaceMemberStatus.AllocatingSeat, - source: 'Link', + kind: 'link', }); + t.is(linkInvitation.source, WorkspaceMemberSource.Link); const invitationCount = app.queue.count('notification.sendInvitation'); await app.eventBus.emitAsync('workspace.members.allocateSeats', { @@ -219,7 +221,6 @@ e2e('should set all rests to NeedMoreSeat', async t => { userId: u1.id, workspaceId: workspace.id, status: WorkspaceMemberStatus.AllocatingSeat, - source: 'Email', }); const u2 = await app.createUser(); @@ -227,7 +228,6 @@ e2e('should set all rests to NeedMoreSeat', async t => { userId: u2.id, workspaceId: workspace.id, status: WorkspaceMemberStatus.AllocatingSeat, - source: 'Email', }); const u3 = await app.createUser(); @@ -235,7 +235,6 @@ e2e('should set all rests to NeedMoreSeat', async t => { userId: u3.id, workspaceId: workspace.id, status: WorkspaceMemberStatus.AllocatingSeat, - source: 'Link', }); await app.eventBus.emitAsync('workspace.members.allocateSeats', { @@ -275,7 +274,6 @@ e2e( userId: allocating.id, workspaceId: workspace.id, status: WorkspaceMemberStatus.AllocatingSeat, - source: 'Email', }); const underReview = await app.create(Mockers.User); @@ -313,10 +311,8 @@ e2e( t.false(await app.models.workspace.isTeamWorkspace(workspace.id)); t.false( - await app.models.workspaceFeature.has( - workspace.id, - 'quota_exceeded_readonly_workspace_v1' - ) + (await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id)) + .isReadonly ); t.is( (await app.models.workspaceUser.get(workspace.id, admin.id))?.type, @@ -350,10 +346,8 @@ e2e( t.false(await app.models.workspace.isTeamWorkspace(workspace.id)); t.true( - await app.models.workspaceFeature.has( - workspace.id, - 'quota_exceeded_readonly_workspace_v1' - ) + (await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id)) + .isReadonly ); t.is( (await app.models.workspaceUser.get(workspace.id, admin.id))?.type, @@ -371,10 +365,8 @@ e2e( } t.false( - await app.models.workspaceFeature.has( - workspace.id, - 'quota_exceeded_readonly_workspace_v1' - ) + (await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id)) + .isReadonly ); } ); diff --git a/packages/backend/server/src/__tests__/mocks/doc-meta.mock.ts b/packages/backend/server/src/__tests__/mocks/doc-meta.mock.ts index 938995c94b..2aa23578ce 100644 --- a/packages/backend/server/src/__tests__/mocks/doc-meta.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/doc-meta.mock.ts @@ -1,16 +1,49 @@ import type { WorkspaceDoc } from '@prisma/client'; import { Prisma } from '@prisma/client'; +import { DocRole } from '../../models'; import { Mocker } from './factory'; -export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput; +export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput & { + public?: boolean; + defaultRole?: DocRole; +}; -export type MockedDocMeta = WorkspaceDoc; +export type MockedDocMeta = WorkspaceDoc & { + public: boolean; + defaultRole: DocRole; +}; export class MockDocMeta extends Mocker { override async create(input: MockDocMetaInput) { - return await this.db.workspaceDoc.create({ - data: input, + const { + public: isPublic = false, + defaultRole = DocRole.Manager, + ...meta + } = input; + const doc = await this.db.workspaceDoc.create({ + data: meta, }); + await this.db.docAccessPolicy.create({ + data: { + workspaceId: input.workspaceId, + docId: input.docId, + visibility: isPublic ? 'public' : 'private', + publicRole: isPublic ? 'external' : null, + memberDefaultRole: + defaultRole === DocRole.None + ? 'none' + : defaultRole === DocRole.Reader + ? 'reader' + : defaultRole === DocRole.Commenter + ? 'commenter' + : defaultRole === DocRole.Editor + ? 'editor' + : defaultRole === DocRole.Owner + ? 'owner' + : 'manager', + }, + }); + return { ...doc, public: isPublic, defaultRole }; } } diff --git a/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts b/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts index da6fa3623f..6969a35cb9 100644 --- a/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/doc-user.mock.ts @@ -1,16 +1,35 @@ -import type { WorkspaceDocUserRole } from '@prisma/client'; -import { Prisma } from '@prisma/client'; - +import { DocRole } from '../../models'; import { Mocker } from './factory'; -export type MockDocUserInput = Prisma.WorkspaceDocUserRoleUncheckedCreateInput; +export type MockDocUserInput = { + workspaceId: string; + docId: string; + userId: string; + type: DocRole; +}; -export type MockedDocUser = WorkspaceDocUserRole; +export type MockedDocUser = MockDocUserInput & { createdAt: Date }; export class MockDocUser extends Mocker { override async create(input: MockDocUserInput) { - return await this.db.workspaceDocUserRole.create({ - data: input, + const grant = await this.db.docGrant.create({ + data: { + workspaceId: input.workspaceId, + docId: input.docId, + principalType: 'user', + principalId: input.userId, + role: + input.type === DocRole.Owner + ? 'owner' + : input.type === DocRole.Manager + ? 'manager' + : input.type === DocRole.Editor + ? 'editor' + : input.type === DocRole.Commenter + ? 'commenter' + : 'reader', + }, }); + return { ...input, createdAt: grant.createdAt }; } } diff --git a/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts b/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts index f48d9e75da..1d919c1126 100644 --- a/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/team-workspace.mock.ts @@ -1,6 +1,5 @@ import { faker } from '@faker-js/faker'; -import { Feature, FeatureType } from '../../models'; import { Mocker } from './factory'; interface MockTeamWorkspaceInput { @@ -16,17 +15,6 @@ export class MockTeamWorkspace extends Mocker< const id = input?.id ?? faker.string.uuid(); const quantity = input?.quantity ?? 10; - await this.db.subscription.create({ - data: { - targetId: id, - plan: 'team', - recurring: 'monthly', - status: 'active', - start: faker.date.past(), - nextBillAt: faker.date.future(), - quantity, - }, - }); await this.db.entitlement.create({ data: { targetType: 'workspace', @@ -38,19 +26,6 @@ export class MockTeamWorkspace extends Mocker< }, }); - await this.db.workspaceFeature.create({ - data: { - workspaceId: id, - reason: 'test', - activated: true, - name: Feature.TeamPlan, - type: FeatureType.Quota, - configs: { - memberLimit: quantity, - }, - }, - }); - return { id }; } } diff --git a/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts b/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts index 70f43d726b..0f4ff6564b 100644 --- a/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts @@ -1,26 +1,68 @@ -import type { Prisma, WorkspaceUserRole } from '@prisma/client'; - -import { WorkspaceMemberStatus, WorkspaceRole } from '../../models'; +import { + WorkspaceMemberStatus, + WorkspaceRole, + type WorkspaceUserCompat, +} from '../../models'; +import { workspaceInvitationToCompat } from '../../models/workspace-user-compat'; import { Mocker } from './factory'; -export type MockWorkspaceUserInput = Omit< - Prisma.WorkspaceUserRoleUncheckedCreateInput, - 'type' -> & { +export type MockWorkspaceUserInput = { + workspaceId: string; + userId: string; type?: WorkspaceRole; + status?: WorkspaceMemberStatus; + inviterId?: string | null; + kind?: 'email' | 'link'; }; export class MockWorkspaceUser extends Mocker< MockWorkspaceUserInput, - WorkspaceUserRole + WorkspaceUserCompat > { override async create(input: MockWorkspaceUserInput) { - return await this.db.workspaceUserRole.create({ + const type = input.type ?? WorkspaceRole.Collaborator; + const status = input.status ?? WorkspaceMemberStatus.Accepted; + if (status === WorkspaceMemberStatus.Accepted) { + const member = await this.db.workspaceMember.create({ + data: { + workspaceId: input.workspaceId, + userId: input.userId, + role: + type === WorkspaceRole.Owner + ? 'owner' + : type === WorkspaceRole.Admin + ? 'admin' + : 'member', + state: 'active', + source: 'legacy', + }, + }); + return { + ...member, + type, + status, + source: 'Email' as const, + inviterId: null, + }; + } + + const invitation = await this.db.workspaceInvitation.create({ data: { - type: WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus.Accepted, - ...input, + workspaceId: input.workspaceId, + inviteeUserId: input.userId, + inviterUserId: input.inviterId, + requestedRole: type === WorkspaceRole.Admin ? 'admin' : 'member', + status: + status === WorkspaceMemberStatus.UnderReview + ? 'waiting_review' + : status === WorkspaceMemberStatus.NeedMoreSeat || + status === WorkspaceMemberStatus.AllocatingSeat || + status === WorkspaceMemberStatus.NeedMoreSeatAndReview + ? 'waiting_seat' + : 'pending', + kind: input.kind ?? 'email', }, }); + return workspaceInvitationToCompat(invitation); } } diff --git a/packages/backend/server/src/__tests__/mocks/workspace.mock.ts b/packages/backend/server/src/__tests__/mocks/workspace.mock.ts index c23061eba9..4bb596afdf 100644 --- a/packages/backend/server/src/__tests__/mocks/workspace.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/workspace.mock.ts @@ -2,15 +2,18 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { faker } from '@faker-js/faker'; -import type { Prisma, Workspace } from '@prisma/client'; +import type { Prisma } from '@prisma/client'; import { omit } from 'lodash-es'; -import { WorkspaceRole } from '../../models'; +import type { Workspace } from '../../models'; import { Mocker } from './factory'; export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & { owner?: { id: string }; snapshot?: Uint8Array | true; + public?: boolean; + enableSharing?: boolean; + enableUrlPreview?: boolean; }; export type MockedWorkspace = Workspace; @@ -28,72 +31,51 @@ export class MockWorkspace extends Mocker { input.snapshot = snapshot; } const snapshot = input?.snapshot; - input = omit(input, 'owner', 'snapshot'); + const isPublic = input?.public ?? false; + const enableSharing = input?.enableSharing ?? true; + const enableUrlPreview = input?.enableUrlPreview ?? false; + input = omit( + input, + 'owner', + 'snapshot', + 'public', + 'enableSharing', + 'enableUrlPreview' + ); const workspace = await this.db.workspace.create({ data: { name: faker.animal.cat(), - public: false, ...input, - permissions: owner + accessPolicy: { + create: { + visibility: isPublic ? 'public' : 'private', + sharingEnabled: enableSharing, + urlPreviewEnabled: enableUrlPreview, + }, + }, + members: owner ? { create: { userId: owner.id, - type: WorkspaceRole.Owner, - status: 'Accepted', + role: 'owner', + state: 'active', + source: 'legacy', }, } : undefined, }, }); - const runtimeStateColumns = await this.db.$queryRaw< - Array<{ exists: boolean }> - >` - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'workspace_runtime_states' - AND column_name = 'known' - ) AS "exists" - `; - if (runtimeStateColumns[0]?.exists) { - await this.db.$executeRaw` - INSERT INTO workspace_runtime_states ( - workspace_id, - known, - readonly, - readonly_reasons, - last_reconciled_at, - stale_after, - updated_at - ) - VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now()) - ON CONFLICT (workspace_id) - DO UPDATE SET - known = true, - readonly = false, - readonly_reasons = ARRAY[]::TEXT[], - last_reconciled_at = now(), - stale_after = NULL, - updated_at = now() - `; - } else { - await this.db.$executeRaw` - INSERT INTO workspace_runtime_states ( - workspace_id, - readonly, - readonly_reasons, - stale_at, - updated_at - ) - VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now()) - ON CONFLICT (workspace_id) - DO UPDATE SET - readonly = false, - readonly_reasons = ARRAY[]::TEXT[], - stale_at = NULL, - updated_at = now() - `; - } + await this.db.effectiveWorkspaceQuotaState.create({ + data: { + workspaceId: workspace.id, + plan: 'free', + seatLimit: 0, + blobLimit: 0, + storageQuota: 0, + historyPeriodSeconds: 0, + known: true, + }, + }); // create a rootDoc snapshot if (snapshot) { @@ -109,6 +91,11 @@ export class MockWorkspace extends Mocker { }, }); } - return workspace; + return { + ...workspace, + public: isPublic, + enableSharing, + enableUrlPreview, + }; } } diff --git a/packages/backend/server/src/__tests__/models/doc-user.spec.ts b/packages/backend/server/src/__tests__/models/doc-user.spec.ts index b61ea181df..3191059afe 100644 --- a/packages/backend/server/src/__tests__/models/doc-user.spec.ts +++ b/packages/backend/server/src/__tests__/models/doc-user.spec.ts @@ -31,7 +31,7 @@ test.after(async () => { async function create() { return db.workspace.create({ - data: { public: false }, + data: { accessPolicy: { create: {} } }, }); } @@ -142,12 +142,13 @@ test('should paginate doc user roles', async t => { })), }); - await db.workspaceDocUserRole.createMany({ + await db.docGrant.createMany({ data: Array.from({ length: 200 }, (_, i) => ({ workspaceId: workspace.id, docId, - userId: String(i), - type: DocRole.Editor, + principalType: 'user', + principalId: String(i), + role: 'editor', createdAt: new Date(Date.now() + i * 1000), })), }); diff --git a/packages/backend/server/src/__tests__/models/feature-user.spec.ts b/packages/backend/server/src/__tests__/models/feature-user.spec.ts index 0273a6c77b..425641e572 100644 --- a/packages/backend/server/src/__tests__/models/feature-user.spec.ts +++ b/packages/backend/server/src/__tests__/models/feature-user.spec.ts @@ -3,8 +3,12 @@ import ava, { TestFn } from 'ava'; import { AdminFeatureManagementResolver } from '../../core/features/resolver'; import { AvailableUserFeatureConfig } from '../../core/features/types'; -import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models'; -import { Feature } from '../../models/common/feature'; +import { + Feature, + FeatureType, + UserFeatureModel, + UserModel, +} from '../../models'; import { createTestingModule, TestingModule } from '../utils'; interface Context { @@ -18,7 +22,6 @@ const test = ava as TestFn; test.before(async t => { const module = await createTestingModule({}); - t.context.model = module.get(UserFeatureModel); t.context.resolver = module.get(AdminFeatureManagementResolver); t.context.module = module; @@ -36,140 +39,39 @@ test.after(async t => { await t.context.module.close(); }); -test('configurable user features exclude commercial projection features', t => { +test('only administrator is a configurable user feature', t => { const config = new AvailableUserFeatureConfig(); - - t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot)); - t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot)); -}); - -test('admin feature resolver rejects commercial projection features', async t => { - await t.throwsAsync( - t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]), - { message: /not configurable/ } - ); - t.deepEqual(await t.context.model.list(t.context.u1.id), []); + t.deepEqual([...config.availableUserFeatures()], [Feature.Admin]); + t.deepEqual([...config.configurableUserFeatures()], [Feature.Admin]); }); test('should get null if user feature not found', async t => { - const { model, u1 } = t.context; - const userFeature = await model.get(u1.id, 'administrator'); - t.is(userFeature, null); + t.is(await t.context.model.get(t.context.u1.id, Feature.Admin), null); }); -test('should get user feature', async t => { +test('should add and get user feature', async t => { const { model, u1 } = t.context; - await model.add(u1.id, 'free_plan_v1', 'legacy projection'); - const userFeature = await model.get(u1.id, 'free_plan_v1'); - t.is(userFeature?.name, 'free_plan_v1'); -}); - -test('should get user quota', async t => { - const { model, u1 } = t.context; - await model.add(u1.id, 'free_plan_v1', 'legacy projection'); - const userQuota = await model.getQuota(u1.id); - t.snapshot(userQuota?.configs, 'free plan'); -}); - -test('should list user features', async t => { - const { model, u1 } = t.context; - - await model.add(u1.id, 'free_plan_v1', 'legacy projection'); - t.like(await model.list(u1.id), ['free_plan_v1']); -}); - -test('should list user features by type', async t => { - const { model, u1 } = t.context; - - await model.add(u1.id, 'free_plan_v1', 'test'); - await model.add(u1.id, 'unlimited_copilot', 'test'); - - t.like(await model.list(u1.id, FeatureType.Quota), ['free_plan_v1']); - t.like(await model.list(u1.id, FeatureType.Feature), ['unlimited_copilot']); -}); - -test('should directly test user feature existence', async t => { - const { model, u1 } = t.context; - - await model.add(u1.id, 'free_plan_v1', 'legacy projection'); - t.true(await model.has(u1.id, 'free_plan_v1')); - t.false(await model.has(u1.id, 'administrator')); -}); - -test('should add user feature', async t => { - const { model, u1 } = t.context; - - await model.add(u1.id, 'unlimited_copilot', 'test'); - t.true(await model.has(u1.id, 'unlimited_copilot')); - t.true((await model.list(u1.id)).includes('unlimited_copilot')); + await model.add(u1.id, Feature.Admin, 'test'); + t.is((await model.get(u1.id, Feature.Admin))?.name, Feature.Admin); + t.true(await model.has(u1.id, Feature.Admin)); + t.deepEqual(await model.list(u1.id, FeatureType.Feature), [Feature.Admin]); }); test('should not add existing user feature', async t => { const { model, u1 } = t.context; - - await model.add(u1.id, 'free_plan_v1', 'test'); - await model.add(u1.id, 'free_plan_v1', 'test'); - - t.like(await model.list(u1.id), ['free_plan_v1']); + await model.add(u1.id, Feature.Admin, 'test'); + await model.add(u1.id, Feature.Admin, 'test'); + t.deepEqual(await model.list(u1.id), [Feature.Admin]); }); test('should remove user feature', async t => { const { model, u1 } = t.context; - - await model.remove(u1.id, 'free_plan_v1'); - t.false(await model.has(u1.id, 'free_plan_v1')); - t.false((await model.list(u1.id)).includes('free_plan_v1')); + await model.add(u1.id, Feature.Admin, 'test'); + await model.remove(u1.id, Feature.Admin); + t.false(await model.has(u1.id, Feature.Admin)); }); -test('should switch user quota', async t => { - const { model, u1 } = t.context; - - await model.switchQuota(u1.id, 'pro_plan_v1', 'test'); - const quota = await model.getQuota(u1.id); - t.snapshot(quota?.configs, 'switch to pro plan'); - - await model.switchQuota(u1.id, 'free_plan_v1', 'test'); - const quota2 = await model.getQuota(u1.id); - t.snapshot(quota2?.configs, 'switch to free plan'); -}); - -test('should not switch user quota if the new quota is the same as the current one', async t => { - const { model, u1 } = t.context; - - await model.add(u1.id, 'free_plan_v1', 'legacy projection'); - await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch'); - - // @ts-expect-error private - const quota = await model.db.userFeature.findFirst({ - where: { - userId: u1.id, - }, - }); - - t.not(quota?.reason, 'test not switch'); -}); - -test('should use pro plan as free for selfhost instance', async t => { - const previousDeploymentType = env.DEPLOYMENT_TYPE; - // @ts-expect-error DEPLOYMENT_TYPE is readonly - env.DEPLOYMENT_TYPE = 'selfhosted'; - try { - await using module = await createTestingModule(); - - const models = module.get(Models); - const u1 = await models.user.create({ - email: 'u1@affine.pro', - registered: true, - }); - - await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection'); - const quota = await models.userFeature.getQuota(u1.id); - t.snapshot( - quota?.configs, - 'use pro plan as free plan for selfhosted instance' - ); - } finally { - // @ts-expect-error DEPLOYMENT_TYPE is readonly - env.DEPLOYMENT_TYPE = previousDeploymentType; - } +test('admin resolver updates administrator feature', async t => { + await t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.Admin]); + t.true(await t.context.model.has(t.context.u1.id, Feature.Admin)); }); diff --git a/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts b/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts deleted file mode 100644 index 0fca894891..0000000000 --- a/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Workspace } from '@prisma/client'; -import ava, { TestFn } from 'ava'; - -import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin'; -import { - FeatureType, - UserModel, - WorkspaceFeatureModel, - WorkspaceModel, -} from '../../models'; -import { createTestingModule, type TestingModule } from '../utils'; - -interface Context { - module: TestingModule; - model: WorkspaceFeatureModel; - resolver: AdminWorkspaceResolver; - ws: Workspace; -} - -const test = ava as TestFn; - -test.before(async t => { - const module = await createTestingModule({}); - - t.context.model = module.get(WorkspaceFeatureModel); - t.context.resolver = module.get(AdminWorkspaceResolver); - t.context.module = module; -}); - -test.beforeEach(async t => { - await t.context.module.initTestingDB(); - const u1 = await t.context.module.get(UserModel).create({ - email: 'u1@affine.pro', - registered: true, - }); - - t.context.ws = await t.context.module.get(WorkspaceModel).create(u1.id); -}); - -test.after(async t => { - await t.context.module.close(); -}); - -test('should get null if workspace feature not found', async t => { - const { model, ws } = t.context; - const userFeature = await model.get(ws.id, 'unlimited_workspace'); - t.is(userFeature, null); -}); - -test('admin workspace update changes workspace flags', async t => { - await t.context.resolver.adminUpdateWorkspace({ - id: t.context.ws.id, - name: 'updated', - }); - t.is( - (await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name, - 'updated' - ); -}); - -test('should directly test workspace feature existence', async t => { - const { model, ws } = t.context; - - t.false(await model.has(ws.id, 'unlimited_workspace')); -}); - -test('should get workspace quota', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'team_plan_v1', 'test', { - memberLimit: 100, - }); - - const quota = await model.getQuota(ws.id); - t.snapshot(quota?.configs, 'team plan'); -}); - -test('should return null if quota removed', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'team_plan_v1', 'test', { - memberLimit: 100, - }); - - await model.remove(ws.id, 'team_plan_v1'); - - const quota = await model.getQuota(ws.id); - t.is(quota, null); -}); - -test('should list empty workspace features', async t => { - const { model, ws } = t.context; - - t.deepEqual(await model.list(ws.id), []); -}); - -test('should list workspace features by type', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'unlimited_workspace', 'test'); - await model.add(ws.id, 'team_plan_v1', 'test'); - - t.like(await model.list(ws.id, FeatureType.Quota), ['team_plan_v1']); - t.like(await model.list(ws.id, FeatureType.Feature), ['unlimited_workspace']); -}); - -test('should add workspace feature', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'unlimited_workspace', 'test'); - t.is( - (await model.get(ws.id, 'unlimited_workspace'))?.name, - 'unlimited_workspace' - ); - t.true(await model.has(ws.id, 'unlimited_workspace')); - t.true((await model.list(ws.id)).includes('unlimited_workspace')); -}); - -test('should add workspace feature with overrides', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'team_plan_v1', 'test'); - const f1 = await model.get(ws.id, 'team_plan_v1'); - await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 }); - const f2 = await model.get(ws.id, 'team_plan_v1'); - - t.not(f1!.configs.memberLimit, f2!.configs.memberLimit); - t.is(f2!.configs.memberLimit, 100); -}); - -test('should not add existing workspace feature', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'team_plan_v1', 'test'); - await model.add(ws.id, 'team_plan_v1', 'test'); - - t.like(await model.list(ws.id), ['team_plan_v1']); -}); - -test('should replace existing workspace if overrides updated', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 10 }); - await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 }); - const f2 = await model.get(ws.id, 'team_plan_v1'); - - t.is(f2!.configs.memberLimit, 100); -}); - -test('should remove workspace feature', async t => { - const { model, ws } = t.context; - - await model.add(ws.id, 'team_plan_v1', 'test'); - await model.remove(ws.id, 'team_plan_v1'); - t.false(await model.has(ws.id, 'team_plan_v1')); - t.false((await model.list(ws.id)).includes('team_plan_v1')); -}); diff --git a/packages/backend/server/src/__tests__/models/feature.spec.ts b/packages/backend/server/src/__tests__/models/feature.spec.ts index 16a04a1a3a..97a03f9989 100644 --- a/packages/backend/server/src/__tests__/models/feature.spec.ts +++ b/packages/backend/server/src/__tests__/models/feature.spec.ts @@ -27,14 +27,7 @@ test.after(async t => { test('should get feature', async t => { const { feature } = t.context; - const freePlanFeature = await feature.get('free_plan_v1'); + const adminFeature = await feature.get('administrator'); - t.snapshot(freePlanFeature.configs); -}); - -test('should throw if feature not found', async t => { - const { feature } = t.context; - await t.throwsAsync(feature.get('not_found_feature' as any), { - message: 'Feature not_found_feature not found', - }); + t.deepEqual(adminFeature.configs, {}); }); diff --git a/packages/backend/server/src/__tests__/models/permission-projection.spec.ts b/packages/backend/server/src/__tests__/models/permission-projection.spec.ts deleted file mode 100644 index 18485e95da..0000000000 --- a/packages/backend/server/src/__tests__/models/permission-projection.spec.ts +++ /dev/null @@ -1,621 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { PrismaClient } from '@prisma/client'; -import test from 'ava'; - -import { PermissionProjectionChecker } from '../../core/permission/projection-checker'; -import { - DocRole, - PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, - PermissionProjectionModel, - permissionProjectionTriggerErrorCategory, - WorkspaceMemberStatus, - WorkspaceRole, -} from '../../models'; -import { createModule } from '../create-module'; -import { Mockers } from '../mocks'; - -const module = await createModule({}); -const db = module.get(PrismaClient); - -test.after.always(async () => { - await module.close(); -}); - -test('payment provider facts migration makes nullable provider identities explicit', t => { - const migration = readFileSync( - join( - process.cwd(), - 'migrations/20260604000000_payment_provider_facts/migration.sql' - ), - 'utf8' - ); - - t.regex( - migration, - /provider_subscriptions_stripe_identity_check[\s\S]*"provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL/ - ); - t.regex( - migration, - /provider_subscriptions_revenuecat_identity_check[\s\S]*"provider" <> 'revenuecat' OR \("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL\)/ - ); - t.regex( - migration, - /CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"\("provider", "external_subscription_id"\)/ - ); - t.regex( - migration, - /CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"\("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id"\)/ - ); -}); - -class TestPermissionProjectionModel extends PermissionProjectionModel { - constructor(private readonly fakeDb: unknown) { - super(); - } - - protected override get db() { - return this.fakeDb as never; - } -} - -let appliedPermissionProjectionTriggerFunctionUpdates = false; -async function applyPermissionProjectionTriggerFunctionUpdates() { - if (appliedPermissionProjectionTriggerFunctionUpdates) { - return; - } - const migration = readFileSync( - join( - process.cwd(), - 'migrations/20260512133700_workspace_runtime_states/migration.sql' - ), - 'utf8' - ); - for (const name of [ - 'affine_permission_project_new_workspace_member', - 'affine_permission_project_new_workspace_invitation', - 'affine_permission_project_new_doc_access_policy', - 'affine_permission_project_new_doc_grant', - ]) { - const sql = migration.match( - new RegExp( - `CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;` - ) - )?.[0]; - if (!sql) { - throw new Error(`Missing migration function ${name}`); - } - await db.$executeRawUnsafe(sql); - } - appliedPermissionProjectionTriggerFunctionUpdates = true; -} - -async function hasCurrentWorkspaceInvitationColumns() { - const rows = await db.$queryRaw<{ columnName: string }[]>` - SELECT column_name AS "columnName" - FROM information_schema.columns - WHERE table_name = 'workspace_invitations' - AND column_name IN ('requested_role', 'status', 'kind') - `; - return rows.length === 3; -} - -test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => { - const queryResults = [ - [{ count: 1n }], - [{ count: 2n }], - [{ count: 3n }], - [{ count: 4n }], - [{ count: 5n }], - [{ count: 6n }], - [{ count: 7n }], - [{ count: 8n }], - [{ count: 9n }], - [{ count: 10n }], - [ - { category: 'legacy_doc_external_row', count: 11n }, - { category: 'doc_default_owner', count: 12n }, - ], - ]; - const model = new TestPermissionProjectionModel({ - $queryRaw: async () => queryResults.shift(), - }); - - t.deepEqual(await model.checkLegacyProjection(), { - oldWorkspacePolicyMismatch: 1, - oldAcceptedMemberMismatch: 2, - extraProjectedMember: 3, - oldInvitationMismatch: 4, - extraProjectedInvitation: 5, - oldDocGrantMismatch: 6, - extraProjectedDocGrant: 7, - oldDocPolicyMismatch: 8, - extraProjectedDocPolicy: 9, - runtimeStateMissing: 0, - runtimeStateMismatch: 0, - ownerConflict: 10, - oldNewDecisionMismatch: 0, - invalidLegacyRows: { - legacy_doc_external_row: 11, - doc_default_owner: 12, - }, - }); -}); - -test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => { - const executed: unknown[] = []; - let transactionOptions: unknown; - const model = new TestPermissionProjectionModel({ - $transaction: async ( - callback: (tx: unknown) => Promise, - options: unknown - ) => { - transactionOptions = options; - await callback({ - $executeRaw: async (query: unknown) => { - executed.push(query); - }, - }); - }, - }); - - await model.backfillLegacyProjection(); - - t.is(executed.length, 11); - t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 }); - t.regex(String(executed[0]), /affine\.permission_sync_origin/); -}); - -test('PermissionProjectionModel exposes stable trigger metric categories', t => { - t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [ - 'owner_conflict', - 'invalid_legacy_role', - 'foreign_key_missing', - 'projection_recursion_guard_missing', - 'unknown', - ]); -}); - -test('permission projection migration uses non-recursive origin guard', t => { - const migration = readFileSync( - join( - process.cwd(), - 'migrations/20260512133700_workspace_runtime_states/migration.sql' - ), - 'utf8' - ); - const guardBody = migration.match( - /CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/ - )?.[0]; - - t.truthy(guardBody); - t.true( - guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN') - ); - t.false( - guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()') - ); - t.truthy( - migration.match( - /CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/ - ) - ); -}); - -test('permission projection trigger maps legacy workspace permission rows', async t => { - const workspace = await module.create(Mockers.Workspace); - const [admin, pending] = await module.create(Mockers.User, 2); - - await db.workspaceUserRole.createMany({ - data: [ - { - workspaceId: workspace.id, - userId: admin.id, - type: WorkspaceRole.Admin, - status: WorkspaceMemberStatus.Accepted, - }, - { - workspaceId: workspace.id, - userId: pending.id, - type: WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus.Pending, - }, - ], - }); - - const member = await db.workspaceMember.findFirstOrThrow({ - where: { - workspaceId: workspace.id, - userId: admin.id, - state: 'active', - }, - }); - const invitation = await db.workspaceInvitation.findUniqueOrThrow({ - where: { - workspaceId_inviteeUserId: { - workspaceId: workspace.id, - inviteeUserId: pending.id, - }, - }, - }); - - t.is(member.role, 'admin'); - t.is(invitation.requestedRole, 'member'); - t.is(invitation.status, 'pending'); -}); - -test('permission projection trigger maps legacy doc policy rows', async t => { - const workspace = await module.create(Mockers.Workspace); - - await db.workspaceDoc.create({ - data: { - workspaceId: workspace.id, - docId: 'public-doc', - public: true, - defaultRole: DocRole.Reader, - }, - }); - - const policy = await db.docAccessPolicy.findUniqueOrThrow({ - where: { - workspaceId_docId: { - workspaceId: workspace.id, - docId: 'public-doc', - }, - }, - }); - - t.is(policy.visibility, 'public'); - t.is(policy.publicRole, 'external'); - t.is(policy.memberDefaultRole, 'reader'); -}); - -async function hasDocGrantLegacyProjectionColumns() { - const rows = await db.$queryRaw<{ columnName: string }[]>` - SELECT column_name AS "columnName" - FROM information_schema.columns - WHERE table_name = 'doc_grants' - AND column_name IN ( - 'legacy_workspace_id', - 'legacy_doc_id', - 'legacy_user_id' - ) - `; - return rows.length === 3; -} - -test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => { - if (!(await hasDocGrantLegacyProjectionColumns())) { - t.false( - Boolean(process.env.CI), - 'current local test database predates doc_grants legacy columns' - ); - return; - } - - const workspace = await module.create(Mockers.Workspace); - const user = await module.create(Mockers.User); - - await db.workspaceDocUserRole.createMany({ - data: [ - { - workspaceId: workspace.id, - docId: 'valid-grant', - userId: user.id, - type: DocRole.Reader, - }, - { - workspaceId: workspace.id, - docId: 'dirty-external', - userId: user.id, - type: DocRole.External, - }, - { - workspaceId: workspace.id, - docId: 'dirty-none', - userId: user.id, - type: DocRole.None, - }, - ], - }); - - const grants = await db.docGrant.findMany({ - where: { - workspaceId: workspace.id, - principalId: user.id, - }, - orderBy: { - docId: 'asc', - }, - }); - - t.deepEqual( - grants.map(grant => [grant.docId, grant.role]), - [['valid-grant', 'reader']] - ); -}); - -test('permission projection trigger clears legacy row for non-active new workspace member states', async t => { - await applyPermissionProjectionTriggerFunctionUpdates(); - const workspace = await module.create(Mockers.Workspace); - const user = await module.create(Mockers.User); - - const member = await db.workspaceMember.create({ - data: { - workspaceId: workspace.id, - userId: user.id, - role: 'member', - state: 'active', - }, - }); - - t.truthy( - await db.workspaceUserRole.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: user.id, - }, - }, - }) - ); - - await db.workspaceMember.update({ - where: { id: member.id }, - data: { state: 'suspended' }, - }); - - t.is( - await db.workspaceUserRole.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: user.id, - }, - }, - }), - null - ); -}); - -test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => { - if (!(await hasCurrentWorkspaceInvitationColumns())) { - t.false( - Boolean(process.env.CI), - 'current local test database predates workspace invitation projection columns' - ); - return; - } - await applyPermissionProjectionTriggerFunctionUpdates(); - const workspace = await module.create(Mockers.Workspace); - const user = await module.create(Mockers.User); - - const [invitation] = await db.$queryRaw<{ id: string }[]>` - INSERT INTO workspace_invitations ( - workspace_id, - invitee_user_id, - requested_role, - status, - kind - ) - VALUES ( - ${workspace.id}, - ${user.id}, - 'member', - 'pending', - 'email' - ) - RETURNING id - `; - - t.is( - ( - await db.workspaceUserRole.findUniqueOrThrow({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: user.id, - }, - }, - }) - ).status, - 'Pending' - ); - - await db.$executeRaw` - UPDATE workspace_invitations - SET status = 'declined' - WHERE id = ${invitation.id} - `; - - t.is( - await db.workspaceUserRole.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: user.id, - }, - }, - }), - null - ); -}); - -test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => { - await applyPermissionProjectionTriggerFunctionUpdates(); - const workspace = await module.create(Mockers.Workspace); - - await db.workspaceDoc.create({ - data: { - workspaceId: workspace.id, - docId: 'metadata-doc', - public: true, - defaultRole: DocRole.Reader, - mode: 1, - blocked: true, - title: 'Title', - summary: 'Summary', - publishedAt: new Date('2026-01-01T00:00:00Z'), - }, - }); - - await db.docAccessPolicy.delete({ - where: { - workspaceId_docId: { - workspaceId: workspace.id, - docId: 'metadata-doc', - }, - }, - }); - - const doc = await db.workspaceDoc.findUniqueOrThrow({ - where: { - workspaceId_docId: { - workspaceId: workspace.id, - docId: 'metadata-doc', - }, - }, - }); - - t.is(doc.public, false); - t.is(doc.defaultRole, DocRole.Manager); - t.is(doc.publishedAt, null); - t.is(doc.mode, 1); - t.is(doc.blocked, true); - t.is(doc.title, 'Title'); - t.is(doc.summary, 'Summary'); -}); - -test('permission projection trigger ignores group doc grants on legacy projection', async t => { - await applyPermissionProjectionTriggerFunctionUpdates(); - const workspace = await module.create(Mockers.Workspace); - const user = await module.create(Mockers.User); - - await db.docGrant.create({ - data: { - workspaceId: workspace.id, - docId: 'group-doc', - principalType: 'user', - principalId: user.id, - role: 'reader', - }, - }); - await db.docGrant.create({ - data: { - workspaceId: workspace.id, - docId: 'group-doc', - principalType: 'group', - principalId: user.id, - role: 'manager', - }, - }); - await db.docGrant.delete({ - where: { - workspaceId_docId_principalType_principalId: { - workspaceId: workspace.id, - docId: 'group-doc', - principalType: 'group', - principalId: user.id, - }, - }, - }); - - const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({ - where: { - workspaceId_docId_userId: { - workspaceId: workspace.id, - docId: 'group-doc', - userId: user.id, - }, - }, - }); - - t.is(legacyGrant.type, DocRole.Reader); -}); - -test('PermissionProjectionModel parses trigger error metric category', t => { - t.is( - permissionProjectionTriggerErrorCategory( - new Error('permission_projection_error:owner_conflict:duplicate owner') - ), - 'owner_conflict' - ); - t.is( - permissionProjectionTriggerErrorCategory( - new Error('permission_projection_error:unexpected:nope') - ), - 'unknown' - ); - t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null); -}); - -test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => { - const checker = new PermissionProjectionChecker( - { - workspace: { - findMany: async () => [], - }, - $queryRaw: async () => [ - { - category: 'active_member_doc', - workspaceId: 'w1', - docId: 'doc1', - userId: 'u1', - workspaceActions: null, - docActions: ['Doc.Read'], - }, - { - category: 'explicit_doc_grant', - workspaceId: 'w1', - docId: 'doc2', - userId: 'u1', - workspaceActions: null, - docActions: ['Doc.Read'], - }, - { - category: 'workspace_invitation', - workspaceId: 'w1', - docId: null, - userId: 'u2', - workspaceActions: ['Workspace.Read'], - docActions: null, - }, - ], - } as never, - { - permissionProjection: { - checkLegacyProjection: async () => ({}), - }, - } as never, - { - load: async (input: { docs?: [{ docId: string }] }) => ({ - version: 1, - workspace: { marker: 'legacy' }, - docs: input.docs - ? [{ docId: input.docs[0].docId, marker: 'legacy' }] - : [], - }), - loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({ - version: 1, - workspace: { marker: input.docs ? 'legacy' : 'projection' }, - docs: input.docs - ? [ - { - docId: input.docs[0].docId, - marker: - input.docs[0].docId === 'doc1' ? 'legacy' : 'projection', - }, - ] - : [], - }), - } as never, - { - evaluate: (input: unknown) => input, - } as never - ); - - t.deepEqual(await checker.checkLegacyProjection(), { - oldNewDecisionMismatch: 2, - }); -}); diff --git a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts index 4f77c4c23b..b901330e9b 100644 --- a/packages/backend/server/src/__tests__/models/workspace-user.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace-user.spec.ts @@ -5,7 +5,12 @@ import test from 'ava'; import Sinon from 'sinon'; import { EventBus, NewOwnerIsNotActiveMember } from '../../base'; -import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models'; +import { + DocRole, + Models, + WorkspaceMemberStatus, + WorkspaceRole, +} from '../../models'; import { createModule, TestingModule } from '../create-module'; import { Mockers } from '../mocks'; @@ -48,10 +53,12 @@ test('should transfer workespace owner', async t => { owner: { id: user.id }, }); - await module.create(Mockers.WorkspaceUser, { - workspaceId: workspace.id, - userId: user2.id, - }); + await models.workspaceUser.set( + workspace.id, + user2.id, + WorkspaceRole.Collaborator, + { status: WorkspaceMemberStatus.Accepted } + ); await models.workspaceUser.setOwner(workspace.id, user2.id); @@ -78,10 +85,12 @@ test('should keep old owner as admin when transferring a team workspace', async id: workspace.id, quantity: 10, }); - await module.create(Mockers.WorkspaceUser, { - workspaceId: workspace.id, - userId: user2.id, - }); + await models.workspaceUser.set( + workspace.id, + user2.id, + WorkspaceRole.Collaborator, + { status: WorkspaceMemberStatus.Accepted } + ); await models.workspaceUser.setOwner(workspace.id, user2.id); @@ -99,11 +108,12 @@ test('should throw if transfer owner to non-active member', async t => { instanceOf: NewOwnerIsNotActiveMember, }); - await module.create(Mockers.WorkspaceUser, { - workspaceId: workspace.id, - userId: user2.id, - status: WorkspaceMemberStatus.AllocatingSeat, - }); + await models.workspaceUser.set( + workspace.id, + user2.id, + WorkspaceRole.Collaborator, + { status: WorkspaceMemberStatus.AllocatingSeat } + ); await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), { instanceOf: NewOwnerIsNotActiveMember, @@ -231,22 +241,7 @@ test('should delete workspace user role', async t => { t.is(role, null); }); -test('should delete legacy-only external workspace user role', async t => { - const workspace = await module.create(Mockers.Workspace); - const u1 = await module.create(Mockers.User); - - await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, { - status: WorkspaceMemberStatus.Accepted, - }); - - t.truthy(await models.workspaceUser.get(workspace.id, u1.id)); - - await models.workspaceUser.delete(workspace.id, u1.id); - - t.is(await models.workspaceUser.get(workspace.id, u1.id), null); -}); - -test('should convert existing workspace user role to legacy-only external role', async t => { +test('should remove workspace permission when changing a member to external', async t => { const workspace = await module.create(Mockers.Workspace); const u1 = await module.create(Mockers.User); @@ -258,85 +253,35 @@ test('should convert existing workspace user role to legacy-only external role', status: WorkspaceMemberStatus.Accepted, } ); + const docId = 'external-member-doc'; + await models.docUser.set(workspace.id, docId, u1.id, DocRole.Editor); await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, { status: WorkspaceMemberStatus.Accepted, }); - const role = await models.workspaceUser.get(workspace.id, u1.id); - t.is(role?.type, WorkspaceRole.External); + t.is(await models.workspaceUser.get(workspace.id, u1.id), null); t.is( await db.workspaceMember.count({ where: { workspaceId: workspace.id, userId: u1.id, - state: 'active', }, }), 0 ); -}); - -test('should backfill legacy permission id for new workspace member writes', async t => { - const workspace = await module.create(Mockers.Workspace); - const u1 = await module.create(Mockers.User); - - await models.workspaceUser.set( - workspace.id, - u1.id, - WorkspaceRole.Collaborator, - { - status: WorkspaceMemberStatus.Accepted, - } - ); - - const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({ - where: { - workspaceId_userId: { + t.is( + await db.workspaceInvitation.count({ + where: { workspaceId: workspace.id, - userId: u1.id, + inviteeUserId: u1.id, }, - }, - }); - const member = await db.workspaceMember.findFirstOrThrow({ - where: { - workspaceId: workspace.id, - userId: u1.id, - state: 'active', - }, - }); - - t.is(member.legacyPermissionId, legacyRole.id); -}); - -test('should backfill legacy permission id for new workspace invitation writes', async t => { - const workspace = await module.create(Mockers.Workspace); - const u1 = await module.create(Mockers.User); - - await models.workspaceUser.set( - workspace.id, - u1.id, - WorkspaceRole.Collaborator, - { - status: WorkspaceMemberStatus.Pending, - } + }), + 0 + ); + t.is( + (await models.docUser.get(workspace.id, docId, u1.id))?.type, + DocRole.Editor ); - - const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({ - where: { - workspaceId_userId: { - workspaceId: workspace.id, - userId: u1.id, - }, - }, - }); - const invitation = await db.workspaceInvitation.findFirstOrThrow({ - where: { - workspaceId: workspace.id, - inviteeUserId: u1.id, - }, - }); - - t.is(invitation.legacyPermissionId, legacyRole.id); }); test('should get user workspace roles with filter', async t => { @@ -344,19 +289,19 @@ test('should get user workspace roles with filter', async t => { const ws2 = await module.create(Mockers.Workspace); const user = await module.create(Mockers.User); - await db.workspaceUserRole.createMany({ + await db.workspaceMember.createMany({ data: [ { workspaceId: ws1.id, userId: user.id, - type: WorkspaceRole.Admin, - status: WorkspaceMemberStatus.Accepted, + role: 'admin', + state: 'active', }, { workspaceId: ws2.id, userId: user.id, - type: WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus.Accepted, + role: 'member', + state: 'active', }, ], }); @@ -381,14 +326,12 @@ test('should paginate workspace user roles', async t => { })), }); - await db.workspaceUserRole.createMany({ + await db.workspaceMember.createMany({ data: users.map((user, i) => ({ workspaceId: workspace.id, userId: user.id, - type: WorkspaceRole.Collaborator, - status: Object.values(WorkspaceMemberStatus)[ - Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length) - ], + role: 'member', + state: 'active', createdAt: new Date(Date.now() + i * 1000), })), }); @@ -421,19 +364,20 @@ test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async const workspace = await module.create(Mockers.Workspace); for (const user of users) { - await module.create(Mockers.WorkspaceUser, { - workspaceId: workspace.id, - userId: user.id, - status: WorkspaceMemberStatus.AllocatingSeat, - }); + await models.workspaceUser.set( + workspace.id, + user.id, + WorkspaceRole.Collaborator, + { status: WorkspaceMemberStatus.AllocatingSeat } + ); } await models.workspaceUser.allocateSeats(workspace.id, 1); - let count = await db.workspaceUserRole.count({ + let count = await db.workspaceInvitation.count({ where: { workspaceId: workspace.id, - status: WorkspaceMemberStatus.Pending, + status: 'pending', }, }); @@ -441,10 +385,10 @@ test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async await models.workspaceUser.allocateSeats(workspace.id, 3); - count = await db.workspaceUserRole.count({ + count = await db.workspaceInvitation.count({ where: { workspaceId: workspace.id, - status: WorkspaceMemberStatus.Pending, + status: 'pending', }, }); diff --git a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md index ee3a350393..b77b0a337a 100644 --- a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md +++ b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md @@ -40,11 +40,15 @@ Generated by [AVA](https://avajs.dev). activatedCount: 1, canceledCount: 0, dbObservability: { + externalProductId: 'app.affine.pro.Annual', + externalRef: 'rc:app.affine.pro.Annual', iapStore: 'app_store', + metadata: { + entitlement: 'Pro', + isTrial: false, + willRenew: true, + }, provider: 'revenuecat', - rcEntitlement: 'Pro', - rcExternalRef: 'orig-tx-1', - rcProductId: 'app.affine.pro.Annual', }, lastActivated: { plan: 'pro', @@ -53,14 +57,14 @@ Generated by [AVA](https://avajs.dev). subscriberCount: 1, } -## should process expiration/refund by deleting subscription and emitting canceled +## should process expiration/refund by canceling subscription and emitting canceled > should process expiration/refund and emit canceled { activatedEventCount: 0, - canceledEventCount: 2, - finalDBCount: 0, + canceledEventCount: 1, + finalDBCount: 1, lastCanceled: { plan: 'pro', recurring: 'yearly', @@ -116,12 +120,16 @@ Generated by [AVA](https://avajs.dev). activatedCount: 1, name: 'Pro monthly on iOS', rec: { + externalProductId: 'app.affine.pro.Monthly', + externalRef: 'rc:app.affine.pro.Monthly', iapStore: 'app_store', + metadata: { + entitlement: 'Pro', + isTrial: false, + willRenew: true, + }, plan: 'pro', provider: 'revenuecat', - rcEntitlement: 'Pro', - rcExternalRef: 'orig-ios-1', - rcProductId: 'app.affine.pro.Monthly', recurring: 'monthly', status: 'active', }, @@ -130,12 +138,16 @@ Generated by [AVA](https://avajs.dev). activatedCount: 1, name: 'AI annual on Android', rec: { + externalProductId: 'app.affine.pro.ai.Annual', + externalRef: 'rc:app.affine.pro.ai.Annual', iapStore: 'play_store', + metadata: { + entitlement: 'AI', + isTrial: false, + willRenew: true, + }, plan: 'ai', provider: 'revenuecat', - rcEntitlement: 'AI', - rcExternalRef: 'token-android-1', - rcProductId: 'app.affine.pro.ai.Annual', recurring: 'yearly', status: 'active', }, @@ -148,7 +160,7 @@ Generated by [AVA](https://avajs.dev). > should keep active after trial renewal { - activatedCount: 2, + activatedCount: 1, canceledCount: 0, status: 'active', } @@ -159,7 +171,7 @@ Generated by [AVA](https://avajs.dev). { canceledCount: 1, - finalDBCount: 0, + finalDBCount: 1, } ## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end) @@ -188,7 +200,7 @@ Generated by [AVA](https://avajs.dev). { activatedCount: 0, - hasRCRecord: false, + hasRCRecord: true, } ## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records @@ -196,18 +208,30 @@ Generated by [AVA](https://avajs.dev). > should reconcile and fix missing or out-of-order states for revenuecat records { - activatedCount: 1, + activatedCount: 0, canceledCount: 0, - subscriberCount: 1, + hasActiveEntitlement: true, + records: [ + { + externalRef: 'rc:app.affine.pro.Annual', + metadata: { + entitlement: 'Pro', + isTrial: false, + willRenew: true, + }, + }, + ], + reusedPlaceholder: true, + subscriberCount: 2, } ## should treat refund as early expiration and revoke immediately -> should delete record and emit canceled on refund +> should cancel record and emit canceled on refund { - canceledEventCount: 2, - finalDBCount: 0, + canceledEventCount: 1, + finalDBCount: 1, } ## should ignore non-whitelisted productId and not write to DB @@ -236,11 +260,11 @@ Generated by [AVA](https://avajs.dev). c: 0, }, afterSecond: { - a: 3, + a: 2, c: 0, }, afterThird: { - a: 4, + a: 2, c: 1, }, }, diff --git a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap index a724cdd430..43c3b4cd38 100644 Binary files a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap and b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap differ diff --git a/packages/backend/server/src/__tests__/payment/event.spec.ts b/packages/backend/server/src/__tests__/payment/event.spec.ts index 1a9119194e..eaa750d5c5 100644 --- a/packages/backend/server/src/__tests__/payment/event.spec.ts +++ b/packages/backend/server/src/__tests__/payment/event.spec.ts @@ -1,5 +1,7 @@ +import { TransactionHost } from '@nestjs-cls/transactional'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; import { CryptoHelper, EventBus, JobQueue } from '../../base'; import { EntitlementService } from '../../core/entitlement'; @@ -127,16 +129,23 @@ test('onetime selfhost license seat allocation ignores projected license quantit }); test('recurring selfhost license activation returns activation projection without remote health recheck', async t => { + const transactionHost = Sinon.stub(TransactionHost, 'getInstance').returns({ + withTransaction: (...args: unknown[]) => + (args.at(-1) as () => Promise)(), + } as TransactionHost); + t.teardown(() => transactionHost.restore()); const events: Array<{ name: string; payload: unknown }> = []; const upserts: unknown[] = []; const entitlements: unknown[] = []; + const operations: string[] = []; const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; const service = new LicenseService( { installedLicense: { findUnique: async () => null, - upsert: async (input: unknown) => { + create: async (input: unknown) => { upserts.push(input); + operations.push('source'); return { workspaceId: 'ws', key: 'license-key', @@ -156,6 +165,7 @@ test('recurring selfhost license activation returns activation projection withou { upsertFromValidatedSelfhostLicense: async (input: unknown) => { entitlements.push(input); + operations.push('entitlement'); }, } as unknown as EntitlementService, {} as unknown as QuotaStateService @@ -186,6 +196,7 @@ test('recurring selfhost license activation returns activation projection withou t.is(entitlements.length, 1); t.is(upserts.length, 1); t.is(activatedLicenseKey, 'license-key'); + t.deepEqual(operations, ['source', 'entitlement']); t.deepEqual(events, [ { name: 'workspace.subscription.activated', diff --git a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts index 671578e7ce..559a90f4df 100644 --- a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts +++ b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts @@ -9,6 +9,7 @@ import { SubscriptionAlreadyExists, } from '../../base'; import { ConfigModule } from '../../base/config'; +import { EntitlementService } from '../../core/entitlement'; import { FeatureService } from '../../core/features'; import { Models } from '../../models'; import { PaymentModule } from '../../plugins/payment'; @@ -107,12 +108,20 @@ test.beforeEach(async t => { Sinon.stub(rc, 'getCustomerAlias').resolves([appUserId]); t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves( - subs.map(s => ({ ...s, customerId: customerId })) + subs.map(s => ({ + ...s, + customerId, + externalRef: s.externalRef ?? `rc:${s.productId}`, + })) ); t.context.mockSubSeq = sequences => { const stub = Sinon.stub(rc, 'getSubscriptions'); sequences.forEach((seq, idx) => { - const subs = seq.map(s => ({ ...s, customerId: customerId })); + const subs = seq.map(s => ({ + ...s, + customerId, + externalRef: s.externalRef ?? `rc:${s.productId}`, + })); if (idx === 0) stub.onFirstCall().resolves(subs); else if (idx === 1) stub.onSecondCall().resolves(subs); else stub.onCall(idx).resolves(subs); @@ -189,7 +198,7 @@ test('should resolve product mapping consistently (whitelist, override, unknown) test('should standardize RC subscriber response and upsert subscription with observability fields', async t => { const { webhook, collectEvents, mockAlias, mockSub } = t.context; - mockAlias(user.id); + const alias = mockAlias(user.id); const subscriber = mockSub([ { identifier: 'Pro', @@ -217,14 +226,14 @@ test('should standardize RC subscriber response and upsert subscription with obs }); const { activatedCount, canceledCount, events } = collectEvents(); - const record = await t.context.db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, + const record = await t.context.db.providerSubscription.findFirst({ + where: { targetType: 'user', targetId: user.id, plan: 'pro' }, select: { provider: true, iapStore: true, - rcEntitlement: true, - rcProductId: true, - rcExternalRef: true, + metadata: true, + externalProductId: true, + externalRef: true, }, }); @@ -241,20 +250,65 @@ test('should standardize RC subscriber response and upsert subscription with obs }, 'should standardize payload and have events' ); + + const transferred = await t.context.models.user.create({ + email: 'revenuecat-transfer@affine.pro', + }); + alias.resolves([transferred.id]); + await webhook.onWebhook({ + appUserId: transferred.id, + event: { + id: 'evt_1_transfer', + environment: 'PRODUCTION', + app_id: 'app.affine.pro', + type: 'TRANSFER', + store: 'app_store', + original_transaction_id: 'orig-tx-1', + }, + }); + t.is( + ( + await t.context.db.providerSubscription.findFirstOrThrow({ + where: { + provider: 'revenuecat', + externalRef: 'rc:app.affine.pro.Annual', + }, + }) + ).targetId, + transferred.id + ); + t.true( + t.context.event.emit.calledWith('entitlement.changed', { + targetType: 'user', + targetId: user.id, + }) + ); + t.true( + t.context.event.emit.calledWith('user.subscription.canceled', { + userId: user.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + }) + ); }); -test('should process expiration/refund by deleting subscription and emitting canceled', async t => { +test('should process expiration/refund by canceling subscription and emitting canceled', async t => { const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context; mockAlias(user.id); - await db.subscription.create({ + await db.providerSubscription.create({ data: { + targetType: 'user', targetId: user.id, plan: 'pro', status: 'active', provider: 'revenuecat', recurring: 'yearly', - start: new Date('2025-01-01T00:00:00.000Z'), + periodStart: new Date('2025-01-01T00:00:00.000Z'), + iapStore: 'app_store', + externalCustomerId: 'cust', + externalProductId: 'app.affine.pro.Annual', + externalRef: 'rc:app.affine.pro.Annual', }, }); @@ -279,7 +333,7 @@ test('should process expiration/refund by deleting subscription and emitting can original_transaction_id: 'orig-tx-2', }); - const finalDBCount = await db.subscription.count({ + const finalDBCount = await db.providerSubscription.count({ where: { targetId: user.id, plan: 'pro' }, }); @@ -304,33 +358,47 @@ test('should enqueue per-user reconciliation jobs for existing RC active/trialin const cron = module.get(SubscriptionCronJobs); - const common = { provider: 'revenuecat', start: new Date() } as const; - await db.subscription.createMany({ + const common = { provider: 'revenuecat', periodStart: new Date() } as const; + await db.providerSubscription.createMany({ data: [ { + targetType: 'user', targetId: 'u1', plan: 'pro', status: 'active', recurring: 'monthly', + iapStore: 'app_store', + externalCustomerId: 'c1', + externalProductId: 'pro-monthly', + externalRef: 'r1', ...common, }, { + targetType: 'user', targetId: 'u2', plan: 'ai', status: 'trialing', recurring: 'yearly', + iapStore: 'app_store', + externalCustomerId: 'c2', + externalProductId: 'ai-yearly', + externalRef: 'r2', ...common, }, { + targetType: 'user', targetId: 'u1', plan: 'ai', status: 'past_due', recurring: 'monthly', + iapStore: 'play_store', + externalCustomerId: 'c1', + externalProductId: 'ai-monthly', + externalRef: 'r3', ...common, }, ], }); - await cron.reconcileRevenueCatSubscriptions(); const calls = module.queue.add.getCalls().map(c => ({ @@ -411,17 +479,21 @@ test('should activate subscriptions via webhook for whitelisted products across // reset event history between scenarios for clean counts event.emit.resetHistory?.(); await triggerWebhook(user.id, s.event); - const rec = await db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } }, + const rec = await db.providerSubscription.findFirst({ + where: { + targetType: 'user', + targetId: user.id, + plan: s.expectedPlan, + }, select: { plan: true, recurring: true, status: true, provider: true, iapStore: true, - rcEntitlement: true, - rcProductId: true, - rcExternalRef: true, + metadata: true, + externalProductId: true, + externalRef: true, }, }); const { activatedCount } = collectEvents(); @@ -479,9 +551,9 @@ test('should keep active and advance period dates when a trialing subscription r store: 'app_store', }); - const rec = await db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, - select: { status: true, start: true, end: true }, + const rec = await db.providerSubscription.findFirst({ + where: { targetType: 'user', targetId: user.id, plan: 'pro' }, + select: { status: true, periodStart: true, periodEnd: true }, }); const { activatedCount, canceledCount } = collectEvents(); t.snapshot( @@ -535,7 +607,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing store: 'app_store', }); - const finalDBCount = await db.subscription.count({ + const finalDBCount = await db.providerSubscription.count({ where: { targetId: user.id, plan: 'pro' }, }); const { canceledCount } = collectEvents(); @@ -564,8 +636,8 @@ test('should set canceledAt and keep active until expiration when will_renew is type: 'CANCELLATION', store: 'app_store', }); - const rec = await db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, + const rec = await db.providerSubscription.findFirst({ + where: { targetType: 'user', targetId: user.id, plan: 'pro' }, select: { status: true, canceledAt: true }, }); const { activatedCount, canceledCount } = collectEvents(); @@ -602,8 +674,8 @@ test('should retain record as past_due (inactive but not expired) and NOT emit c store: 'app_store', }); - const rec = await db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, + const rec = await db.providerSubscription.findFirst({ + where: { targetType: 'user', targetId: user.id, plan: 'pro' }, select: { status: true }, }); const { canceledCount } = collectEvents(); @@ -617,18 +689,25 @@ test('should block checkout when an existing subscription of the same plan is ac const { module, db } = t.context; const manager = module.get(UserSubscriptionManager); + let subscriptionId: string; { - await db.subscription.create({ + const subscription = await db.providerSubscription.create({ data: { + targetType: 'user', targetId: user.id, plan: 'pro', status: 'active', provider: 'revenuecat', recurring: 'monthly', - start: new Date('2025-01-01T00:00:00.000Z'), + periodStart: new Date('2025-01-01T00:00:00.000Z'), + iapStore: 'app_store', + externalCustomerId: 'cust', + externalProductId: 'app.affine.pro.Monthly', + externalRef: 'rc:app.affine.pro.Monthly', }, }); + subscriptionId = subscription.id; await t.throwsAsync( manager.checkout( @@ -649,9 +728,16 @@ test('should block checkout when an existing subscription of the same plan is ac } { - await db.subscription.update({ - where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, - data: { provider: 'stripe' }, + await db.providerSubscription.update({ + where: { id: subscriptionId }, + data: { + provider: 'stripe', + externalSubscriptionId: 'sub_existing', + iapStore: null, + externalCustomerId: null, + externalProductId: null, + externalRef: null, + }, }); await t.throwsAsync( @@ -677,17 +763,18 @@ test('should block checkout when an existing subscription of the same plan is ac test('should skip RC upsert when Stripe active already exists for same plan', async t => { const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context; mockAlias(user.id); - await db.subscription.create({ + await db.providerSubscription.create({ data: { + targetType: 'user', targetId: user.id, plan: 'pro', status: 'active', provider: 'stripe', recurring: 'monthly', - start: new Date('2025-01-01T00:00:00.000Z'), + periodStart: new Date('2025-01-01T00:00:00.000Z'), + externalSubscriptionId: 'sub_conflict', }, }); - mockSub([ { identifier: 'Pro', @@ -708,7 +795,7 @@ test('should skip RC upsert when Stripe active already exists for same plan', as store: 'app_store', }); - const rcRec = await db.subscription.findFirst({ + const rcRec = await db.providerSubscription.findFirst({ where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' }, }); const { activatedCount } = collectEvents(); @@ -720,19 +807,23 @@ test('should skip RC upsert when Stripe active already exists for same plan', as test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => { const { db, service } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + targetType: 'user', targetId: user.id, plan: 'pro', status: 'active', provider: 'revenuecat', recurring: 'monthly', - start: new Date(), + periodStart: new Date(), + iapStore: 'app_store', + externalCustomerId: 'cust', + externalProductId: 'app.affine.pro.Monthly', + externalRef: 'rc:managed', }, }); - // local helper used multiple times within this test - const expectManaged = async (fn: () => Promise) => + const expectManaged = async (fn: () => Promise) => t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay }); await expectManaged(() => @@ -752,29 +843,83 @@ test('should block read-write ops on revenuecat-managed record (cancel/resume/up }); test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => { - const { webhook, collectEvents, mockAlias, mockSub } = t.context; + const { webhook, db, collectEvents, mockAlias, mockSubSeq } = t.context; mockAlias(user.id); - const subscriber = mockSub([ - { - identifier: 'Pro', - isTrial: false, - isActive: true, - latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'), - expirationDate: new Date('2026-03-01T00:00:00.000Z'), - productId: 'app.affine.pro.Annual', - store: 'play_store', - willRenew: true, - duration: null, + const placeholder = await db.providerSubscription.create({ + data: { + targetType: 'user', + targetId: user.id, + plan: 'pro', + status: 'active', + provider: 'revenuecat', + recurring: 'yearly', + periodStart: new Date('2025-01-01T00:00:00.000Z'), + periodEnd: new Date('2999-01-01T00:00:00.000Z'), + iapStore: 'app_store', + externalCustomerId: user.id, + externalProductId: 'legacy_product:1', + externalRef: 'legacy_subscription:1', + metadata: { legacyRevenueCatIdentityIncomplete: true }, }, - ]); + }); + const subscription = { + identifier: 'Pro', + isTrial: false, + isActive: true, + latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'), + expirationDate: new Date('2026-03-01T00:00:00.000Z'), + productId: 'app.affine.pro.Annual', + store: 'play_store', + willRenew: true, + duration: null, + } as const; + const subscriber = mockSubSeq([[subscription], [subscription]]); + await webhook.syncAppUser(user.id); + await db.providerSubscription.create({ + data: { + targetType: 'user', + targetId: user.id, + plan: 'pro', + status: 'active', + provider: 'revenuecat', + recurring: 'yearly', + periodEnd: new Date('2999-01-01T00:00:00.000Z'), + iapStore: 'app_store', + externalCustomerId: user.id, + externalProductId: 'legacy_product:2', + externalRef: 'legacy_subscription:2', + metadata: { legacyRevenueCatIdentityIncomplete: true }, + }, + }); await webhook.syncAppUser(user.id); const { activatedCount, canceledCount } = collectEvents(); const subscriberCount = subscriber.getCalls()?.length || 0; + const records = await db.providerSubscription.findMany({ + where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' }, + select: { id: true, externalRef: true, metadata: true }, + }); + const normalizedRecords = records.map(({ id: _, ...record }) => record); + const activeEntitlement = await db.entitlement.findFirst({ + where: { + targetType: 'user', + targetId: user.id, + source: 'cloud_subscription', + status: 'active', + subjectId: placeholder.id, + }, + }); t.snapshot( - { subscriberCount, activatedCount, canceledCount }, + { + subscriberCount, + activatedCount, + canceledCount, + records: normalizedRecords, + reusedPlaceholder: records[0]?.id === placeholder.id, + hasActiveEntitlement: !!activeEntitlement, + }, 'should reconcile and fix missing or out-of-order states for revenuecat records' ); }); @@ -783,14 +928,19 @@ test('should treat refund as early expiration and revoke immediately', async t = const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context; mockAlias(user.id); - await db.subscription.create({ + await db.providerSubscription.create({ data: { + targetType: 'user', targetId: user.id, plan: 'pro', status: 'active', provider: 'revenuecat', recurring: 'monthly', - start: new Date('2025-01-01T00:00:00.000Z'), + periodStart: new Date('2025-01-01T00:00:00.000Z'), + iapStore: 'app_store', + externalCustomerId: 'cust', + externalProductId: 'app.affine.pro.Monthly', + externalRef: 'rc:app.affine.pro.Monthly', }, }); @@ -814,13 +964,13 @@ test('should treat refund as early expiration and revoke immediately', async t = store: 'app_store', }); - const count = await db.subscription.count({ + const count = await db.providerSubscription.count({ where: { targetId: user.id, plan: 'pro' }, }); const { canceledCount } = collectEvents(); t.snapshot( { finalDBCount: count, canceledEventCount: canceledCount }, - 'should delete record and emit canceled on refund' + 'should cancel record and emit canceled on refund' ); }); @@ -846,7 +996,9 @@ test('should ignore non-whitelisted productId and not write to DB', async t => { type: 'INITIAL_PURCHASE', store: 'app_store', }); - const dbCount = await db.subscription.count({ where: { targetId: user.id } }); + const dbCount = await db.providerSubscription.count({ + where: { targetId: user.id }, + }); const { activatedCount, canceledCount } = collectEvents(); t.snapshot( { dbCount, activatedCount, canceledCount }, @@ -901,8 +1053,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 type: 'INITIAL_PURCHASE', store: 'app_store', }); - const r1 = await db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: 'pro' } }, + const r1 = await db.providerSubscription.findFirst({ + where: { targetType: 'user', targetId: user.id, plan: 'pro' }, select: { plan: true, recurring: true, provider: true }, }); const s1 = collectEvents(); @@ -913,8 +1065,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 type: 'INITIAL_PURCHASE', store: 'play_store', }); - const r2 = await db.subscription.findUnique({ - where: { targetId_plan: { targetId: user.id, plan: 'ai' } }, + const r2 = await db.providerSubscription.findFirst({ + where: { targetType: 'user', targetId: user.id, plan: 'ai' }, select: { plan: true, recurring: true, provider: true }, }); const s2 = collectEvents(); @@ -925,7 +1077,9 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 type: 'INITIAL_PURCHASE', store: 'app_store', }); - const count = await db.subscription.count({ where: { targetId: user.id } }); + const count = await db.providerSubscription.count({ + where: { targetId: user.id }, + }); const s3 = collectEvents(); t.snapshot( @@ -1025,18 +1179,19 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy // case3: only stripe subscription -> should NOT sync (call count remains 2) { - await db.subscription.deleteMany({ + await db.providerSubscription.deleteMany({ where: { targetId: user.id, provider: 'revenuecat' }, }); - await db.subscription.create({ + await db.providerSubscription.create({ data: { + targetType: 'user', targetId: user.id, plan: 'pro', provider: 'stripe', status: 'active', recurring: 'monthly', - start: new Date('2025-01-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_123', + periodStart: new Date('2025-01-01T00:00:00.000Z'), + externalSubscriptionId: 'sub_123', }, }); const subs = await subResolver.refreshUserSubscriptions(currentUser); @@ -1048,30 +1203,49 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy test('user subscriptions ignore active rows after their current period ended', async t => { const { db, subResolver } = t.context; - await db.subscription.createMany({ + await db.providerSubscription.createMany({ data: [ { + targetType: 'user', targetId: user.id, plan: 'ai', provider: 'stripe', status: 'active', recurring: 'yearly', - start: new Date('2025-01-01T00:00:00.000Z'), - end: new Date('2025-01-08T00:00:00.000Z'), - stripeSubscriptionId: 'sub_expired_ai', + periodStart: new Date('2025-01-01T00:00:00.000Z'), + periodEnd: new Date('2025-01-08T00:00:00.000Z'), + externalSubscriptionId: 'sub_expired_ai', }, { + targetType: 'user', targetId: user.id, plan: 'pro', provider: 'stripe', status: 'active', recurring: 'yearly', - start: new Date('2025-01-01T00:00:00.000Z'), - end: new Date('2099-01-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_current_pro', + periodStart: new Date('2025-01-01T00:00:00.000Z'), + periodEnd: new Date('2099-01-01T00:00:00.000Z'), + externalSubscriptionId: 'sub_current_pro', }, ], }); + const entitlement = t.context.module.get(EntitlementService); + await entitlement.upsertFromCloudSubscription({ + targetId: user.id, + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + status: SubscriptionStatus.Active, + subscriptionId: 'sub_expired_ai', + end: new Date('2025-01-08T00:00:00.000Z'), + }); + await entitlement.upsertFromCloudSubscription({ + targetId: user.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + status: SubscriptionStatus.Active, + subscriptionId: 'sub_current_pro', + end: new Date('2099-01-01T00:00:00.000Z'), + }); const subscriptions = await subResolver.subscriptions(user, user); t.deepEqual(subscriptions.map(subscription => subscription.plan).sort(), [ @@ -1092,18 +1266,6 @@ test('user subscriptions preserve provider trialing status', async t => { email: `${Date.now()}-trial-status@affine.pro`, }); - await db.subscription.create({ - data: { - targetId: trialUser.id, - plan: SubscriptionPlan.Pro, - provider: 'stripe', - status: SubscriptionStatus.Trialing, - recurring: SubscriptionRecurring.Yearly, - start: new Date('2026-01-01T00:00:00.000Z'), - end: new Date('2099-01-01T00:00:00.000Z'), - stripeSubscriptionId: 'sub_trialing_status', - }, - }); await db.providerSubscription.create({ data: { provider: 'stripe', @@ -1117,6 +1279,14 @@ test('user subscriptions preserve provider trialing status', async t => { periodEnd: new Date('2099-01-01T00:00:00.000Z'), }, }); + await t.context.module.get(EntitlementService).upsertFromCloudSubscription({ + targetId: trialUser.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Yearly, + status: SubscriptionStatus.Trialing, + subscriptionId: 'sub_trialing_status', + end: new Date('2099-01-01T00:00:00.000Z'), + }); const subscriptions = await subResolver.subscriptions(trialUser, trialUser); diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index fb42a127f7..1e84a298ac 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -10,7 +10,9 @@ import { EventBus } from '../../base'; import { ConfigFactory, ConfigModule } from '../../base/config'; import { CurrentUser } from '../../core/auth'; import { AuthService } from '../../core/auth/service'; +import { EntitlementService } from '../../core/entitlement'; import { SubscriptionCronJobs } from '../../plugins/payment/cron'; +import { SelfhostTeamSubscriptionManager } from '../../plugins/payment/manager'; import { RevenueCatService } from '../../plugins/payment/revenuecat'; import { SubscriptionService } from '../../plugins/payment/service'; import { StripeFactory } from '../../plugins/payment/stripe'; @@ -226,7 +228,7 @@ test.beforeEach(async t => { await db.workspace.create({ data: { id: 'ws_1', - public: false, + accessPolicy: { create: {} }, }, }); await db.userStripeCustomer.create({ @@ -365,15 +367,17 @@ test('should list normal prices for user with old ai subscriptions', async t => test('should throw if user has subscription already', async t => { const { service, u1, db } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), }, }); @@ -394,15 +398,17 @@ test('should throw if user has subscription already', async t => { test('should allow checkout after local subscription period ended', async t => { const { service, u1, db, stripe } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_expired_ai', + externalSubscriptionId: 'sub_expired_ai', plan: SubscriptionPlan.AI, recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Active, - start: new Date('2026-05-04T13:11:45.000Z'), - end: new Date('2026-05-11T13:11:45.000Z'), + periodStart: new Date('2026-05-04T13:11:45.000Z'), + periodEnd: new Date('2026-05-11T13:11:45.000Z'), }, }); @@ -710,10 +716,6 @@ test('should be able to create subscription', async t => { await service.saveStripeSubscription(sub); - const subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id }, - }); - t.true( event.emit.calledOnceWith('user.subscription.activated', { userId: u1.id, @@ -721,8 +723,6 @@ test('should be able to create subscription', async t => { recurring: SubscriptionRecurring.Monthly, }) ); - t.is(subInDB?.stripeSubscriptionId, sub.id); - const providerFact = await db.providerSubscription.findUnique({ where: { provider_externalSubscriptionId: { @@ -760,26 +760,33 @@ test('should be able to update subscription', async t => { }) ); - const subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id }, + const subInDB = await db.providerSubscription.findUnique({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: sub.id, + }, + }, }); t.is(subInDB?.status, SubscriptionStatus.Active); t.is(subInDB?.canceledAt?.getTime(), canceledAt * 1000); }); -test('should replace old subscription row when stripe creates a new subscription for the same plan', async t => { +test('should preserve old provider fact when stripe creates a new subscription for the same plan', async t => { const { service, db, u1 } = t.context; - const old = await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_old', + externalSubscriptionId: 'sub_old', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Canceled, - start: new Date('2026-03-26T08:23:57.000Z'), - end: new Date('2027-03-26T08:23:57.000Z'), + periodStart: new Date('2026-03-26T08:23:57.000Z'), + periodEnd: new Date('2027-03-26T08:23:57.000Z'), }, }); @@ -801,14 +808,21 @@ test('should replace old subscription row when stripe creates a new subscription }, }); - const subscriptions = await db.subscription.findMany({ - where: { targetId: u1.id, plan: SubscriptionPlan.Pro }, + const subscriptions = await db.providerSubscription.findMany({ + where: { + provider: 'stripe', + targetType: 'user', + targetId: u1.id, + plan: SubscriptionPlan.Pro, + }, + orderBy: { externalSubscriptionId: 'asc' }, }); - t.is(subscriptions.length, 1); - t.is(subscriptions[0].id, old.id); - t.is(subscriptions[0].stripeSubscriptionId, 'sub_new'); + t.is(subscriptions.length, 2); + t.is(subscriptions[0].externalSubscriptionId, 'sub_new'); t.is(subscriptions[0].status, SubscriptionStatus.Active); + t.is(subscriptions[1].externalSubscriptionId, 'sub_old'); + t.is(subscriptions[1].status, SubscriptionStatus.Canceled); }); test('should be able to delete subscription', async t => { @@ -828,11 +842,6 @@ test('should be able to delete subscription', async t => { }) ); - const subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id }, - }); - - t.is(subInDB, null); t.like( await db.providerSubscription.findUnique({ where: { @@ -851,15 +860,18 @@ test('should be able to delete subscription', async t => { test('should be able to cancel subscription', async t => { const { service, db, u1, stripe } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), + metadata: { preserved: true }, }, }); @@ -881,6 +893,15 @@ test('should be able to cancel subscription', async t => { ); t.is(subInDB.status, SubscriptionStatus.Active); t.truthy(subInDB.canceledAt); + const saved = await db.providerSubscription.findUniqueOrThrow({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: 'sub_1', + }, + }, + }); + t.like(saved.metadata, { preserved: true, nextBillAt: null }); }); test('should reconcile canceled stripe subscriptions and revoke local entitlement', async t => { @@ -897,11 +918,16 @@ test('should reconcile canceled stripe subscriptions and revoke local entitlemen await cron.reconcileStripeSubscriptions(); - const subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id, stripeSubscriptionId: sub.id }, + const subInDB = await db.providerSubscription.findUnique({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: sub.id, + }, + }, }); - t.is(subInDB, null); + t.is(subInDB?.status, SubscriptionStatus.Canceled); t.true( event.emit.calledWith('user.subscription.canceled', { userId: u1.id, @@ -914,15 +940,17 @@ test('should reconcile canceled stripe subscriptions and revoke local entitlemen test('should be able to resume subscription', async t => { const { service, db, u1, stripe } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), canceledAt: new Date(), }, }); @@ -976,15 +1004,17 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = { test('should be able to update recurring', async t => { const { service, db, u1, stripe } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), }, }); @@ -1034,16 +1064,18 @@ test('should be able to update recurring', async t => { test('should release the schedule if the new recurring is the same as the current phase', async t => { const { service, db, u1, stripe } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_1', - stripeScheduleId: 'sub_sched_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), + metadata: { stripeScheduleId: 'sub_sched_1' }, }, }); @@ -1253,14 +1285,16 @@ test('should be able to checkout for lifetime recurring', async t => { test('should not be able to checkout for lifetime recurring if already subscribed', async t => { const { service, u1, db } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: null, + externalSubscriptionId: 'stripe_invoice:existing_lifetime', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Lifetime, status: SubscriptionStatus.Active, - start: new Date(), + periodStart: new Date(), }, }); @@ -1287,8 +1321,13 @@ test('should be able to subscribe to lifetime recurring', async t => { await service.saveStripeInvoice(lifetimeInvoice); - const subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id }, + const subInDB = await db.providerSubscription.findFirst({ + where: { + provider: 'stripe', + targetType: 'user', + targetId: u1.id, + recurring: SubscriptionRecurring.Lifetime, + }, }); t.true( @@ -1301,7 +1340,7 @@ test('should be able to subscribe to lifetime recurring', async t => { t.is(subInDB?.plan, SubscriptionPlan.Pro); t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime); t.is(subInDB?.status, SubscriptionStatus.Active); - t.is(subInDB?.stripeSubscriptionId, null); + t.is(subInDB?.externalSubscriptionId, `stripe_invoice:${lifetimeInvoice.id}`); const paymentFact = await db.paymentEvent.findUnique({ where: { @@ -1319,28 +1358,88 @@ test('should be able to subscribe to lifetime recurring', async t => { currency: lifetimeInvoice.currency, processingStatus: 'processed', }); + + t.context.stripe.invoices.retrieve.resolves( + lifetimeInvoice as Stripe.Response + ); + await service.handleRefundedInvoice(lifetimeInvoice.id, 'refund'); + const entitlement = await db.entitlement.findFirstOrThrow({ + where: { + source: 'cloud_subscription', + subjectId: `stripe_invoice:${lifetimeInvoice.id}`, + }, + }); + t.is(entitlement.status, 'revoked'); }); test('should be able to subscribe to lifetime recurring with old subscription', async t => { - const { service, stripe, db, u1, event } = t.context; + const { app, service, stripe, db, u1, event } = t.context; - await db.subscription.create({ + const previous = await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), }, }); + await app.get(EntitlementService).upsertFromCloudSubscription({ + targetId: u1.id, + plan: SubscriptionPlan.Pro, + recurring: SubscriptionRecurring.Monthly, + status: SubscriptionStatus.Active, + subscriptionId: previous.id, + stripeSubscriptionId: previous.externalSubscriptionId, + end: previous.periodEnd, + }); + event.emit.resetHistory(); + + stripe.subscriptions.cancel.rejects(new Error('remote cancellation failed')); + await t.throwsAsync(() => service.saveStripeInvoice(lifetimeInvoice), { + message: 'remote cancellation failed', + }); + const current = await db.providerSubscription.findUniqueOrThrow({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: 'sub_1', + }, + }, + }); + t.is(current.status, SubscriptionStatus.Active); + t.is( + ( + await db.entitlement.findFirstOrThrow({ + where: { source: 'cloud_subscription', subjectId: 'sub_1' }, + }) + ).status, + 'active' + ); + t.is( + await db.providerSubscription.count({ + where: { + targetId: u1.id, + recurring: SubscriptionRecurring.Lifetime, + }, + }), + 0 + ); stripe.subscriptions.cancel.resolves(sub as any); await service.saveStripeInvoice(lifetimeInvoice); - const subInDB = await db.subscription.findFirst({ - where: { targetId: u1.id }, + const subInDB = await db.providerSubscription.findFirst({ + where: { + provider: 'stripe', + targetType: 'user', + targetId: u1.id, + recurring: SubscriptionRecurring.Lifetime, + }, }); t.true( @@ -1353,20 +1452,36 @@ test('should be able to subscribe to lifetime recurring with old subscription', t.is(subInDB?.plan, SubscriptionPlan.Pro); t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime); t.is(subInDB?.status, SubscriptionStatus.Active); - t.is(subInDB?.stripeSubscriptionId, null); + t.is(subInDB?.externalSubscriptionId, `stripe_invoice:${lifetimeInvoice.id}`); + t.is( + ( + await db.entitlement.findFirstOrThrow({ + where: { source: 'cloud_subscription', subjectId: 'sub_1' }, + }) + ).status, + 'revoked' + ); + t.is(stripe.subscriptions.cancel.callCount, 2); + t.deepEqual( + stripe.subscriptions.cancel.firstCall.lastArg, + stripe.subscriptions.cancel.secondCall.lastArg + ); }); test('should not be able to cancel lifetime subscription', async t => { const { service, db, u1 } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, + externalSubscriptionId: 'stripe_invoice:lifetime_cancel', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Lifetime, status: SubscriptionStatus.Active, - start: new Date(), - end: null, + periodStart: new Date(), + periodEnd: null, }, }); @@ -1383,14 +1498,17 @@ test('should not be able to cancel lifetime subscription', async t => { test('should not be able to update lifetime recurring', async t => { const { service, db, u1 } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'user', targetId: u1.id, + externalSubscriptionId: 'stripe_invoice:lifetime_update', plan: SubscriptionPlan.Pro, recurring: SubscriptionRecurring.Lifetime, status: SubscriptionStatus.Active, - start: new Date(), - end: null, + periodStart: new Date(), + periodEnd: null, }, }); @@ -1444,15 +1562,17 @@ test('should be able to checkout for team', async t => { test('should not be able to checkout for workspace if subscribed', async t => { const { service, u1, db } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'workspace', targetId: 'ws_1', - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Team, recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Active, - start: new Date(), - end: new Date(Date.now() + 100000), + periodStart: new Date(), + periodEnd: new Date(Date.now() + 100000), quantity: 1, }, }); @@ -1478,15 +1598,17 @@ test('should not be able to checkout for workspace if subscribed', async t => { test('should be able to checkout for workspace after canceled subscription', async t => { const { service, u1, db, stripe } = t.context; - await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'workspace', targetId: 'ws_1', - stripeSubscriptionId: 'sub_1', + externalSubscriptionId: 'sub_1', plan: SubscriptionPlan.Team, recurring: SubscriptionRecurring.Monthly, status: SubscriptionStatus.Canceled, - start: new Date(Date.now() - 100000), - end: new Date(Date.now() - 1000), + periodStart: new Date(Date.now() - 100000), + periodEnd: new Date(Date.now() - 1000), quantity: 1, }, }); @@ -1537,8 +1659,8 @@ test('should be able to create team subscription', async t => { await service.saveStripeSubscription(teamSub); - const subInDB = await db.subscription.findFirst({ - where: { targetId: 'ws_1' }, + const subInDB = await db.providerSubscription.findFirst({ + where: { targetType: 'workspace', targetId: 'ws_1' }, }); t.true( @@ -1549,21 +1671,23 @@ test('should be able to create team subscription', async t => { quantity: 1, }) ); - t.is(subInDB?.stripeSubscriptionId, sub.id); + t.is(subInDB?.externalSubscriptionId, sub.id); }); -test('should replace old team subscription row when stripe creates a new subscription', async t => { +test('should preserve old team provider fact when stripe creates a new subscription', async t => { const { service, db } = t.context; - const old = await db.subscription.create({ + await db.providerSubscription.create({ data: { + provider: 'stripe', + targetType: 'workspace', targetId: 'ws_1', - stripeSubscriptionId: 'sub_old_team', + externalSubscriptionId: 'sub_old_team', plan: SubscriptionPlan.Team, recurring: SubscriptionRecurring.Yearly, status: SubscriptionStatus.Canceled, - start: new Date('2026-03-26T08:23:57.000Z'), - end: new Date('2027-03-26T08:23:57.000Z'), + periodStart: new Date('2026-03-26T08:23:57.000Z'), + periodEnd: new Date('2027-03-26T08:23:57.000Z'), quantity: 24, }, }); @@ -1583,15 +1707,22 @@ test('should replace old team subscription row when stripe creates a new subscri }, }); - const subscriptions = await db.subscription.findMany({ - where: { targetId: 'ws_1', plan: SubscriptionPlan.Team }, + const subscriptions = await db.providerSubscription.findMany({ + where: { + provider: 'stripe', + targetType: 'workspace', + targetId: 'ws_1', + plan: SubscriptionPlan.Team, + }, + orderBy: { externalSubscriptionId: 'asc' }, }); - t.is(subscriptions.length, 1); - t.is(subscriptions[0].id, old.id); - t.is(subscriptions[0].stripeSubscriptionId, 'sub_new_team'); + t.is(subscriptions.length, 2); + t.is(subscriptions[0].externalSubscriptionId, 'sub_new_team'); t.is(subscriptions[0].status, SubscriptionStatus.Active); t.is(subscriptions[0].quantity, 11); + t.is(subscriptions[1].externalSubscriptionId, 'sub_old_team'); + t.is(subscriptions[1].status, SubscriptionStatus.Canceled); }); test('should be able to update team subscription', async t => { @@ -1612,8 +1743,8 @@ test('should be able to update team subscription', async t => { }, }); - const subInDB = await db.subscription.findFirst({ - where: { targetId: 'ws_1' }, + const subInDB = await db.providerSubscription.findFirst({ + where: { targetType: 'workspace', targetId: 'ws_1' }, }); t.is(subInDB?.quantity, 2); @@ -1654,9 +1785,6 @@ test('should persist mutable team subscription fields on same stripe subscriptio }, }); - const subInDB = await db.subscription.findFirst({ - where: { targetId: 'ws_1' }, - }); const entitlement = await db.entitlement.findFirst({ where: { source: 'cloud_subscription', @@ -1672,11 +1800,11 @@ test('should persist mutable team subscription fields on same stripe subscriptio }, }); - t.like(subInDB, { + t.like(providerFact, { recurring: SubscriptionRecurring.Yearly, quantity: 9, - start: new Date(1780000000 * 1000), - end: new Date(1811536000 * 1000), + periodStart: new Date(1780000000 * 1000), + periodEnd: new Date(1811536000 * 1000), trialStart: new Date(1780000000 * 1000), trialEnd: new Date(1780604800 * 1000), }); @@ -1748,11 +1876,16 @@ test('should suspend on dispute and restore when dispute won', async t => { await service.handleRefundedInvoice(invoice.id, 'dispute_open'); - const removed = await db.subscription.findFirst({ - where: { stripeSubscriptionId: 'sub_1' }, + const suspended = await db.providerSubscription.findUnique({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: 'sub_1', + }, + }, }); - t.is(removed, null); + t.is(suspended?.status, SubscriptionStatus.Canceled); t.true( event.emit.calledWith('user.subscription.canceled', { userId: t.context.u1.id, @@ -1766,8 +1899,13 @@ test('should suspend on dispute and restore when dispute won', async t => { await service.handleRefundedInvoice(invoice.id, 'dispute_won'); - const restored = await db.subscription.findFirst({ - where: { stripeSubscriptionId: 'sub_1' }, + const restored = await db.providerSubscription.findUnique({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: 'sub_1', + }, + }, }); t.truthy(restored); @@ -1781,6 +1919,99 @@ test('should suspend on dispute and restore when dispute won', async t => { ); }); +test('should keep selfhost provider identity and license source on replay and cancellation', async t => { + const { app, db, service } = t.context; + const selfhostSubscription = { + ...sub, + id: 'sub_selfhost', + schedule: 'schedule_selfhost', + items: { + ...sub.items, + data: [ + { + ...sub.items.data[0], + quantity: 5, + subscription: 'sub_selfhost', + price: { + id: 'price_selfhost', + lookup_key: `${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Yearly}`, + product: 'product_selfhost', + currency: 'usd', + unit_amount: 12000, + }, + }, + ], + }, + } as Stripe.Subscription; + + await service.saveStripeSubscription(selfhostSubscription); + const first = await db.providerSubscription.findUniqueOrThrow({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: selfhostSubscription.id, + }, + }, + }); + await db.providerSubscription.create({ + data: { + provider: 'revenuecat', + targetType: 'instance', + targetId: first.targetId, + plan: SubscriptionPlan.SelfHostedTeam, + recurring: SubscriptionRecurring.Yearly, + status: SubscriptionStatus.Active, + iapStore: 'app_store', + externalCustomerId: 'selfhost-customer', + externalProductId: 'selfhost-product', + externalRef: 'selfhost-ref', + }, + }); + const selected = await app + .get(SelfhostTeamSubscriptionManager) + .getSubscription({ + key: first.targetId, + plan: SubscriptionPlan.SelfHostedTeam, + }); + t.is(selected?.provider, 'stripe'); + + await service.saveStripeSubscription({ + ...selfhostSubscription, + items: { + ...selfhostSubscription.items, + data: [{ ...selfhostSubscription.items.data[0], quantity: 7 }], + }, + }); + + const replayed = await db.providerSubscription.findUniqueOrThrow({ + where: { + provider_externalSubscriptionId: { + provider: 'stripe', + externalSubscriptionId: selfhostSubscription.id, + }, + }, + }); + t.is(replayed.targetId, first.targetId); + t.is(replayed.quantity, 7); + t.is(await db.license.count({ where: { key: first.targetId } }), 1); + t.is(app.mails.count('TeamLicense'), 1); + + await service.deleteStripeSubscription({ + ...selfhostSubscription, + status: SubscriptionStatus.Canceled, + }); + + t.is( + ( + await db.providerSubscription.findUniqueOrThrow({ + where: { id: first.id }, + }) + ).status, + SubscriptionStatus.Canceled + ); + t.is(await db.license.count({ where: { key: first.targetId } }), 1); +}); + // NOTE(@forehalo): cancel and resume a team subscription share the same logic with user subscription test.skip('should be able to cancel team subscription', async () => {}); test.skip('should be able to resume team subscription', async () => {}); diff --git a/packages/backend/server/src/__tests__/utils/feature.ts b/packages/backend/server/src/__tests__/utils/feature.ts index 1eed73a03d..b4b898f1ff 100644 --- a/packages/backend/server/src/__tests__/utils/feature.ts +++ b/packages/backend/server/src/__tests__/utils/feature.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; -import { WorkspaceRole } from '../../core/permission'; import { UserType } from '../../core/user/types'; @Injectable() @@ -11,12 +10,12 @@ export class WorkspaceResolverMock { async createWorkspace(user: UserType, _init: null) { const workspace = await this.prisma.workspace.create({ data: { - public: false, - permissions: { + accessPolicy: { create: {} }, + members: { create: { - type: WorkspaceRole.Owner, + role: 'owner', + state: 'active', userId: user.id, - status: WorkspaceMemberStatus.Accepted, }, }, }, diff --git a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts index 6722b98240..f12c7064ec 100644 --- a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts @@ -8,7 +8,7 @@ import { ConfigFactory } from '../../base'; import { QuotaStateService } from '../../core/quota/state'; import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob'; import { StorageRuntimeProvider } from '../../core/storage-runtime'; -import { BlobModel, WorkspaceFeatureModel } from '../../models'; +import { BlobModel } from '../../models'; import { getMime } from '../../native'; import { collectAllBlobSizes, @@ -34,7 +34,6 @@ const RESTRICTED_QUOTA = { }; let app: TestingApp; -let model: WorkspaceFeatureModel; type CompleteResult = | { ok: true; @@ -146,7 +145,6 @@ test.before(async () => { builder.overrideProvider(StorageRuntimeProvider).useValue(storageRuntime); }, }); - model = app.get(WorkspaceFeatureModel); app.get(ConfigFactory).override({ storages: { blob: { @@ -447,18 +445,6 @@ test('should reject blob exceeded storage quota', async t => { }); }); -test('should accept blob even storage out of quota if workspace has unlimited feature', async t => { - await app.signupV1('u1@affine.pro'); - - const workspace = await createWorkspace(app); - await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); - await model.add(workspace.id, 'unlimited_workspace', 'test'); - - const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0)); - await t.notThrowsAsync(setBlob(app, workspace.id, buffer)); - await t.notThrowsAsync(setBlob(app, workspace.id, buffer)); -}); - test('should throw error when blob size large than max file size', async t => { await app.signup(); diff --git a/packages/backend/server/src/__tests__/workspace/controller.spec.ts b/packages/backend/server/src/__tests__/workspace/controller.spec.ts index 156d3aff54..7e687348d6 100644 --- a/packages/backend/server/src/__tests__/workspace/controller.spec.ts +++ b/packages/backend/server/src/__tests__/workspace/controller.spec.ts @@ -7,9 +7,7 @@ import Sinon from 'sinon'; import supertest from 'supertest'; import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs'; -import { ConfigFactory } from '../../base'; import { PgWorkspaceDocStorageAdapter } from '../../core/doc'; -import { PermissionReadModel } from '../../core/permission/config'; import { WorkspaceBlobStorage } from '../../core/storage'; import { Models, PublicDocMode, WorkspaceRole } from '../../models'; import { @@ -52,11 +50,10 @@ test.before(async t => { workspace: { create: { id: 'public', - public: true, + accessPolicy: { create: { visibility: 'public' } }, }, }, docId: 'private', - public: false, }, }); @@ -65,11 +62,10 @@ test.before(async t => { workspace: { create: { id: 'private', - public: false, + accessPolicy: { create: {} }, }, }, docId: 'public', - public: true, }, }); @@ -78,13 +74,28 @@ test.before(async t => { workspace: { create: { id: 'totally-private', - public: false, + accessPolicy: { create: {} }, }, }, docId: 'private', - public: false, }, }); + await db.docAccessPolicy.createMany({ + data: [ + { workspaceId: 'public', docId: 'private', visibility: 'private' }, + { + workspaceId: 'private', + docId: 'public', + visibility: 'public', + publicRole: 'external', + }, + { + workspaceId: 'totally-private', + docId: 'private', + visibility: 'private', + }, + ], + }); }); test.after.always(async t => { @@ -154,31 +165,6 @@ test('should be able to get private workspace with public pages', async t => { t.is(res.text, 'blob'); }); -test('should be able to get private workspace with public pages using new permission model', async t => { - const { app, storage } = t.context; - const config = app.get(ConfigFactory); - - config.override({ - permission: { - readModel: PermissionReadModel.Projection, - }, - }); - try { - storage.get.resolves(blob()); - const res = await app.GET('/api/workspaces/private/blobs/test'); - - t.is(res.status, HttpStatus.OK); - t.is(res.get('content-type'), 'text/plain'); - t.is(res.text, 'blob'); - } finally { - config.override({ - permission: { - readModel: PermissionReadModel.Legacy, - }, - }); - } -}); - test('should not be able to get private workspace with no public pages', async t => { const { app } = t.context; diff --git a/packages/backend/server/src/core/auth/dev.ts b/packages/backend/server/src/core/auth/dev.ts index 8751ed4978..8d8c2aca65 100644 --- a/packages/backend/server/src/core/auth/dev.ts +++ b/packages/backend/server/src/core/auth/dev.ts @@ -1,31 +1,39 @@ -import { Models, UserFeatureName, WorkspaceFeatureName } from '../../models'; +import { Models } from '../../models'; +import { + SubscriptionRecurring, + SubscriptionStatus, +} from '../../plugins/payment/types'; +import { EntitlementService } from '../entitlement'; -export async function createDevUsers(models: Models) { +export async function createDevUsers( + models: Models, + entitlement: EntitlementService +) { const devUsers: { email: string; name: string; password: string; - features: UserFeatureName[]; - workspaceFeatures?: WorkspaceFeatureName[]; + plans: Array<'pro' | 'ai'>; + teamWorkspace?: boolean; }[] = [ { email: 'dev@affine.pro', name: 'Dev User', password: 'dev', - features: ['free_plan_v1', 'unlimited_copilot', 'administrator'], + plans: ['ai'], }, { email: 'pro@affine.pro', name: 'Pro User', password: 'pro', - features: ['pro_plan_v1', 'unlimited_copilot', 'administrator'], + plans: ['pro', 'ai'], }, { email: 'team@affine.pro', name: 'Team User', password: 'team', - features: ['pro_plan_v1', 'unlimited_copilot', 'administrator'], - workspaceFeatures: ['team_plan_v1'], + plans: ['pro', 'ai'], + teamWorkspace: true, }, ]; const devWorkspaceBlob = Buffer.from( @@ -33,13 +41,7 @@ export async function createDevUsers(models: Models) { 'base64' ); - for (const { - email, - name, - password, - features, - workspaceFeatures, - } of devUsers) { + for (const { email, name, password, plans, teamWorkspace } of devUsers) { try { let devUser = await models.user.getUserByEmail(email); if (!devUser) { @@ -49,40 +51,47 @@ export async function createDevUsers(models: Models) { password, }); } - for (const feature of features) { - if (feature.includes('plan')) { - await models.userFeature.switchQuota(devUser.id, feature, name); - } else { - await models.userFeature.add(devUser.id, feature, name); - } + await models.userFeature.add(devUser.id, 'administrator', name); + for (const plan of plans) { + await entitlement.upsertFromCloudSubscription({ + targetId: devUser.id, + plan, + recurring: SubscriptionRecurring.Monthly, + status: SubscriptionStatus.Active, + provider: 'dev', + subscriptionId: `dev:${devUser.id}:${plan}`, + }); } - if (workspaceFeatures) { - for (const feature of workspaceFeatures) { - const workspaceIds = ( - await models.workspaceUser.getUserActiveRoles(devUser.id) - ).map(row => row.workspaceId); - const workspaces = await models.workspace.findMany(workspaceIds); - let hasFeatureWorkspace = false; - for (const workspace of workspaces) { - if (await models.workspaceFeature.has(workspace.id, feature)) { - hasFeatureWorkspace = true; - break; - } - } - if (!hasFeatureWorkspace) { - // create a new workspace with the feature - const workspace = await models.workspace.create(devUser.id); - await models.doc.upsert({ - spaceId: workspace.id, - docId: workspace.id, - blob: devWorkspaceBlob, - timestamp: Date.now(), - editorId: devUser.id, - }); - await models.workspaceFeature.add(workspace.id, feature, name, { - memberLimit: 10, - }); - } + if (teamWorkspace) { + const workspaceIds = ( + await models.workspaceUser.getUserActiveRoles(devUser.id) + ).map(row => row.workspaceId); + const workspaces = await models.workspace.findMany(workspaceIds); + const hasTeamWorkspace = ( + await Promise.all( + workspaces.map(workspace => + entitlement.resolveWorkspaceEntitlement(workspace.id) + ) + ) + ).some(resolved => resolved.plan === 'team'); + if (!hasTeamWorkspace) { + const workspace = await models.workspace.create(devUser.id); + await models.doc.upsert({ + spaceId: workspace.id, + docId: workspace.id, + blob: devWorkspaceBlob, + timestamp: Date.now(), + editorId: devUser.id, + }); + await entitlement.upsertFromCloudSubscription({ + targetId: workspace.id, + plan: 'team', + recurring: SubscriptionRecurring.Monthly, + status: SubscriptionStatus.Active, + quantity: 10, + provider: 'dev', + subscriptionId: `dev:${workspace.id}:team`, + }); } } } catch { diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index bd210b6576..f367229ff7 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -3,6 +3,7 @@ import './config'; import { Module } from '@nestjs/common'; import { BackendRuntimeModule } from '../backend-runtime'; +import { EntitlementModule } from '../entitlement'; import { FeatureModule } from '../features'; import { MailModule } from '../mail'; import { QuotaModule } from '../quota'; @@ -27,6 +28,7 @@ import { AuthSigningKeyResolver } from './signing-key-resolver'; imports: [ BackendRuntimeModule, FeatureModule, + EntitlementModule, UserModule, QuotaModule, MailModule, diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index ecad47cef1..6fe5430a78 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -7,6 +7,7 @@ import { assign, pick } from 'lodash-es'; import { Config, OnEvent, SignUpForbidden } from '../../base'; import { Models, type User, type UserSession } from '../../models'; +import { EntitlementService } from '../entitlement'; import { Mailer } from '../mail/mailer'; import type { MailDeliveryMetadata } from '../mail/types'; import { AuthSessionService } from './auth-session'; @@ -44,7 +45,8 @@ export class AuthService implements OnApplicationBootstrap { private readonly config: Config, private readonly models: Models, private readonly mailer: Mailer, - private readonly authSessions: AuthSessionService + private readonly authSessions: AuthSessionService, + private readonly entitlement: EntitlementService ) { this.cookieOptions = { sameSite: 'lax', @@ -63,7 +65,7 @@ export class AuthService implements OnApplicationBootstrap { async onApplicationBootstrap() { if (env.dev) { - await createDevUsers(this.models); + await createDevUsers(this.models, this.entitlement); } } diff --git a/packages/backend/server/src/core/config/resolver.ts b/packages/backend/server/src/core/config/resolver.ts index d14e559da5..76cc9b8f52 100644 --- a/packages/backend/server/src/core/config/resolver.ts +++ b/packages/backend/server/src/core/config/resolver.ts @@ -14,7 +14,7 @@ import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; import { Config, hasNewerVersion, URLHelper } from '../../base'; import { Namespace } from '../../env'; -import { Feature, type WorkspaceFeatureName } from '../../models'; +import { Feature } from '../../models'; import { CurrentUser, Public } from '../auth'; import { Admin } from '../common'; import { AvailableUserFeatureConfig } from '../features'; @@ -168,13 +168,6 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig { override availableUserFeatures() { return super.availableUserFeatures(); } - - @ResolveField(() => [Feature], { - description: 'Workspace features available for admin configuration', - }) - availableWorkspaceFeatures(): WorkspaceFeatureName[] { - return []; - } } @InputType() diff --git a/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts b/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts deleted file mode 100644 index 96cf477bcb..0000000000 --- a/packages/backend/server/src/core/entitlement/__tests__/projection-checker.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { PrismaClient } from '@prisma/client'; -import ava, { TestFn } from 'ava'; - -import { - createTestingModule, - type TestingModule, -} from '../../../__tests__/utils'; -import { Models } from '../../../models'; -import { - SubscriptionPlan, - SubscriptionRecurring, - SubscriptionStatus, -} from '../../../plugins/payment/types'; -import { - EntitlementModule, - EntitlementProjectionChecker, - EntitlementService, -} from '../index'; - -interface Context { - module: TestingModule; - db: PrismaClient; - models: Models; - entitlement: EntitlementService; - checker: EntitlementProjectionChecker; -} - -const test = ava as TestFn; - -test.before(async t => { - const module = await createTestingModule({ imports: [EntitlementModule] }); - t.context.module = module; - t.context.db = module.get(PrismaClient); - t.context.models = module.get(Models); - t.context.entitlement = module.get(EntitlementService); - t.context.checker = module.get(EntitlementProjectionChecker); -}); - -test.beforeEach(async t => { - await t.context.module.initTestingDB(); -}); - -test.after.always(async t => { - await t.context.module.close(); -}); - -test('checker distinguishes valid projection from dirty legacy features', async t => { - const cleanUser = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.entitlement.upsertFromCloudSubscription({ - targetId: cleanUser.id, - plan: 'pro', - recurring: SubscriptionRecurring.Monthly, - status: 'active', - }); - - const dirtyUser = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.models.userFeature.add( - dirtyUser.id, - 'pro_plan_v1', - 'dirty legacy feature' - ); - - const report = await t.context.checker.checkEntitlementProjection(); - - t.is(report.dirtyLegacyUserFeatures, 1); - t.is(report.missingUserFeatureProjection, 0); -}); - -test('checker reports missing legacy projection and stale state', async t => { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.entitlement.upsertFromCloudSubscription( - { - targetId: user.id, - plan: 'pro', - recurring: SubscriptionRecurring.Monthly, - status: 'active', - }, - { emit: false } - ); - await t.context.db.effectiveUserQuotaState.update({ - where: { userId: user.id }, - data: { - staleAfter: new Date('2020-01-01T00:00:00Z'), - }, - }); - - const report = await t.context.checker.checkEntitlementProjection(); - - t.is(report.cloudSubscriptionProjectionMissing, 1); - t.is(report.staleEffectiveUserState, 1); -}); - -test('checker reports legal legacy facts missing entitlements', async t => { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.db.subscription.create({ - data: { - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Monthly, - status: SubscriptionStatus.Active, - start: new Date(), - }, - }); - - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - await t.context.db.installedLicense.create({ - data: { - key: 'legacy-verifiable-key', - workspaceId: workspace.id, - quantity: 5, - recurring: SubscriptionRecurring.Yearly, - validateKey: 'validate-key', - validatedAt: new Date(), - license: Buffer.from('raw-license'), - }, - }); - - const report = await t.context.checker.checkEntitlementProjection(); - - t.is(report.cloudSubscriptionEntitlementMissing, 1); - t.is(report.selfhostLicenseEntitlementMissing, 1); -}); - -test('checker reports provider facts missing entitlements', async t => { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.db.providerSubscription.create({ - data: { - provider: 'stripe', - targetType: 'user', - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - externalSubscriptionId: 'sub_provider_without_entitlement', - periodStart: new Date(), - periodEnd: new Date('2099-01-01T00:00:00.000Z'), - }, - }); - - const report = await t.context.checker.checkEntitlementProjection(); - - t.is(report.providerActiveEntitlementMissing, 1); -}); - -test('checker reports entitlements missing active provider facts', async t => { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.entitlement.upsertFromCloudSubscription({ - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - stripeSubscriptionId: 'sub_entitlement_without_active_provider', - }); - - const report = await t.context.checker.checkEntitlementProjection(); - - t.is(report.entitlementProviderMissing, 1); -}); diff --git a/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts b/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts deleted file mode 100644 index fc05490aa6..0000000000 --- a/packages/backend/server/src/core/entitlement/__tests__/projection.spec.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import { PrismaClient } from '@prisma/client'; -import ava, { TestFn } from 'ava'; - -import { - createTestingModule, - type TestingModule, -} from '../../../__tests__/utils'; -import { Models } from '../../../models'; -import { - SubscriptionPlan, - SubscriptionRecurring, - SubscriptionStatus, -} from '../../../plugins/payment/types'; -import { EntitlementModule, EntitlementService } from '../index'; -import { LegacyEntitlementProjectionService } from '../projection'; - -interface Context { - module: TestingModule; - db: PrismaClient; - models: Models; - entitlement: EntitlementService; - projection: LegacyEntitlementProjectionService; -} - -const test = ava as TestFn; - -test.before(async t => { - const module = await createTestingModule({ imports: [EntitlementModule] }); - t.context.module = module; - t.context.db = module.get(PrismaClient); - t.context.models = module.get(Models); - t.context.entitlement = module.get(EntitlementService); - t.context.projection = module.get(LegacyEntitlementProjectionService); -}); - -test.beforeEach(async t => { - await t.context.module.initTestingDB(); -}); - -test.after.always(async t => { - await t.context.module.close(); -}); - -test('projects user entitlement to legacy user features and subscriptions', async t => { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - - await t.context.entitlement.upsertFromCloudSubscription( - { - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: 'active', - }, - { emit: false } - ); - await t.context.entitlement.upsertFromCloudSubscription( - { - targetId: user.id, - plan: SubscriptionPlan.AI, - recurring: SubscriptionRecurring.Monthly, - status: 'active', - }, - { emit: false } - ); - await t.context.projection.onEntitlementChanged({ - targetType: 'user', - targetId: user.id, - }); - - t.true(await t.context.models.userFeature.has(user.id, 'pro_plan_v1')); - t.true(await t.context.models.userFeature.has(user.id, 'unlimited_copilot')); - t.like( - await t.context.db.subscription.findUnique({ - where: { - targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro }, - }, - }), - { - recurring: SubscriptionRecurring.Yearly, - status: 'active', - } - ); - - await t.context.entitlement.revokeCloudSubscription({ - targetId: user.id, - plan: SubscriptionPlan.AI, - }); - t.false(await t.context.models.userFeature.has(user.id, 'unlimited_copilot')); -}); - -test('projects workspace entitlement and readonly state to legacy workspace features', async t => { - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - - await t.context.entitlement.upsertFromCloudSubscription({ - targetId: workspace.id, - plan: SubscriptionPlan.Team, - recurring: SubscriptionRecurring.Yearly, - status: 'active', - quantity: 8, - }); - await t.context.projection.onEntitlementChanged({ - targetType: 'workspace', - targetId: workspace.id, - }); - - const teamFeature = await t.context.models.workspaceFeature.get( - workspace.id, - 'team_plan_v1' - ); - t.is(teamFeature?.configs.memberLimit, 8); - - await t.context.db.effectiveWorkspaceQuotaState.upsert({ - where: { - workspaceId: workspace.id, - }, - create: { - workspaceId: workspace.id, - plan: 'free', - ownerUserId: owner.id, - usesOwnerQuota: true, - seatLimit: 3, - memberCount: 4, - overcapacityMemberCount: 1, - blobLimit: BigInt(10), - storageQuota: BigInt(10), - usedStorageQuota: BigInt(1), - historyPeriodSeconds: 7, - readonly: true, - readonlyReasons: ['member_overflow'], - known: true, - stale: false, - }, - update: { - plan: 'free', - ownerUserId: owner.id, - usesOwnerQuota: true, - seatLimit: 3, - memberCount: 4, - overcapacityMemberCount: 1, - blobLimit: BigInt(10), - storageQuota: BigInt(10), - usedStorageQuota: BigInt(1), - historyPeriodSeconds: 7, - readonly: true, - readonlyReasons: ['member_overflow'], - known: true, - stale: false, - }, - }); - await t.context.projection.onWorkspaceQuotaStateChanged({ - workspaceId: workspace.id, - }); - - t.true( - await t.context.models.workspaceFeature.has( - workspace.id, - 'quota_exceeded_readonly_workspace_v1' - ) - ); -}); - -test('installed license scanner never trusts quantity without raw license', async t => { - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - - await t.context.db.installedLicense.create({ - data: { - key: 'legacy-key', - workspaceId: workspace.id, - quantity: 100, - recurring: SubscriptionRecurring.Yearly, - validateKey: '', - validatedAt: new Date(), - }, - }); - - await t.context.projection.scanInstalledLicenses(); - - const entitlement = await t.context.db.entitlement.findFirst({ - where: { - source: 'selfhost_license', - subjectId: 'legacy-key', - }, - }); - t.is(entitlement?.status, 'needs_reupload'); - t.is(entitlement?.quantity, null); -}); - -test.serial( - 'selfhosted legacy projection ignores unknown entitlements', - async t => { - const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE; - // @ts-expect-error test mutates env singleton for deployment-specific projection semantics - globalThis.env.DEPLOYMENT_TYPE = 'selfhosted'; - try { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - await t.context.db.entitlement.create({ - data: { - targetType: 'user', - targetId: user.id, - source: 'cloud_subscription', - plan: 'ai', - status: 'active', - subjectId: `forged-ai:${user.id}`, - }, - }); - - await t.context.projection.onEntitlementChanged({ - targetType: 'user', - targetId: user.id, - }); - - t.false( - await t.context.models.userFeature.has(user.id, 'unlimited_copilot') - ); - t.is( - await t.context.db.subscription.count({ where: { targetId: user.id } }), - 0 - ); - } finally { - // @ts-expect-error restore mutable test env singleton - globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType; - } - } -); - -test('backfill marks selfhost team subscriptions as needing license revalidation', async t => { - await t.context.db.subscription.create({ - data: { - targetId: 'license-key-target', - plan: SubscriptionPlan.SelfHostedTeam, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: new Date(), - }, - }); - - await t.context.projection.backfillEntitlementsAndQuotaStates(); - - t.like( - await t.context.db.entitlement.findFirstOrThrow({ - where: { - source: 'selfhost_license', - subjectId: 'license-key-target', - }, - }), - { - targetType: 'instance', - targetId: 'license-key-target', - plan: 'selfhost_team', - status: 'needs_reupload', - } - ); -}); - -test('backfill removes dangling legacy subscriptions and entitlements', async t => { - await t.context.db.subscription.createMany({ - data: [ - { - targetId: randomUUID(), - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: new Date(), - }, - { - targetId: randomUUID(), - plan: SubscriptionPlan.Team, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: new Date(), - }, - ], - }); - await t.context.db.entitlement.createMany({ - data: [ - { - targetType: 'user', - targetId: randomUUID(), - source: 'cloud_subscription', - plan: 'pro', - status: 'active', - subjectId: randomUUID(), - }, - { - targetType: 'workspace', - targetId: randomUUID(), - source: 'cloud_subscription', - plan: 'team', - status: 'active', - subjectId: randomUUID(), - }, - ], - }); - - await t.context.projection.backfillEntitlementsAndQuotaStates(); - - t.is(await t.context.db.subscription.count(), 0); - t.is(await t.context.db.entitlement.count(), 0); -}); - -test('shadow backfill preserves legacy rows and records provider facts', async t => { - const user = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const paidAiUser = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - const danglingTargetId = randomUUID(); - - await t.context.db.subscription.createMany({ - data: [ - { - targetId: user.id, - stripeSubscriptionId: 'sub_ai_trial', - plan: SubscriptionPlan.AI, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: new Date('2026-01-01T00:00:00.000Z'), - trialStart: new Date('2026-01-01T00:00:00.000Z'), - trialEnd: new Date('2026-01-08T00:00:00.000Z'), - }, - { - targetId: paidAiUser.id, - stripeSubscriptionId: 'sub_ai_paid', - plan: SubscriptionPlan.AI, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: new Date('2026-01-01T00:00:00.000Z'), - }, - { - targetId: danglingTargetId, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: new Date('2026-01-01T00:00:00.000Z'), - }, - ], - }); - await t.context.db.invoice.create({ - data: { - stripeInvoiceId: 'in_backfill_lifetime', - targetId: user.id, - currency: 'usd', - amount: 9999, - status: 'paid', - reason: 'subscription_create', - }, - }); - await t.context.db.installedLicense.create({ - data: { - key: 'shadow-license-key', - workspaceId: workspace.id, - quantity: 3, - recurring: SubscriptionRecurring.Yearly, - validateKey: 'shadow-validate-key', - validatedAt: new Date(), - }, - }); - - await t.context.projection.shadowBackfillEntitlementsAndQuotaStates(); - - t.truthy( - await t.context.db.subscription.findFirst({ - where: { targetId: danglingTargetId }, - }) - ); - t.like( - await t.context.db.providerSubscription.findUnique({ - where: { - provider_externalSubscriptionId: { - provider: 'stripe', - externalSubscriptionId: 'sub_ai_trial', - }, - }, - }), - { - targetType: 'user', - targetId: user.id, - plan: SubscriptionPlan.AI, - status: SubscriptionStatus.Active, - } - ); - t.truthy( - await t.context.db.subscriptionTrialUsage.findUnique({ - where: { - targetType_targetId_plan: { - targetType: 'user', - targetId: user.id, - plan: SubscriptionPlan.AI, - }, - }, - }) - ); - t.falsy( - await t.context.db.subscriptionTrialUsage.findUnique({ - where: { - targetType_targetId_plan: { - targetType: 'user', - targetId: paidAiUser.id, - plan: SubscriptionPlan.AI, - }, - }, - }) - ); - t.like( - await t.context.db.paymentEvent.findUnique({ - where: { - provider_externalEventId: { - provider: 'stripe', - externalEventId: 'stripe_invoice:in_backfill_lifetime', - }, - }, - }), - { - targetId: user.id, - externalInvoiceId: 'in_backfill_lifetime', - amount: 9999, - processingStatus: 'processed', - } - ); - t.false( - await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1') - ); -}); - -test('key based selfhost entitlements without raw payload need reupload', async t => { - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - - await t.context.entitlement.upsertFromSelfhostLicense({ - workspaceId: workspace.id, - licenseKey: 'remote-key', - recurring: SubscriptionRecurring.Yearly, - quantity: 5, - validateKey: 'validate-key', - expiresAt: new Date(Date.now() + 3600_000), - }); - - await t.context.projection.scanInstalledLicenses(); - - t.like( - await t.context.db.entitlement.findFirstOrThrow({ - where: { source: 'selfhost_license', subjectId: 'remote-key' }, - }), - { status: 'needs_reupload', quantity: null } - ); -}); - -test('revoked selfhost entitlement removes installed license projection', async t => { - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - - await t.context.db.entitlement.create({ - data: { - targetType: 'workspace', - targetId: workspace.id, - source: 'selfhost_license', - plan: 'selfhost_team', - status: 'active', - subjectId: 'revoked-key', - quantity: 5, - signedPayload: Buffer.from('signed-license-payload'), - metadata: { - recurring: SubscriptionRecurring.Yearly, - validateKey: 'validate-key', - }, - expiresAt: new Date(Date.now() + 3600_000), - validatedAt: new Date(), - }, - }); - await t.context.db.installedLicense.create({ - data: { - key: 'revoked-key', - workspaceId: workspace.id, - quantity: 5, - recurring: SubscriptionRecurring.Yearly, - validateKey: 'validate-key', - validatedAt: new Date(), - license: Buffer.from('signed-license-payload'), - }, - }); - - await t.context.entitlement.revokeBySubject( - 'selfhost_license', - 'revoked-key' - ); - - t.falsy( - await t.context.db.installedLicense.findUnique({ - where: { workspaceId: workspace.id }, - }) - ); -}); - -test('installed license projection uses explicit entitlement status priority', async t => { - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - - await t.context.db.entitlement.createMany({ - data: [ - { - targetType: 'workspace', - targetId: workspace.id, - source: 'selfhost_license', - plan: 'selfhost_team', - status: 'expired', - subjectId: 'expired-key', - quantity: 5, - metadata: { - recurring: SubscriptionRecurring.Yearly, - validateKey: 'expired-validate-key', - }, - expiresAt: new Date(Date.now() - 3600_000), - validatedAt: new Date(), - }, - { - targetType: 'workspace', - targetId: workspace.id, - source: 'selfhost_license', - plan: 'selfhost_team', - status: 'grace', - subjectId: 'grace-key', - quantity: 6, - metadata: { - recurring: SubscriptionRecurring.Yearly, - validateKey: 'grace-validate-key', - }, - expiresAt: new Date(Date.now() - 1800_000), - graceUntil: new Date(Date.now() + 3600_000), - validatedAt: new Date(), - }, - ], - }); - - await t.context.projection.onEntitlementChanged({ - targetType: 'workspace', - targetId: workspace.id, - }); - - const installedLicense = - await t.context.db.installedLicense.findUniqueOrThrow({ - where: { workspaceId: workspace.id }, - }); - t.is(installedLicense.key, 'grace-key'); - t.is(installedLicense.quantity, 6); - t.is(installedLicense.validateKey, 'grace-validate-key'); -}); - -test.serial( - 'selfhosted projection does not trust non-null signed payload', - async t => { - const previousDeploymentType = globalThis.env.DEPLOYMENT_TYPE; - // @ts-expect-error test mutates env singleton for deployment-specific projection semantics - globalThis.env.DEPLOYMENT_TYPE = 'selfhosted'; - try { - const owner = await t.context.models.user.create({ - email: `${randomUUID()}@affine.pro`, - }); - const workspace = await t.context.models.workspace.create(owner.id); - - await t.context.db.entitlement.create({ - data: { - targetType: 'workspace', - targetId: workspace.id, - source: 'selfhost_license', - plan: 'selfhost_team', - status: 'active', - subjectId: 'forged-key', - quantity: 100, - signedPayload: Buffer.from('not-a-valid-license'), - metadata: { - recurring: SubscriptionRecurring.Yearly, - validateKey: 'validate-key', - }, - expiresAt: new Date(Date.now() + 3600_000), - validatedAt: new Date(), - }, - }); - - await t.context.projection.onEntitlementChanged({ - targetType: 'workspace', - targetId: workspace.id, - }); - - t.falsy( - await t.context.models.workspaceFeature.get( - workspace.id, - 'team_plan_v1' - ) - ); - t.falsy( - await t.context.db.installedLicense.findUnique({ - where: { workspaceId: workspace.id }, - }) - ); - } finally { - // @ts-expect-error restore mutable test env singleton - globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType; - } - } -); diff --git a/packages/backend/server/src/core/entitlement/__tests__/service.spec.ts b/packages/backend/server/src/core/entitlement/__tests__/service.spec.ts index 8214719df1..a00d69612d 100644 --- a/packages/backend/server/src/core/entitlement/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/entitlement/__tests__/service.spec.ts @@ -9,7 +9,6 @@ import { Models } from '../../../models'; import { SubscriptionPlan, SubscriptionRecurring, - SubscriptionStatus, } from '../../../plugins/payment/types'; import { EntitlementModule } from '../index'; import { EntitlementService } from '../service'; @@ -375,134 +374,3 @@ test('selfhosted resolution ignores unsigned DB entitlements', async t => { globalThis.env.DEPLOYMENT_TYPE = previousDeploymentType; } }); - -test('cloud resolution lazily imports legacy subscriptions written after backfill', async t => { - const user = await t.context.models.user.create({ - email: 'legacy-subscription-user@affine.pro', - }); - await t.context.db.subscription.create({ - data: { - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - quantity: 1, - start: new Date(), - }, - }); - - const userResolved = await t.context.service.resolveUserEntitlement(user.id); - const userEntitlement = await t.context.db.entitlement.findFirst({ - where: { - targetType: 'user', - targetId: user.id, - source: 'cloud_subscription', - plan: 'pro', - }, - }); - - t.is(userResolved.plan, 'pro'); - t.is(userEntitlement?.status, 'active'); - - const owner = await t.context.models.user.create({ - email: 'legacy-subscription-owner@affine.pro', - }); - const workspace = await t.context.models.workspace.create(owner.id); - await t.context.db.subscription.create({ - data: { - targetId: workspace.id, - plan: SubscriptionPlan.Team, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - quantity: 7, - start: new Date(), - }, - }); - - const workspaceResolved = await t.context.service.resolveWorkspaceEntitlement( - workspace.id - ); - - t.is(workspaceResolved.plan, 'team'); - t.is(workspaceResolved.quantity, 7); - t.is(workspaceResolved.quota.seatLimit, 7); - - await t.context.db.subscription.delete({ - where: { - targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro }, - }, - }); - - const revokedResolved = await t.context.service.resolveUserEntitlement( - user.id - ); - const revokedEntitlement = await t.context.db.entitlement.findFirst({ - where: { - targetType: 'user', - targetId: user.id, - source: 'cloud_subscription', - plan: 'pro', - }, - }); - - t.is(revokedResolved.plan, 'free'); - t.is(revokedEntitlement?.status, 'revoked'); -}); - -test('cloud resolution revokes projected entitlements after legacy subscription deletion', async t => { - const user = await t.context.models.user.create({ - email: 'legacy-delete-user@affine.pro', - }); - const entitlement = await t.context.service.upsertFromCloudSubscription({ - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - }); - - await t.context.db.subscription.findUniqueOrThrow({ - where: { - targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro }, - }, - }); - await t.context.db.subscription.delete({ - where: { - targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro }, - }, - }); - - const resolved = await t.context.service.resolveUserEntitlement(user.id); - const updated = await t.context.db.entitlement.findUnique({ - where: { id: entitlement.id }, - }); - - t.is(resolved.plan, 'free'); - t.is(updated?.status, 'revoked'); -}); - -test('cloud resolution keeps projected string-subscription entitlements while legacy row exists', async t => { - const user = await t.context.models.user.create({ - email: 'string-subscription-user@affine.pro', - }); - const entitlement = await t.context.service.upsertFromCloudSubscription({ - targetId: user.id, - plan: SubscriptionPlan.Pro, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - subscriptionId: 'sub_legacy_string', - }); - - await t.context.db.subscription.findUniqueOrThrow({ - where: { - targetId_plan: { targetId: user.id, plan: SubscriptionPlan.Pro }, - }, - }); - - const resolved = await t.context.service.resolveUserEntitlement(user.id); - const updated = await t.context.db.entitlement.findUnique({ - where: { id: entitlement.id }, - }); - - t.is(resolved.plan, 'pro'); - t.is(updated?.status, 'active'); -}); diff --git a/packages/backend/server/src/core/entitlement/index.ts b/packages/backend/server/src/core/entitlement/index.ts index 6e8df528ac..3a2f1b778d 100644 --- a/packages/backend/server/src/core/entitlement/index.ts +++ b/packages/backend/server/src/core/entitlement/index.ts @@ -1,23 +1,11 @@ import { Module } from '@nestjs/common'; -import { LegacyEntitlementProjectionService } from './projection'; -import { EntitlementProjectionChecker } from './projection-checker'; import { EntitlementService } from './service'; @Module({ - providers: [ - EntitlementService, - LegacyEntitlementProjectionService, - EntitlementProjectionChecker, - ], - exports: [ - EntitlementService, - LegacyEntitlementProjectionService, - EntitlementProjectionChecker, - ], + providers: [EntitlementService], + exports: [EntitlementService], }) export class EntitlementModule {} export { EntitlementService }; -export { EntitlementProjectionChecker }; -export { LegacyEntitlementProjectionService }; diff --git a/packages/backend/server/src/core/entitlement/projection-checker.ts b/packages/backend/server/src/core/entitlement/projection-checker.ts deleted file mode 100644 index e0b94cffe1..0000000000 --- a/packages/backend/server/src/core/entitlement/projection-checker.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; - -@Injectable() -export class EntitlementProjectionChecker { - constructor(private readonly db: PrismaClient) {} - - async checkEntitlementProjection() { - const now = new Date(); - const [ - missingEffectiveUserState, - missingEffectiveWorkspaceState, - staleEffectiveUserState, - staleEffectiveWorkspaceState, - cloudSubscriptionProjectionMissing, - selfhostLicenseProjectionMissing, - cloudSubscriptionEntitlementMissing, - selfhostLicenseEntitlementMissing, - providerActiveEntitlementMissing, - entitlementProviderMissing, - dirtyLegacyUserFeatures, - dirtyLegacyWorkspaceFeatures, - missingUserFeatureProjection, - missingWorkspaceFeatureProjection, - ] = await Promise.all([ - this.db.user.count({ - where: { quotaState: null }, - }), - this.db.workspace.count({ - where: { quotaState: null }, - }), - this.db.effectiveUserQuotaState.count({ - where: { - OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }], - }, - }), - this.db.effectiveWorkspaceQuotaState.count({ - where: { - OR: [{ stale: true }, { known: false }, { staleAfter: { lt: now } }], - }, - }), - this.cloudSubscriptionProjectionMissing(), - this.selfhostLicenseProjectionMissing(), - this.cloudSubscriptionEntitlementMissing(), - this.selfhostLicenseEntitlementMissing(), - this.providerActiveEntitlementMissing(), - this.entitlementProviderMissing(), - this.dirtyLegacyUserFeatures(), - this.dirtyLegacyWorkspaceFeatures(), - this.missingUserFeatureProjection(), - this.missingWorkspaceFeatureProjection(), - ]); - - return { - missingEffectiveUserState, - missingEffectiveWorkspaceState, - staleEffectiveUserState, - staleEffectiveWorkspaceState, - cloudSubscriptionProjectionMissing, - selfhostLicenseProjectionMissing, - cloudSubscriptionEntitlementMissing, - selfhostLicenseEntitlementMissing, - providerActiveEntitlementMissing, - entitlementProviderMissing, - dirtyLegacyUserFeatures, - dirtyLegacyWorkspaceFeatures, - missingUserFeatureProjection, - missingWorkspaceFeatureProjection, - }; - } - - private async cloudSubscriptionProjectionMissing() { - const legacyKeys = new Set( - ( - await this.db.subscription.findMany({ - where: { - status: { in: ['active', 'trialing', 'past_due'] }, - }, - select: { targetId: true, plan: true }, - }) - ).map(subscription => `${subscription.targetId}:${subscription.plan}`) - ); - const entitlements = await this.validEntitlements({ - source: 'cloud_subscription', - }); - - return entitlements.filter( - entitlement => - entitlement.targetId && - !legacyKeys.has( - `${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}` - ) - ).length; - } - - private async selfhostLicenseProjectionMissing() { - const licenseKeys = new Set( - ( - await this.db.installedLicense.findMany({ - select: { key: true }, - }) - ).map(license => license.key) - ); - const entitlements = await this.validEntitlements({ - source: 'selfhost_license', - }); - - return entitlements.filter( - entitlement => - entitlement.subjectId && !licenseKeys.has(entitlement.subjectId) - ).length; - } - - private async cloudSubscriptionEntitlementMissing() { - const activeSubscriptions = await this.db.subscription.findMany({ - where: { - status: { in: ['active', 'trialing', 'past_due'] }, - }, - select: { targetId: true, plan: true }, - }); - const valid = new Set( - ( - await this.validEntitlements({ - source: 'cloud_subscription', - }) - ).map( - entitlement => - `${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}` - ) - ); - - return activeSubscriptions.filter( - subscription => - !valid.has(`${subscription.targetId}:${subscription.plan}`) - ).length; - } - - private async selfhostLicenseEntitlementMissing() { - const licenses = await this.db.installedLicense.findMany({ - where: { - license: { not: null }, - }, - select: { key: true }, - }); - const validKeys = new Set( - ( - await this.validEntitlements({ - source: 'selfhost_license', - }) - ).flatMap(entitlement => entitlement.subjectId ?? []) - ); - - return licenses.filter(license => !validKeys.has(license.key)).length; - } - - private async providerActiveEntitlementMissing() { - const activeProviderKeys = await this.activeProviderSubscriptionKeys(); - const valid = new Set( - ( - await this.validEntitlements({ - source: 'cloud_subscription', - }) - ).map( - entitlement => - `${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}` - ) - ); - - return activeProviderKeys.filter(key => !valid.has(key)).length; - } - - private async entitlementProviderMissing() { - const activeProviderKeys = new Set( - await this.activeProviderSubscriptionKeys() - ); - const entitlements = await this.validEntitlements({ - source: 'cloud_subscription', - }); - - return entitlements.filter( - entitlement => - entitlement.targetId && - !activeProviderKeys.has( - `${entitlement.targetId}:${this.subscriptionPlan(entitlement.plan)}` - ) - ).length; - } - - private async dirtyLegacyUserFeatures() { - const rows = await this.db.userFeature.findMany({ - where: { - activated: true, - name: { - in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'], - }, - }, - select: { - userId: true, - name: true, - }, - }); - - const valid = new Set( - ( - await this.validEntitlements({ - targetType: 'user', - plan: { in: ['pro', 'lifetime_pro', 'ai'] }, - }) - ).map(entitlement => `${entitlement.targetId}:${entitlement.plan}`) - ); - - return rows.filter(row => { - const plan = - row.name === 'lifetime_pro_plan_v1' - ? 'lifetime_pro' - : row.name === 'pro_plan_v1' - ? 'pro' - : 'ai'; - return !valid.has(`${row.userId}:${plan}`); - }).length; - } - - private async dirtyLegacyWorkspaceFeatures() { - const rows = await this.db.workspaceFeature.findMany({ - where: { - activated: true, - name: 'team_plan_v1', - }, - select: { workspaceId: true }, - }); - const validWorkspaceIds = new Set( - ( - await this.validEntitlements({ - targetType: 'workspace', - plan: { in: ['team', 'selfhost_team'] }, - }) - ).flatMap(entitlement => entitlement.targetId ?? []) - ); - - return rows.filter(row => !validWorkspaceIds.has(row.workspaceId)).length; - } - - private async missingUserFeatureProjection() { - const entitlements = await this.validEntitlements({ - targetType: 'user', - plan: { in: ['pro', 'lifetime_pro', 'ai'] }, - }); - const features = new Set( - ( - await this.db.userFeature.findMany({ - where: { - activated: true, - name: { - in: ['pro_plan_v1', 'lifetime_pro_plan_v1', 'unlimited_copilot'], - }, - }, - select: { userId: true, name: true }, - }) - ).map(feature => `${feature.userId}:${feature.name}`) - ); - - return entitlements.filter(entitlement => { - if (!entitlement.targetId) { - return false; - } - const feature = - entitlement.plan === 'lifetime_pro' - ? 'lifetime_pro_plan_v1' - : entitlement.plan === 'pro' - ? 'pro_plan_v1' - : 'unlimited_copilot'; - return !features.has(`${entitlement.targetId}:${feature}`); - }).length; - } - - private async missingWorkspaceFeatureProjection() { - const entitlements = await this.validEntitlements({ - targetType: 'workspace', - plan: { in: ['team', 'selfhost_team'] }, - }); - const featureWorkspaceIds = new Set( - ( - await this.db.workspaceFeature.findMany({ - where: { - activated: true, - name: 'team_plan_v1', - }, - select: { workspaceId: true }, - }) - ).map(feature => feature.workspaceId) - ); - - return entitlements.filter( - entitlement => - entitlement.targetId && !featureWorkspaceIds.has(entitlement.targetId) - ).length; - } - - private validEntitlements(where: Record) { - const now = new Date(); - return this.db.entitlement.findMany({ - where: { - ...where, - ...(where.source === 'selfhost_license' - ? { signedPayload: { not: null } } - : {}), - OR: [ - { - status: 'active', - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }, - { - status: 'grace', - graceUntil: { gt: now }, - }, - ], - }, - select: { - targetId: true, - subjectId: true, - plan: true, - }, - }); - } - - private subscriptionPlan(plan: string) { - return plan === 'lifetime_pro' ? 'pro' : plan; - } - - private async activeProviderSubscriptionKeys() { - const now = new Date(); - const subscriptions = await this.db.providerSubscription.findMany({ - where: { - status: { in: ['active', 'trialing', 'past_due'] }, - OR: [{ periodEnd: null }, { periodEnd: { gt: now } }], - }, - select: { - targetId: true, - plan: true, - }, - }); - - return subscriptions.map( - subscription => `${subscription.targetId}:${subscription.plan}` - ); - } -} diff --git a/packages/backend/server/src/core/entitlement/projection.ts b/packages/backend/server/src/core/entitlement/projection.ts deleted file mode 100644 index 832412223c..0000000000 --- a/packages/backend/server/src/core/entitlement/projection.ts +++ /dev/null @@ -1,835 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Entitlement, IapStore, PrismaClient, Provider } from '@prisma/client'; - -import { OnEvent } from '../../base'; -import { Models } from '../../models'; -import { - SubscriptionPlan, - SubscriptionRecurring, - SubscriptionStatus, -} from '../../plugins/payment/types'; -import { EntitlementService } from './service'; - -type Metadata = { - provider?: string | null; - recurring?: string | null; - variant?: string | null; - subscriptionId?: string | number | null; - stripeSubscriptionId?: string | null; - validateKey?: string | null; - legacyProjected?: boolean; -}; - -const BACKFILL_BATCH_SIZE = 1000; - -@Injectable() -export class LegacyEntitlementProjectionService { - constructor( - private readonly db: PrismaClient, - private readonly models: Models, - private readonly entitlement: EntitlementService - ) {} - - @OnEvent('entitlement.changed') - async onEntitlementChanged({ - targetType, - targetId, - }: Events['entitlement.changed']) { - if (targetType === 'user') { - await this.#projectCloudSubscriptions('user', targetId); - await this.#projectUserFeatures(targetId); - } else if (targetType === 'workspace') { - await this.#projectCloudSubscriptions('workspace', targetId); - await Promise.all([ - this.#projectWorkspaceFeatures(targetId), - this.#projectInstalledLicense(targetId), - ]); - } - } - - @OnEvent('workspace.quota_state.changed') - async onWorkspaceQuotaStateChanged({ - workspaceId, - }: Events['workspace.quota_state.changed']) { - await this.#projectReadonlyFeature(workspaceId); - } - - async scanInstalledLicenses(options: { emit?: boolean } = {}) { - const licenses = await this.db.installedLicense.findMany(); - const emit = options.emit ?? true; - - await Promise.all( - licenses.map(async license => - license.license - ? await this.entitlement.upsertFromSelfhostLicense( - { - workspaceId: license.workspaceId, - licenseKey: license.key, - recurring: license.recurring, - quantity: license.quantity, - expiresAt: license.expiredAt, - validatedAt: license.validatedAt, - license: Buffer.from(license.license), - }, - { emit } - ) - : license.validateKey - ? await this.entitlement.upsertFromValidatedSelfhostLicense( - { - workspaceId: license.workspaceId, - licenseKey: license.key, - recurring: license.recurring, - quantity: license.quantity, - expiresAt: license.expiredAt, - validatedAt: license.validatedAt, - validateKey: license.validateKey, - variant: license.variant, - }, - { emit } - ) - : await this.entitlement.markSelfhostLicenseNeedsReupload( - { - workspaceId: license.workspaceId, - licenseKey: license.key, - reason: 'Installed license has no raw payload to verify.', - }, - { emit } - ) - ) - ); - } - - async backfillEntitlementsAndQuotaStates() { - await this.#cleanupDanglingLegacyEntitlements(); - await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: true }); - } - - async shadowBackfillEntitlementsAndQuotaStates() { - await this.#backfillEntitlementsAndQuotaStates({ cleanupLegacy: false }); - } - - async #backfillEntitlementsAndQuotaStates({ - cleanupLegacy, - }: { - cleanupLegacy: boolean; - }) { - const [subscriptionCount, invoiceCount, installedLicenseCount] = - await Promise.all([ - this.db.subscription.count(), - this.db.invoice.count(), - this.db.installedLicense.count(), - ]); - - if ( - subscriptionCount === 0 && - invoiceCount === 0 && - installedLicenseCount === 0 - ) { - await this.#backfillQuotaStateStaleFlags(); - return; - } - - const subscriptions = await this.db.subscription.findMany(); - - for (const subscription of subscriptions) { - if (!(await this.#subscriptionTargetExists(subscription))) { - continue; - } - if (subscription.plan === SubscriptionPlan.SelfHostedTeam) { - await this.entitlement.markSelfhostLicenseNeedsReupload( - { - licenseKey: subscription.targetId, - reason: - 'Historical self-hosted team subscription needs license activation or revalidation.', - }, - { emit: cleanupLegacy } - ); - continue; - } - await this.entitlement.upsertFromCloudSubscription(subscription, { - emit: cleanupLegacy, - legacySync: true, - }); - await this.#backfillProviderSubscription(subscription); - if ( - subscription.plan === SubscriptionPlan.AI && - (subscription.trialStart || subscription.trialEnd) - ) { - await this.#backfillTrialUsage(subscription); - } - } - - await this.#backfillPaymentEvents(); - await this.scanInstalledLicenses({ emit: cleanupLegacy }); - - await this.#backfillQuotaStateStaleFlags(); - } - - async #backfillQuotaStateStaleFlags() { - await Promise.all([ - this.db.effectiveUserQuotaState.updateMany({ - data: { stale: true }, - }), - this.db.effectiveWorkspaceQuotaState.updateMany({ - data: { stale: true }, - }), - ]); - - await Promise.all([ - this.#createMissingUserQuotaStates(), - this.#createMissingWorkspaceQuotaStates(), - ]); - } - - async #createMissingUserQuotaStates() { - let lastId: string | undefined; - while (true) { - const users = await this.db.user.findMany({ - select: { id: true }, - where: lastId ? { id: { gt: lastId } } : undefined, - orderBy: { id: 'asc' }, - take: BACKFILL_BATCH_SIZE, - }); - if (!users.length) { - break; - } - - await this.db.effectiveUserQuotaState.createMany({ - data: users.map(user => ({ - userId: user.id, - plan: 'free', - blobLimit: BigInt(0), - storageQuota: BigInt(0), - usedStorageQuota: BigInt(0), - historyPeriodSeconds: 0, - known: false, - stale: true, - })), - skipDuplicates: true, - }); - - lastId = users.at(-1)?.id; - } - } - - async #createMissingWorkspaceQuotaStates() { - let lastId: string | undefined; - while (true) { - const workspaces = await this.db.workspace.findMany({ - select: { id: true }, - where: lastId ? { id: { gt: lastId } } : undefined, - orderBy: { id: 'asc' }, - take: BACKFILL_BATCH_SIZE, - }); - if (!workspaces.length) { - break; - } - - await this.db.effectiveWorkspaceQuotaState.createMany({ - data: workspaces.map(workspace => ({ - workspaceId: workspace.id, - plan: 'free', - usesOwnerQuota: true, - seatLimit: 0, - memberCount: 0, - overcapacityMemberCount: 0, - blobLimit: BigInt(0), - storageQuota: BigInt(0), - usedStorageQuota: BigInt(0), - historyPeriodSeconds: 0, - known: false, - stale: true, - })), - skipDuplicates: true, - }); - - lastId = workspaces.at(-1)?.id; - } - } - - async #backfillProviderSubscription(subscription: { - targetId: string; - plan: string; - recurring: string; - status: string; - provider: Provider | string; - iapStore?: IapStore | null; - rcExternalRef?: string | null; - rcProductId?: string | null; - stripeSubscriptionId?: string | null; - quantity: number; - start: Date; - end?: Date | null; - trialStart?: Date | null; - trialEnd?: Date | null; - canceledAt?: Date | null; - }) { - const targetType = - subscription.plan === SubscriptionPlan.Team ? 'workspace' : 'user'; - if ( - subscription.provider === 'stripe' && - subscription.stripeSubscriptionId - ) { - await this.db.providerSubscription.upsert({ - where: { - provider_externalSubscriptionId: { - provider: 'stripe', - externalSubscriptionId: subscription.stripeSubscriptionId, - }, - }, - update: { - targetType, - targetId: subscription.targetId, - plan: subscription.plan, - recurring: subscription.recurring, - status: subscription.status, - quantity: subscription.quantity, - periodStart: subscription.start, - periodEnd: subscription.end, - trialStart: subscription.trialStart, - trialEnd: subscription.trialEnd, - canceledAt: subscription.canceledAt, - metadata: { legacySync: true }, - }, - create: { - provider: 'stripe', - targetType, - targetId: subscription.targetId, - plan: subscription.plan, - recurring: subscription.recurring, - status: subscription.status, - externalSubscriptionId: subscription.stripeSubscriptionId, - quantity: subscription.quantity, - periodStart: subscription.start, - periodEnd: subscription.end, - trialStart: subscription.trialStart, - trialEnd: subscription.trialEnd, - canceledAt: subscription.canceledAt, - metadata: { legacySync: true }, - }, - }); - return; - } - - if ( - subscription.provider === 'revenuecat' && - subscription.iapStore && - subscription.rcExternalRef && - subscription.rcProductId - ) { - await this.db.providerSubscription.upsert({ - where: { - provider_iapStore_externalRef_externalProductId_externalCustomerId: { - provider: 'revenuecat', - iapStore: subscription.iapStore, - externalRef: subscription.rcExternalRef, - externalProductId: subscription.rcProductId, - externalCustomerId: subscription.targetId, - }, - }, - update: { - targetType, - targetId: subscription.targetId, - plan: subscription.plan, - recurring: subscription.recurring, - status: subscription.status, - quantity: subscription.quantity, - periodStart: subscription.start, - periodEnd: subscription.end, - trialStart: subscription.trialStart, - trialEnd: subscription.trialEnd, - canceledAt: subscription.canceledAt, - metadata: { legacySync: true }, - }, - create: { - provider: 'revenuecat', - targetType, - targetId: subscription.targetId, - plan: subscription.plan, - recurring: subscription.recurring, - status: subscription.status, - externalCustomerId: subscription.targetId, - iapStore: subscription.iapStore, - externalRef: subscription.rcExternalRef, - externalProductId: subscription.rcProductId, - quantity: subscription.quantity, - periodStart: subscription.start, - periodEnd: subscription.end, - trialStart: subscription.trialStart, - trialEnd: subscription.trialEnd, - canceledAt: subscription.canceledAt, - metadata: { legacySync: true }, - }, - }); - } - } - - async #backfillTrialUsage(subscription: { - targetId: string; - plan: string; - provider: Provider | string; - stripeSubscriptionId?: string | null; - rcExternalRef?: string | null; - trialStart?: Date | null; - trialEnd?: Date | null; - start: Date; - }) { - await this.db.subscriptionTrialUsage.upsert({ - where: { - targetType_targetId_plan: { - targetType: 'user', - targetId: subscription.targetId, - plan: subscription.plan, - }, - }, - update: {}, - create: { - targetType: 'user', - targetId: subscription.targetId, - plan: subscription.plan, - provider: - subscription.provider === 'revenuecat' ? 'revenuecat' : 'stripe', - externalRef: - subscription.stripeSubscriptionId ?? - subscription.rcExternalRef ?? - null, - firstUsedAt: - subscription.trialStart ?? - subscription.trialEnd ?? - subscription.start, - metadata: { legacySync: true }, - }, - }); - } - - async #backfillPaymentEvents() { - const invoices = await this.db.invoice.findMany(); - - for (const invoice of invoices) { - await this.db.paymentEvent.upsert({ - where: { - provider_externalEventId: { - provider: 'stripe', - externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`, - }, - }, - update: { - targetId: invoice.targetId, - externalInvoiceId: invoice.stripeInvoiceId, - amount: invoice.amount, - currency: invoice.currency, - processingStatus: 'processed', - processedAt: invoice.updatedAt, - metadata: { - legacySync: true, - status: invoice.status, - reason: invoice.reason, - }, - }, - create: { - provider: 'stripe', - eventType: 'invoice.backfill', - externalEventId: `stripe_invoice:${invoice.stripeInvoiceId}`, - targetId: invoice.targetId, - externalInvoiceId: invoice.stripeInvoiceId, - amount: invoice.amount, - currency: invoice.currency, - occurredAt: invoice.createdAt, - processingStatus: 'processed', - processedAt: invoice.updatedAt, - metadata: { - legacySync: true, - status: invoice.status, - reason: invoice.reason, - }, - }, - }); - } - } - - async #cleanupDanglingLegacyEntitlements() { - await this.db.$executeRaw` - DELETE FROM entitlements entitlement - WHERE ( - entitlement.target_type = 'user' - AND NOT EXISTS ( - SELECT 1 - FROM users - WHERE users.id = entitlement.target_id - ) - ) - OR ( - entitlement.target_type = 'workspace' - AND NOT EXISTS ( - SELECT 1 - FROM workspaces - WHERE workspaces.id = entitlement.target_id - ) - ) - `; - - await this.db.$executeRaw` - DELETE FROM subscriptions subscription - WHERE ( - subscription.plan IN (${SubscriptionPlan.Pro}, ${SubscriptionPlan.AI}) - AND NOT EXISTS ( - SELECT 1 - FROM users - WHERE users.id = subscription.target_id - ) - ) - OR ( - subscription.plan = ${SubscriptionPlan.Team} - AND NOT EXISTS ( - SELECT 1 - FROM workspaces - WHERE workspaces.id = subscription.target_id - ) - ) - `; - } - - async #subscriptionTargetExists(subscription: { - targetId: string; - plan: string; - }) { - if ( - subscription.plan === SubscriptionPlan.Pro || - subscription.plan === SubscriptionPlan.AI - ) { - return !!(await this.db.user.findUnique({ - where: { id: subscription.targetId }, - select: { id: true }, - })); - } - - if (subscription.plan === SubscriptionPlan.Team) { - return !!(await this.db.workspace.findUnique({ - where: { id: subscription.targetId }, - select: { id: true }, - })); - } - - return true; - } - - async #projectUserFeatures(userId: string) { - // TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone. - const entitlements = await this.#activeEntitlements('user', userId); - const quotaEntitlement = entitlements.find(entitlement => - ['lifetime_pro', 'pro'].includes(entitlement.plan) - ); - - if (quotaEntitlement?.plan === 'lifetime_pro') { - await this.models.userFeature.switchQuota( - userId, - 'lifetime_pro_plan_v1', - 'legacy entitlement projection' - ); - } else if (quotaEntitlement?.plan === 'pro') { - await this.models.userFeature.switchQuota( - userId, - 'pro_plan_v1', - 'legacy entitlement projection' - ); - } else if ( - await this.hasActiveUserFeature(userId, [ - 'pro_plan_v1', - 'lifetime_pro_plan_v1', - ]) - ) { - await this.models.userFeature.switchQuota( - userId, - 'free_plan_v1', - 'legacy entitlement projection' - ); - } - - if (entitlements.some(entitlement => entitlement.plan === 'ai')) { - await this.models.userFeature.add( - userId, - 'unlimited_copilot', - 'legacy entitlement projection' - ); - } else { - await this.models.userFeature.remove(userId, 'unlimited_copilot'); - } - } - - async #projectWorkspaceFeatures(workspaceId: string) { - // TODO(stable-upgrade): contract legacy feature projection after old clients/resolvers are gone. - const [entitlement, resolved] = await Promise.all([ - this.entitlement.getBestEntitlement('workspace', workspaceId), - this.entitlement.resolveWorkspaceEntitlement(workspaceId), - ]); - - if ( - entitlement && - ['team', 'selfhost_team'].includes(resolved.plan) && - resolved.valid && - resolved.quota.seatLimit - ) { - await this.models.workspaceFeature.add( - workspaceId, - 'team_plan_v1', - 'legacy entitlement projection', - { - memberLimit: resolved.quota.seatLimit, - } - ); - } else { - await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1'); - } - } - - async #projectCloudSubscriptions( - targetType: 'user' | 'workspace', - targetId: string - ) { - // TODO(stable-upgrade): remove reverse projection after stable no longer depends on old subscriptions. - if (env.selfhosted) return; - const entitlements = await this.db.entitlement.findMany({ - where: { - targetType, - targetId, - source: 'cloud_subscription', - }, - orderBy: { updatedAt: 'asc' }, - }); - - for (const entitlement of this.#projectableCloudEntitlements( - entitlements - )) { - const metadata = entitlement.metadata as Metadata; - await this.db.subscription.upsert({ - where: { - targetId_plan: { - targetId, - plan: this.#subscriptionPlan(entitlement.plan), - }, - }, - update: { - recurring: metadata.recurring ?? SubscriptionRecurring.Monthly, - variant: metadata.variant ?? null, - quantity: entitlement.quantity ?? 1, - stripeSubscriptionId: metadata.stripeSubscriptionId ?? null, - provider: this.#provider(metadata.provider), - status: this.#subscriptionStatus(entitlement.status), - start: entitlement.startsAt ?? entitlement.createdAt, - end: entitlement.expiresAt, - trialEnd: entitlement.graceUntil, - }, - create: { - targetId, - plan: this.#subscriptionPlan(entitlement.plan), - recurring: metadata.recurring ?? SubscriptionRecurring.Monthly, - variant: metadata.variant ?? null, - quantity: entitlement.quantity ?? 1, - stripeSubscriptionId: metadata.stripeSubscriptionId ?? null, - provider: this.#provider(metadata.provider), - status: this.#subscriptionStatus(entitlement.status), - start: entitlement.startsAt ?? entitlement.createdAt, - end: entitlement.expiresAt, - trialEnd: entitlement.graceUntil, - }, - }); - if (!metadata.legacyProjected) { - await this.db.entitlement.update({ - where: { id: entitlement.id }, - data: { - metadata: { - ...metadata, - legacyProjected: true, - }, - }, - }); - } - } - } - - *#projectableCloudEntitlements(entitlements: Entitlement[]) { - const byPlan = new Map(); - - for (const entitlement of entitlements) { - const plan = this.#subscriptionPlan(entitlement.plan); - const current = byPlan.get(plan); - - if ( - !current || - this.#subscriptionProjectionPriority(entitlement) > - this.#subscriptionProjectionPriority(current) - ) { - byPlan.set(plan, entitlement); - } - } - - yield* byPlan.values(); - } - - #subscriptionProjectionPriority(entitlement: { - status: string; - updatedAt: Date; - }) { - const statusPriority = - entitlement.status === 'active' || entitlement.status === 'grace' - ? 2 - : entitlement.status === 'expired' - ? 1 - : 0; - - return ( - statusPriority * 10_000_000_000_000 + entitlement.updatedAt.getTime() - ); - } - - async #projectInstalledLicense(workspaceId: string) { - const [entitlements, resolved] = await Promise.all([ - this.db.entitlement.findMany({ - where: { - targetType: 'workspace', - targetId: workspaceId, - source: 'selfhost_license', - }, - orderBy: [{ signedPayload: 'desc' }, { updatedAt: 'desc' }], - }), - this.entitlement.resolveWorkspaceEntitlement(workspaceId), - ]); - const entitlement = entitlements.sort( - (left, right) => - this.#installedLicenseStatusPriority(right.status) - - this.#installedLicenseStatusPriority(left.status) || - Number(!!right.signedPayload) - Number(!!left.signedPayload) || - right.updatedAt.getTime() - left.updatedAt.getTime() - )[0]; - - if (!entitlement) { - return; - } - - if ( - resolved.plan !== 'selfhost_team' || - !['active', 'grace', 'expired'].includes(resolved.status) - ) { - await this.db.installedLicense.deleteMany({ - where: { workspaceId }, - }); - return; - } - - const metadata = entitlement.metadata as Metadata; - const expiredAt = resolved.expiresAt - ? new Date(resolved.expiresAt) - : entitlement.expiresAt; - await this.db.installedLicense.upsert({ - where: { workspaceId }, - update: { - key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id, - quantity: resolved.quantity ?? 1, - recurring: - resolved.recurring ?? - metadata.recurring ?? - SubscriptionRecurring.Monthly, - variant: metadata.variant ?? null, - validateKey: metadata.validateKey ?? '', - validatedAt: entitlement.validatedAt ?? new Date(), - expiredAt, - license: entitlement.signedPayload - ? Buffer.from(entitlement.signedPayload) - : null, - }, - create: { - workspaceId, - key: resolved.subjectId ?? entitlement.subjectId ?? entitlement.id, - quantity: resolved.quantity ?? 1, - recurring: - resolved.recurring ?? - metadata.recurring ?? - SubscriptionRecurring.Monthly, - variant: metadata.variant ?? null, - validateKey: metadata.validateKey ?? '', - validatedAt: entitlement.validatedAt ?? new Date(), - expiredAt, - license: entitlement.signedPayload - ? Buffer.from(entitlement.signedPayload) - : null, - }, - }); - } - - #installedLicenseStatusPriority(status: string) { - if (status === 'active' || status === 'grace') { - return 3; - } - if (status === 'expired') { - return 2; - } - if (status === 'needs_reupload') { - return 1; - } - return 0; - } - - async #projectReadonlyFeature(workspaceId: string) { - const state = await this.db.effectiveWorkspaceQuotaState.findUnique({ - where: { - workspaceId, - }, - }); - - if (state?.readonly) { - await this.models.workspaceFeature.add( - workspaceId, - 'quota_exceeded_readonly_workspace_v1', - `legacy quota state projection: ${state.readonlyReasons.join(',')}` - ); - } else { - await this.models.workspaceFeature.remove( - workspaceId, - 'quota_exceeded_readonly_workspace_v1' - ); - } - } - - async #activeEntitlements( - targetType: 'user' | 'workspace', - targetId: string - ) { - return this.entitlement.getActiveEntitlements(targetType, targetId); - } - - private async hasActiveUserFeature(userId: string, names: string[]) { - const count = await this.db.userFeature.count({ - where: { - userId, - name: { in: names }, - activated: true, - }, - }); - - return count > 0; - } - - #subscriptionPlan(plan: string) { - if (plan === 'lifetime_pro') { - return SubscriptionPlan.Pro; - } - if (plan === 'selfhost_team') { - return SubscriptionPlan.SelfHostedTeam; - } - return plan; - } - - #subscriptionStatus(status: string) { - if (status === 'active') { - return SubscriptionStatus.Active; - } - if (status === 'grace') { - return SubscriptionStatus.PastDue; - } - return SubscriptionStatus.Canceled; - } - - #provider(provider: string | null | undefined) { - return provider === 'revenuecat' ? 'revenuecat' : 'stripe'; - } -} diff --git a/packages/backend/server/src/core/entitlement/service.ts b/packages/backend/server/src/core/entitlement/service.ts index 5d76e81817..1e8604c0f7 100644 --- a/packages/backend/server/src/core/entitlement/service.ts +++ b/packages/backend/server/src/core/entitlement/service.ts @@ -60,10 +60,6 @@ declare global { @Injectable() export class EntitlementService { - private readonly legacyCloudSubscriptionSyncs = new Map< - string, - Promise - >(); private readonly remoteSelfhostLicenseVerifications = new Map< string, Promise @@ -102,7 +98,6 @@ export class EntitlementService { } async getBestEntitlement(targetType: TargetType, targetId: string) { - await this.syncLegacyCloudSubscriptionEntitlements(targetType, targetId); const entitlements = await this.db.entitlement.findMany({ where: { targetType, @@ -138,7 +133,6 @@ export class EntitlementService { } async getActiveEntitlements(targetType: TargetType, targetId: string) { - await this.syncLegacyCloudSubscriptionEntitlements(targetType, targetId); return this.db.entitlement.findMany({ where: { targetType, @@ -154,7 +148,7 @@ export class EntitlementService { async upsertFromCloudSubscription( input: CloudSubscriptionEntitlementInput, - options: { emit?: boolean; legacySync?: boolean } = {} + options: { emit?: boolean } = {} ) { const emit = options.emit ?? true; const targetType = this.targetTypeForPlan(input.plan); @@ -182,7 +176,6 @@ export class EntitlementService { variant: input.variant ?? null, subscriptionId: input.subscriptionId ?? null, stripeSubscriptionId: input.stripeSubscriptionId ?? null, - legacySync: options.legacySync ?? false, }, startsAt: input.start ?? null, expiresAt: input.end ?? null, @@ -294,37 +287,6 @@ export class EntitlementService { ); } - async syncLegacyCloudSubscriptionEntitlements( - targetType: TargetType, - targetId: string - ) { - // TODO(stable-upgrade): remove legacy subscription import after stable no longer writes old subscriptions. - if (env.selfhosted || targetType === 'instance') { - return; - } - - const key = `${targetType}:${targetId}`; - const existing = this.legacyCloudSubscriptionSyncs.get(key); - if (existing) { - return existing; - } - - const task = this.doSyncLegacyCloudSubscriptionEntitlements( - targetType, - targetId - ) - .then(changed => { - if (changed) { - this.event.emit('entitlement.changed', { targetType, targetId }); - } - }) - .finally(() => { - this.legacyCloudSubscriptionSyncs.delete(key); - }); - this.legacyCloudSubscriptionSyncs.set(key, task); - return task; - } - async upsertFromSelfhostLicense( input: SelfhostLicenseEntitlementInput, options: { emit?: boolean } = {} @@ -505,16 +467,6 @@ export class EntitlementService { subscriptionId?: string | number | null; stripeSubscriptionId?: string | null; }) { - await this.db.subscription.updateMany({ - where: { - targetId: input.targetId, - plan: input.plan, - }, - data: { - status: SubscriptionStatus.Canceled, - end: new Date(), - }, - }); await this.revokeBySubject( 'cloud_subscription', this.cloudSubjectId(input) @@ -610,86 +562,6 @@ export class EntitlementService { } } - private async doSyncLegacyCloudSubscriptionEntitlements( - targetType: Exclude, - targetId: string - ) { - let changed = false; - const legacyPlans = - targetType === 'user' - ? [SubscriptionPlan.Pro, SubscriptionPlan.AI] - : [SubscriptionPlan.Team]; - const entitlementPlans = - targetType === 'user' ? ['pro', 'lifetime_pro', 'ai'] : ['team']; - const subscriptions = await this.db.subscription.findMany({ - where: { - targetId, - plan: { in: legacyPlans }, - }, - orderBy: { updatedAt: 'asc' }, - }); - const legacySubjects = new Set( - subscriptions.map(subscription => this.cloudSubjectId(subscription)) - ); - const legacySubscriptionPlans = new Set( - subscriptions.map(subscription => subscription.plan) - ); - - for (const subscription of subscriptions) { - const before = await this.findBySubject( - 'cloud_subscription', - this.cloudSubjectId(subscription) - ); - const entitlement = await this.upsertFromCloudSubscription(subscription, { - emit: false, - legacySync: true, - }); - changed = - changed || - !before || - before.targetType !== entitlement.targetType || - before.targetId !== entitlement.targetId || - before.plan !== entitlement.plan || - before.status !== entitlement.status || - before.quantity !== entitlement.quantity || - before.expiresAt?.getTime() !== entitlement.expiresAt?.getTime() || - before.graceUntil?.getTime() !== entitlement.graceUntil?.getTime(); - } - - const staleEntitlements = await this.db.entitlement.findMany({ - where: { - targetType, - targetId, - source: 'cloud_subscription', - plan: { in: entitlementPlans }, - status: { in: ['active', 'grace'] }, - OR: [ - { metadata: { path: ['legacySync'], equals: true } }, - { metadata: { path: ['legacyProjected'], equals: true } }, - ], - }, - }); - const staleIds = staleEntitlements - .filter( - entitlement => - !legacySubjects.has(entitlement.subjectId ?? '') && - !legacySubscriptionPlans.has( - this.legacySubscriptionPlan(entitlement.plan) - ) - ) - .map(entitlement => entitlement.id); - - if (staleIds.length) { - await this.db.entitlement.updateMany({ - where: { id: { in: staleIds } }, - data: { status: 'revoked' }, - }); - changed = true; - } - - return changed; - } - private cloudSubjectId( input: Pick< CloudSubscriptionEntitlementInput, @@ -704,19 +576,6 @@ export class EntitlementService { ); } - private legacySubscriptionPlan(plan: string) { - if (plan === 'pro' || plan === 'lifetime_pro') { - return SubscriptionPlan.Pro; - } - if (plan === 'ai') { - return SubscriptionPlan.AI; - } - if (plan === 'team') { - return SubscriptionPlan.Team; - } - return plan; - } - private async revokeCloudSubscriptionByLegacyTarget(input: { targetId: string; plan: SubscriptionPlan | string; @@ -887,37 +746,23 @@ export class EntitlementService { const expiresAt = new Date(license.expiresAt); const validateKey = license.validateKey || metadata.validateKey; - const [updated] = await Promise.all([ - this.db.entitlement.update({ - where: { id: entitlement.id }, - data: { - status: 'active', - quantity: this.normalizedQuantity(license.quantity), - metadata: { - ...metadata, - recurring: license.recurring, - validateKey, - remoteValidated: true, - errorCode: null, - errorMessage: null, - }, - expiresAt, - validatedAt: new Date(), + const updated = await this.db.entitlement.update({ + where: { id: entitlement.id }, + data: { + status: 'active', + quantity: this.normalizedQuantity(license.quantity), + metadata: { + ...metadata, + recurring: license.recurring, + validateKey, + remoteValidated: true, + errorCode: null, + errorMessage: null, }, - }), - this.db.installedLicense - .updateMany({ - where: { key: entitlement.subjectId }, - data: { - quantity: this.normalizedQuantity(license.quantity), - recurring: license.recurring, - validateKey, - validatedAt: new Date(), - expiredAt: expiresAt, - }, - }) - .catch(() => null), - ]); + expiresAt, + validatedAt: new Date(), + }, + }); this.event.emit('entitlement.changed', { targetType: 'workspace', targetId: entitlement.targetId, diff --git a/packages/backend/server/src/core/features/resolver.ts b/packages/backend/server/src/core/features/resolver.ts index 0c77cebb7c..2df0e11e5a 100644 --- a/packages/backend/server/src/core/features/resolver.ts +++ b/packages/backend/server/src/core/features/resolver.ts @@ -31,7 +31,11 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig { description: 'Enabled features of a user', }) async userFeatures(@Parent() user: UserType) { - const features = await this.models.userFeature.list(user.id); + const features = await this.models.userFeature.list( + user.id, + undefined, + Array.from(this.availableUserFeatures()) + ); const availableUserFeatures = this.availableUserFeatures(); return features.filter(feature => availableUserFeatures.has(feature)); } diff --git a/packages/backend/server/src/core/permission/__tests__/docs.spec.ts b/packages/backend/server/src/core/permission/__tests__/docs.spec.ts index b8d3fa92a8..22b9b07e24 100644 --- a/packages/backend/server/src/core/permission/__tests__/docs.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/docs.spec.ts @@ -1,5 +1,3 @@ -import { randomUUID } from 'node:crypto'; - import { Prisma, PrismaClient } from '@prisma/client'; import test from 'ava'; @@ -7,7 +5,6 @@ import { createModule } from '../../../__tests__/create-module'; import { Mockers } from '../../../__tests__/mocks'; import { Models } from '../../../models'; import { AccessControllerBuilder } from '../builder'; -import { PermissionDiagnosticService } from '../diagnostic'; import { DocRole, PermissionModule, WorkspaceRole } from '../index'; import { PermissionSqlPredicateBuilder } from '../sql-predicate'; import type { DocAction } from '../types'; @@ -19,7 +16,6 @@ const module = await createModule({ const builder = module.get(AccessControllerBuilder); const models = module.get(Models); const db = module.get(PrismaClient); -const diagnostic = module.get(PermissionDiagnosticService); const sqlPredicate = module.get(PermissionSqlPredicateBuilder); test.after.always(async () => { @@ -35,7 +31,7 @@ async function sqlReadableDocIds(input: { const values = Prisma.join( input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`) ); - const predicate = sqlPredicate.docReadableByNewTablesSql({ + const predicate = sqlPredicate.docReadableSql({ workspaceId: input.workspaceId, userId: input.userId, action: input.action ?? 'Doc.Read', @@ -73,9 +69,29 @@ async function resetProjection(workspaceId: string) { member_default_doc_role = EXCLUDED.member_default_doc_role, updated_at = now() `; - await models.workspaceRuntimeState.upsert(workspaceId, { - readonly: false, - readonlyReasons: [], + await setWritableRuntime(workspaceId); +} + +async function setWritableRuntime(workspaceId: string) { + await db.effectiveWorkspaceQuotaState.upsert({ + where: { workspaceId }, + create: { + workspaceId, + plan: 'free', + usesOwnerQuota: false, + seatLimit: 0, + blobLimit: 0, + storageQuota: 0, + historyPeriodSeconds: 0, + readonly: false, + known: true, + }, + update: { + readonly: false, + readonlyReasons: [], + known: true, + stale: false, + }, }); } @@ -143,7 +159,7 @@ test('should filter docs by Doc.Read', async t => { t.is(docs3.length, 0); }); -test('SQL doc read predicate matches Rust for projection default and public candidates', async t => { +test('SQL doc read predicate handles member default and public candidates', async t => { const owner = await module.create(Mockers.User); const member = await module.create(Mockers.User); const workspace = await module.create(Mockers.Workspace, { @@ -186,18 +202,10 @@ test('SQL doc read predicate matches Rust for projection default and public cand userId: member.id, docIds, }); - const shadow = await diagnostic.shadowSqlDocRead({ - workspaceId: workspace.id, - userId: member.id, - docs: docIds.map(docId => ({ docId })), - sqlReadableDocIds: sqlReadable, - }); - t.deepEqual(sqlReadable, ['missing-policy', 'public-doc']); - t.true(shadow.matched); }); -test('SQL doc read predicate matches Rust for non-member grant and sharing disabled', async t => { +test('SQL doc read predicate handles non-member grant and sharing disabled', async t => { const owner = await module.create(Mockers.User); const nonMember = await module.create(Mockers.User); const workspace = await module.create(Mockers.Workspace, { @@ -258,12 +266,6 @@ test('SQL doc read predicate matches Rust for non-member grant and sharing disab userId: nonMember.id, docIds, }); - const sharingEnabledShadow = await diagnostic.shadowSqlDocRead({ - workspaceId: workspace.id, - userId: nonMember.id, - docs: docIds.map(docId => ({ docId })), - sqlReadableDocIds: sharingEnabledReadable, - }); const sharingEnabledUpdate = await sqlReadableDocIds({ workspaceId: workspace.id, userId: nonMember.id, @@ -281,22 +283,14 @@ test('SQL doc read predicate matches Rust for non-member grant and sharing disab userId: nonMember.id, docIds, }); - const sharingDisabledShadow = await diagnostic.shadowSqlDocRead({ - workspaceId: workspace.id, - userId: nonMember.id, - docs: docIds.map(docId => ({ docId })), - sqlReadableDocIds: sharingDisabledReadable, - }); t.deepEqual(sharingEnabledReadable, [ 'public-doc', 'explicit-grant', 'explicit-owner-grant', ]); - t.true(sharingEnabledShadow.matched); t.deepEqual(sharingEnabledUpdate, ['explicit-owner-grant']); t.deepEqual(sharingDisabledReadable, []); - t.true(sharingDisabledShadow.matched); }); test('SQL doc predicate suppresses member default when explicit grant exists', async t => { @@ -365,107 +359,13 @@ test('SQL doc predicate suppresses member default when explicit grant exists', a t.deepEqual(sqlUpdateAllowed, ['default-manager']); }); -test('legacy SQL doc predicate matches external row and explicit grant cap semantics', async t => { - const workspaceId = randomUUID(); - const memberId = randomUUID(); - const externalId = randomUUID(); - - async function fixtureLegacyDocIds(input: { - userId: string; - action: DocAction; - docIds: string[]; - }) { - const values = Prisma.join( - input.docIds.map((docId, index) => Prisma.sql`(${docId}, ${index})`) - ); - const predicate = sqlPredicate.docReadableByLegacyTablesSql({ - workspaceId, - userId: input.userId, - action: input.action, - docIdColumn: Prisma.raw('c.doc_id'), - }); - // Current triggers reject newly inserted legacy External workspace rows; - // CTEs let the same predicate run in Postgres against historical shapes. - const rows = await db.$queryRaw<{ docId: string }[]>` - WITH - workspaces(id, enable_sharing) AS ( - VALUES (${workspaceId}, true) - ), - workspace_pages(workspace_id, page_id, public, "defaultRole") AS ( - VALUES - (${workspaceId}, 'default-manager', false, ${DocRole.Manager}::smallint), - (${workspaceId}, 'explicit-reader', false, ${DocRole.Manager}::smallint), - (${workspaceId}, 'external-owner', false, ${DocRole.Manager}::smallint), - (${workspaceId}, 'dirty-external', false, ${DocRole.Manager}::smallint) - ), - workspace_user_permissions( - id, - workspace_id, - user_id, - status, - type - ) AS ( - VALUES - (${randomUUID()}, ${workspaceId}, ${memberId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.Collaborator}::smallint), - (${randomUUID()}, ${workspaceId}, ${externalId}, 'Accepted'::"WorkspaceMemberStatus", ${WorkspaceRole.External}::smallint) - ), - workspace_page_user_permissions( - workspace_id, - page_id, - user_id, - type - ) AS ( - VALUES - (${workspaceId}, 'explicit-reader', ${memberId}, ${DocRole.Reader}::smallint), - (${workspaceId}, 'external-owner', ${externalId}, ${DocRole.Owner}::smallint), - (${workspaceId}, 'dirty-external', ${externalId}, ${DocRole.External}::smallint) - ), - candidates(doc_id, ord) AS (VALUES ${values}) - SELECT c.doc_id AS "docId" - FROM candidates c - WHERE ${predicate} - ORDER BY c.ord ASC - `; - return rows.map(row => row.docId); - } - - const memberUpdateAllowed = await fixtureLegacyDocIds({ - userId: memberId, - action: 'Doc.Update', - docIds: ['default-manager', 'explicit-reader'], - }); - const externalUpdateAllowed = await fixtureLegacyDocIds({ - userId: externalId, - action: 'Doc.Update', - docIds: ['external-owner', 'dirty-external'], - }); - const externalManageAllowed = await fixtureLegacyDocIds({ - userId: externalId, - action: 'Doc.Users.Manage', - docIds: ['external-owner', 'dirty-external'], - }); - const externalTransferAllowed = await fixtureLegacyDocIds({ - userId: externalId, - action: 'Doc.TransferOwner', - docIds: ['external-owner', 'dirty-external'], - }); - - t.deepEqual(memberUpdateAllowed, ['default-manager']); - t.deepEqual(externalUpdateAllowed, ['external-owner']); - t.deepEqual(externalManageAllowed, []); - t.deepEqual(externalTransferAllowed, []); -}); - test('should filter docs by Doc.Publish', async t => { const owner = await module.create(Mockers.User); const workspace = await module.create(Mockers.Workspace, { owner, }); await models.workspace.update(workspace.id, { enableSharing: true }); - await models.workspaceRuntimeState.upsert(workspace.id, { - readonly: false, - readonlyReasons: [], - }); + await setWritableRuntime(workspace.id); const docs1 = await builder .user(owner.id) @@ -524,73 +424,3 @@ test('should filter docs by Doc.Publish', async t => { t.is(docs3.length, 0); }); - -test('legacy duplicate doc owner grants do not block projection', async t => { - const owner = await module.create(Mockers.User); - const secondOwner = await module.create(Mockers.User); - const workspace = await module.create(Mockers.Workspace, { - owner, - }); - const docId = randomUUID(); - - await db.$executeRaw` - INSERT INTO workspace_pages ( - workspace_id, - page_id, - public, - "defaultRole" - ) - VALUES (${workspace.id}, ${docId}, false, ${DocRole.Manager}) - `; - await resetProjection(workspace.id); - - await db.$transaction(async tx => { - await tx.$executeRaw` - SELECT set_config('affine.permission_projection.enabled', 'off', true) - `; - await tx.$executeRaw` - INSERT INTO workspace_page_user_permissions ( - workspace_id, - page_id, - user_id, - type, - created_at - ) - VALUES ( - ${workspace.id}, - ${docId}, - ${owner.id}, - ${DocRole.Owner}, - ${new Date('2026-01-02T00:00:00Z')} - ) - `; - await tx.$executeRaw` - INSERT INTO workspace_page_user_permissions ( - workspace_id, - page_id, - user_id, - type, - created_at - ) - VALUES ( - ${workspace.id}, - ${docId}, - ${secondOwner.id}, - ${DocRole.Owner}, - ${new Date('2026-01-01T00:00:00Z')} - ) - `; - }); - - await models.permissionProjection.backfillLegacyProjection(); - - const projectedOwners = await db.$queryRaw<{ principalId: string }[]>` - SELECT principal_id AS "principalId" - FROM doc_grants - WHERE workspace_id = ${workspace.id} - AND doc_id = ${docId} - AND role = 'owner' - `; - - t.deepEqual(projectedOwners, [{ principalId: secondOwner.id }]); -}); diff --git a/packages/backend/server/src/core/permission/__tests__/policy.spec.ts b/packages/backend/server/src/core/permission/__tests__/policy.spec.ts index c4ca002967..0eec8dbb3a 100644 --- a/packages/backend/server/src/core/permission/__tests__/policy.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/policy.spec.ts @@ -209,57 +209,14 @@ test('should roll back team cancellation cleanup when cleanup fails', async t => const admin = await t.context.models.user.create({ email: `${randomUUID()}@affine.pro`, }); - await t.context.db.$transaction(async db => { - await db.$executeRaw` - SELECT set_config('affine.permission_projection.enabled', 'off', true) - `; - const pendingPermission = await db.workspaceUserRole.create({ - data: { - workspaceId: workspace.id, - userId: pending.id, - type: WorkspaceRole.Collaborator, - status: WorkspaceMemberStatus.Pending, - }, - }); - const [invitationShape] = await db.$queryRaw>` - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'workspace_invitations' - AND column_name = 'requested_role' - ) AS "current" - `; - if (invitationShape?.current) { - await db.workspaceInvitation.create({ - data: { - workspaceId: workspace.id, - inviteeUserId: pending.id, - requestedRole: 'member', - status: 'pending', - kind: 'email', - legacyPermissionId: pendingPermission.id, - }, - }); - } else { - await db.$executeRaw` - INSERT INTO workspace_invitations ( - workspace_id, - invitee_user_id, - role, - state, - source, - updated_at - ) - VALUES ( - ${workspace.id}, - ${pending.id}, - ${'member'}, - ${'pending'}, - ${'email'}, - now() - ) - `; - } + await t.context.db.workspaceInvitation.create({ + data: { + workspaceId: workspace.id, + inviteeUserId: pending.id, + requestedRole: 'member', + status: 'pending', + kind: 'email', + }, }); await t.context.models.workspaceUser.set( workspace.id, @@ -269,17 +226,10 @@ test('should roll back team cancellation cleanup when cleanup fails', async t => status: WorkspaceMemberStatus.Accepted, } ); - await t.context.models.workspaceFeature.add( - workspace.id, - 'team_plan_v1', - 'test team workspace', - { - memberLimit: 20, - } - ); - const failure = new Error('cleanup failed'); - Sinon.stub(t.context.models.workspaceFeature, 'remove').rejects(failure); + Sinon.stub(t.context.models.workspaceUser, 'demoteAcceptedAdmins').rejects( + failure + ); const error = await t.throwsAsync( t.context.policy.handleTeamPlanCanceled(workspace.id), @@ -294,7 +244,4 @@ test('should roll back team cancellation cleanup when cleanup fails', async t => (await t.context.models.workspaceUser.get(workspace.id, admin.id))?.type, WorkspaceRole.Admin ); - t.true( - await t.context.models.workspaceFeature.has(workspace.id, 'team_plan_v1') - ); }); diff --git a/packages/backend/server/src/core/permission/__tests__/service.spec.ts b/packages/backend/server/src/core/permission/__tests__/service.spec.ts index 95bc630b95..9229de5528 100644 --- a/packages/backend/server/src/core/permission/__tests__/service.spec.ts +++ b/packages/backend/server/src/core/permission/__tests__/service.spec.ts @@ -1,122 +1,94 @@ -import { WorkspaceMemberStatus } from '@prisma/client'; import test from 'ava'; import { InternalServerError } from '../../../base'; -import { - DocRole, - WorkspaceRole, - type WorkspaceRuntimeState, -} from '../../../models'; -import { PermissionReadModel } from '../config'; +import { DocRole } from '../../../models'; import { docLegacyBoundary } from '../context'; import { PermissionContextLoader } from '../context-loader'; -import { - PERMISSION_SHADOW_MISMATCH_CATEGORIES, - PermissionDiagnosticService, -} from '../diagnostic'; import { PermissionService } from '../service'; import { PermissionSqlPredicateBuilder } from '../sql-predicate'; function createCls() { const store = new Map(); return { - get(key: string) { - return store.get(key); - }, - set(key: string, value: unknown) { - store.set(key, value); - }, + isActive: () => true, + get: (key: string) => store.get(key), + set: (key: string, value: unknown) => store.set(key, value), }; } -function createLoader( - runtimeState: WorkspaceRuntimeState = { - workspaceId: 'w1', - known: true, - stale: false, - readonly: false, - readonlyReasons: [], - updatedAt: new Date(), - lastReconciledAt: new Date(), - staleAfter: null, - }, - docGrantRole: DocRole | null = null, - queryRaw: (query: unknown) => Promise = async () => [] -) { +function createLoader() { const calls = { - workspaceUser: 0, - workspace: 0, + members: 0, + policies: 0, runtime: 0, docPolicies: 0, docGrants: 0, }; - const models = { - workspaceUser: { - get: async () => { - calls.workspaceUser += 1; - return { - type: WorkspaceRole.Owner, - status: WorkspaceMemberStatus.Accepted, - }; - }, + const db = { + $queryRaw: async (strings: TemplateStringsArray) => { + const sql = strings.join(''); + if (sql.includes('FROM workspace_members')) { + calls.members += 1; + return [{ role: 'owner', state: 'active' }]; + } + if (sql.includes('FROM workspace_access_policies')) { + calls.policies += 1; + return [ + { + visibility: 'private', + sharingEnabled: true, + urlPreviewEnabled: true, + memberDefaultDocRole: 'manager', + }, + ]; + } + if (sql.includes('FROM effective_workspace_quota_states')) { + calls.runtime += 1; + return [ + { + known: true, + stale: false, + readonly: false, + readonlyReasons: [], + staleAfter: null, + }, + ]; + } + if (sql.includes('FROM doc_access_policies')) { + calls.docPolicies += 1; + return [ + { + docId: 'public', + visibility: 'public', + publicRole: 'external', + memberDefaultRole: null, + urlPreviewEnabled: false, + }, + ]; + } + if (sql.includes('FROM doc_grants')) { + calls.docGrants += 1; + return [ + { + docId: 'private', + role: 'manager', + }, + ]; + } + throw new Error(`Unexpected query: ${sql}`); }, workspace: { - get: async () => { - calls.workspace += 1; - return { - public: false, - enableSharing: true, - enableUrlPreview: true, - }; - }, - }, - workspaceRuntimeState: { - get: async () => { - calls.runtime += 1; - return runtimeState; - }, - }, - doc: { - findDefaultRoles: async (_workspaceId: string, docIds: string[]) => { - calls.docPolicies += 1; - return docIds.map(docId => ({ - workspace: docId === 'private' ? DocRole.None : DocRole.Manager, - external: docId === 'public' ? DocRole.External : null, - })); - }, - }, - docUser: { - findMany: async () => { - calls.docGrants += 1; - return docGrantRole === null - ? [] - : [ - { - docId: 'private', - type: docGrantRole, - }, - ]; - }, + findUnique: async () => ({ id: 'w1' }), }, }; return { calls, - loader: new PermissionContextLoader( - models as never, - { $queryRaw: queryRaw } as never, - createCls() as never - ), + loader: new PermissionContextLoader(db as never, createCls() as never), }; } test('PermissionService maps native decisions to legacy role boundary', async t => { - const { loader } = createLoader(); - const service = new PermissionService(loader, undefined, undefined, { - permission: { - readModel: PermissionReadModel.Legacy, - fallbackLegacyLoader: false, - }, - } as never); + const service = new PermissionService(createLoader().loader); const permissions = await service.docPermissions({ userId: 'u1', @@ -159,28 +131,14 @@ test('doc legacy boundary keeps resource owner and effective role separate', t = ); }); -test('PermissionService uses new projection loader behind read flag', async t => { +test('PermissionService uses the terminal loader without fallback', async t => { const calls: string[] = []; - const service = new PermissionService( - { - load: async () => { - calls.push('old'); - return { version: 1 } as never; - }, - loadFromNewTables: async () => { - calls.push('new'); - return { version: 1 } as never; - }, - } as never, - undefined, - undefined, - { - permission: { - readModel: PermissionReadModel.Projection, - fallbackLegacyLoader: true, - }, - } as never - ); + const service = new PermissionService({ + load: async () => { + calls.push('load'); + return { version: 1 } as never; + }, + } as never); service.evaluate = () => ({ version: 1, @@ -200,70 +158,15 @@ test('PermissionService uses new projection loader behind read flag', async t => actions: ['Doc.Read'], }); - t.deepEqual(calls, ['new']); + t.deepEqual(calls, ['load']); }); -test('PermissionService falls back to old loader when new read fails', async t => { - const calls: string[] = []; - const service = new PermissionService( - { - load: async () => { - calls.push('old'); - return { version: 1 } as never; - }, - loadFromNewTables: async () => { - calls.push('new'); - throw new Error('projection unavailable'); - }, - } as never, - undefined, - undefined, - { - permission: { - readModel: PermissionReadModel.Projection, - fallbackLegacyLoader: true, - }, - } as never - ); - service.evaluate = () => - ({ - version: 1, - workspace: { decisions: [] }, - docs: [ - { - docId: 'doc', - effectiveRole: 'reader', - decisions: [], - }, - ], - }) as never; - - await service.docPermissions({ - workspaceId: 'w1', - docId: 'doc', - actions: ['Doc.Read'], - }); - - t.deepEqual(calls, ['new', 'old']); -}); - -test('PermissionService can disable old evaluator fallback', async t => { - const service = new PermissionService( - { - load: async () => ({ version: 1 }) as never, - loadFromNewTables: async () => { - throw new Error('projection unavailable'); - }, - } as never, - undefined, - undefined, - { - permission: { - readModel: PermissionReadModel.Projection, - fallbackLegacyLoader: false, - }, - } as never - ); +test('PermissionService propagates terminal loader failures', async t => { + const service = new PermissionService({ + load: async () => { + throw new Error('permission tables unavailable'); + }, + } as never); await t.throwsAsync( service.docPermissions({ @@ -271,22 +174,12 @@ test('PermissionService can disable old evaluator fallback', async t => { docId: 'doc', actions: ['Doc.Read'], }), - { message: 'projection unavailable' } + { message: 'permission tables unavailable' } ); }); test('PermissionService supports anonymous preview without doc read', async t => { - const service = new PermissionService( - createLoader().loader, - undefined, - undefined, - { - permission: { - readModel: PermissionReadModel.Legacy, - fallbackLegacyLoader: false, - }, - } as never - ); + const service = new PermissionService(createLoader().loader); t.true( await service.canPreviewDoc({ @@ -303,149 +196,49 @@ test('PermissionService supports anonymous preview without doc read', async t => ); }); -test('PermissionContextLoader passes missing runtime state as unknown and stale', async t => { - const { loader } = createLoader({ - workspaceId: 'w1', - known: false, - stale: true, - readonly: false, - readonlyReasons: [], - updatedAt: null, - lastReconciledAt: null, - staleAfter: null, - }); - - const input = await loader.load({ - userId: 'u1', - workspaceId: 'w1', - workspaceActions: ['Workspace.CreateDoc'], - }); - - t.is(input.runtime?.known, false); - t.is(input.runtime?.stale, true); -}); - -test('PermissionContextLoader falls back to quota runtime for missing legacy runtime state', async t => { - const { loader } = createLoader( - { - workspaceId: 'w1', - known: false, - stale: true, - readonly: false, - readonlyReasons: [], - updatedAt: null, - lastReconciledAt: null, - staleAfter: null, - }, - null, - async () => [ - { - known: true, - stale: false, - readonly: true, - readonlyReasons: ['storage_overflow'], - staleAfter: null, - }, - ] - ); - - const input = await loader.load({ - userId: 'u1', - workspaceId: 'w1', - workspaceActions: ['Workspace.CreateDoc'], - }); - - t.true(input.runtime?.known); - t.false(input.runtime?.stale); - t.true(input.runtime?.readonly); - t.is(input.runtime?.readonlyReason, 'storage_overflow'); -}); - -test('PermissionContextLoader drops dirty external and none explicit rows', async t => { - for (const role of [DocRole.None, DocRole.External]) { - const { loader } = createLoader(undefined, role); - const input = await loader.load({ - userId: 'u1', - workspaceId: 'w1', - docs: [{ docId: 'private', actions: ['Doc.Update'] }], - }); - - t.is(input.docs?.[0]?.explicitUserRole, undefined); - } -}); - -test('PermissionContextLoader loads shadow input from new projection tables', async t => { - const queryResults = [ - [{ role: 'admin', state: 'active' }], - [ - { - visibility: 'public', - sharingEnabled: true, - urlPreviewEnabled: true, - memberDefaultDocRole: 'reader', - }, - ], - [ - { - known: true, - stale: false, - readonly: true, - readonlyReasons: ['storage_overflow'], - staleAfter: null, - }, - ], - [ - { - docId: 'doc1', - visibility: 'public', - publicRole: 'external', - memberDefaultRole: null, - urlPreviewEnabled: false, - }, - ], - [{ docId: 'doc1', role: 'manager' }], - ]; +test('PermissionContextLoader reads only terminal permission tables', async t => { const { loader } = createLoader(); - const shadowLoader = new PermissionContextLoader( - (loader as never as { models: never }).models, - { - $queryRaw: async () => queryResults.shift() ?? [], - } as never, - createCls() as never - ); - - const input = await shadowLoader.loadFromNewTables({ + const input = await loader.load({ userId: 'u1', workspaceId: 'w1', workspaceActions: ['Workspace.Read'], - docs: [{ docId: 'doc1', actions: ['Doc.Read'] }], + docs: [ + { docId: 'private', actions: ['Doc.Update'] }, + { docId: 'public', actions: ['Doc.Read'] }, + ], }); - t.is(input.workspace?.role, 'admin'); - t.true(input.runtime?.readonly); - t.is(input.runtime?.readonlyReason, 'storage_overflow'); - t.is(input.workspace?.public, true); + t.is(input.workspace?.role, 'owner'); + t.true(input.runtime?.known); t.is(input.docs?.[0]?.explicitUserRole, 'manager'); - t.is(input.docs?.[0]?.memberDefaultRole, 'reader'); - t.is(input.docs?.[0]?.publicRole, 'external'); + t.is(input.docs?.[0]?.memberDefaultRole, 'manager'); + t.is(input.docs?.[1]?.publicRole, 'external'); }); -test('PermissionContextLoader treats missing effective quota state as unknown and stale', async t => { - const queryResults = [[], [], [], [], []]; - const { loader } = createLoader(); - const shadowLoader = new PermissionContextLoader( - (loader as never as { models: never }).models, - { - $queryRaw: async () => queryResults.shift() ?? [], - workspace: { - findUnique: async () => ({ id: 'w1' }), - }, - } as never, - createCls() as never - ); +test('PermissionContextLoader treats missing quota state as unknown and stale', async t => { + const db = { + $queryRaw: async (strings: TemplateStringsArray) => { + const sql = strings.join(''); + if (sql.includes('effective_workspace_quota_states')) { + return []; + } + if (sql.includes('workspace_access_policies')) { + return [ + { + visibility: 'private', + sharingEnabled: true, + urlPreviewEnabled: false, + memberDefaultDocRole: 'manager', + }, + ]; + } + return []; + }, + workspace: { findUnique: async () => ({ id: 'w1' }) }, + }; + const loader = new PermissionContextLoader(db as never); - const input = await shadowLoader.loadFromNewTables({ - userId: 'u1', + const input = await loader.load({ workspaceId: 'w1', workspaceActions: ['Workspace.CreateDoc'], }); @@ -454,639 +247,28 @@ test('PermissionContextLoader treats missing effective quota state as unknown an t.true(input.runtime?.stale); }); -test('PermissionService refreshes cached runtime after write-action reconcile', async t => { - let runtimeQueries = 0; - const loader = new PermissionContextLoader( - { - workspaceUser: { - get: async () => null, - }, - workspace: { - get: async () => ({ - public: false, - enableSharing: true, - enableUrlPreview: false, - }), - }, - workspaceRuntimeState: { - get: async () => ({ - workspaceId: 'w1', - known: false, - stale: true, - readonly: false, - readonlyReasons: [], - }), - }, - doc: { - findDefaultRoles: async () => [], - }, - docUser: { - findMany: async () => [], - }, - } as never, - { - $queryRaw: async (strings: TemplateStringsArray) => { - const sql = strings.join(''); - if (sql.includes('workspace_members')) { - return [{ role: 'owner', state: 'active' }]; - } - if (sql.includes('workspace_access_policies')) { - return [ - { - visibility: 'private', - sharingEnabled: true, - urlPreviewEnabled: false, - memberDefaultDocRole: 'manager', - }, - ]; - } - if (sql.includes('effective_workspace_quota_states')) { - runtimeQueries += 1; - return runtimeQueries === 1 - ? [] - : [ - { - known: true, - stale: false, - readonly: false, - readonlyReasons: [], - staleAfter: null, - }, - ]; - } - return []; - }, - workspace: { - findUnique: async () => ({ id: 'w1' }), - }, - } as never, - createCls() as never - ); - const service = new PermissionService(loader, undefined, { - getWorkspaceState: async () => ({}), - } as never); - const runtimeKnownValues: Array = []; - service.evaluate = input => { - runtimeKnownValues.push(input.runtime?.known); - return { - version: 1, - workspace: { - decisions: [ - { - action: input.workspaceActions?.[0] ?? 'Workspace.Read', - allowed: input.runtime?.known !== false, - sources: [], - restrictions: [], - }, - ], - }, - docs: [], - }; - }; +test('PermissionContextLoader memoizes quota runtime within a request', async t => { + const { loader, calls } = createLoader(); - t.false( - await service.canWorkspace({ - userId: 'u1', - workspaceId: 'w1', - action: 'Workspace.Read', - }) - ); - t.true( - await service.canWorkspace({ - userId: 'u1', - workspaceId: 'w1', - action: 'Workspace.CreateDoc', - }) - ); - t.deepEqual(runtimeKnownValues, [false, true]); - t.is(runtimeQueries, 2); -}); + await loader.load({ workspaceId: 'w1' }); + await loader.load({ workspaceId: 'w1' }); -test('PermissionService does not reconcile runtime for read-only workspace actions', async t => { - let runtimeQueries = 0; - let reconciles = 0; - const loader = new PermissionContextLoader( - { - workspaceUser: { - get: async () => null, - }, - workspace: { - get: async () => ({ - public: false, - enableSharing: true, - enableUrlPreview: false, - }), - }, - workspaceRuntimeState: { - get: async () => null, - }, - doc: { - findDefaultRoles: async () => [], - }, - docUser: { - findMany: async () => [], - }, - } as never, - { - $queryRaw: async (strings: TemplateStringsArray) => { - const sql = strings.join(''); - if (sql.includes('workspace_members')) { - return [{ role: 'owner', state: 'active' }]; - } - if (sql.includes('workspace_access_policies')) { - return [ - { - visibility: 'private', - sharingEnabled: true, - urlPreviewEnabled: false, - memberDefaultDocRole: 'manager', - }, - ]; - } - if (sql.includes('effective_workspace_quota_states')) { - runtimeQueries += 1; - return []; - } - return []; - }, - workspace: { - findUnique: async () => ({ id: 'w1' }), - }, - } as never, - createCls() as never - ); - const service = new PermissionService(loader, undefined, { - getWorkspaceState: async () => { - reconciles += 1; - return {}; - }, - } as never); - service.evaluate = input => ({ - version: 1, - workspace: { - decisions: [ - { - action: input.workspaceActions?.[0] ?? 'Workspace.Read', - allowed: true, - sources: [], - restrictions: [], - }, - ], - }, - docs: [], - }); - - t.true( - await service.canWorkspace({ - userId: 'u1', - workspaceId: 'w1', - action: 'Workspace.Settings.Read', - }) - ); - t.is(runtimeQueries, 1); - t.is(reconciles, 0); -}); - -test('PermissionService reconciles runtime for non-readonly write actions', async t => { - let runtimeQueries = 0; - let reconciles = 0; - const loader = new PermissionContextLoader( - { - workspaceUser: { - get: async () => null, - }, - workspace: { - get: async () => ({ - public: false, - enableSharing: true, - enableUrlPreview: false, - }), - }, - workspaceRuntimeState: { - get: async () => null, - }, - doc: { - findDefaultRoles: async () => [], - }, - docUser: { - findMany: async () => [], - }, - } as never, - { - $queryRaw: async (strings: TemplateStringsArray) => { - const sql = strings.join(''); - if (sql.includes('workspace_members')) { - return [{ role: 'owner', state: 'active' }]; - } - if (sql.includes('workspace_access_policies')) { - return [ - { - visibility: 'private', - sharingEnabled: true, - urlPreviewEnabled: false, - memberDefaultDocRole: 'manager', - }, - ]; - } - if (sql.includes('effective_workspace_quota_states')) { - runtimeQueries += 1; - return [ - { - known: true, - stale: false, - readonly: false, - readonlyReasons: [], - staleAfter: null, - }, - ]; - } - return []; - }, - workspace: { - findUnique: async () => ({ id: 'w1' }), - }, - } as never, - createCls() as never - ); - const service = new PermissionService(loader, undefined, { - getWorkspaceState: async () => { - reconciles += 1; - return {}; - }, - } as never); - service.evaluate = input => ({ - version: 1, - workspace: { - decisions: [ - { - action: input.workspaceActions?.[0] ?? 'Workspace.Read', - allowed: input.runtime?.known === true, - sources: [], - restrictions: [], - }, - ], - }, - docs: [], - }); - - t.true( - await service.canWorkspace({ - userId: 'u1', - workspaceId: 'w1', - action: 'Workspace.Delete', - }) - ); - t.is(runtimeQueries, 1); - t.is(reconciles, 1); -}); - -for (const projectionLocalCase of [ - { - name: 'does not treat missing projection as local workspace', - workspaceId: 'w1', - workspaceRow: { id: 'w1' }, - expectedLocal: false, - }, - { - name: 'allows local only when workspace is absent', - workspaceId: 'local', - workspaceRow: null, - expectedLocal: true, - }, -] as const) { - test(`PermissionContextLoader ${projectionLocalCase.name}`, async t => { - const queryResults = [[], [], [], [], []]; - const { loader } = createLoader(); - const shadowLoader = new PermissionContextLoader( - (loader as never as { models: never }).models, - { - $queryRaw: async () => queryResults.shift() ?? [], - workspace: { - findUnique: async () => projectionLocalCase.workspaceRow, - }, - } as never, - createCls() as never - ); - - const input = await shadowLoader.loadFromNewTables({ - userId: 'u1', - workspaceId: projectionLocalCase.workspaceId, - allowLocal: true, - }); - - t.is(input.workspace?.local, projectionLocalCase.expectedLocal); - }); -} - -for (const shadowDocCase of [ - { - name: 'reports projection mismatches', - expectedMismatchType: 'rust_rule', - buildEvaluate: () => { - let index = 0; - return () => - ({ - version: 1, - workspace: { - decisions: [], - }, - docs: [ - { - docId: 'doc1', - decisions: [ - { - action: 'Doc.Read', - allowed: index++ === 0, - sources: [], - restrictions: [], - }, - ], - }, - ], - }) as never; - }, - }, - { - name: 'classifies legacy API role mapping mismatches', - expectedMismatchType: 'legacy_api_role_mapping', - buildEvaluate: () => { - let index = 0; - return () => - ({ - version: 1, - workspace: { - decisions: [], - }, - docs: [ - { - docId: 'doc1', - effectiveRole: index++ === 0 ? 'owner' : 'manager', - resourceOwnerRole: null, - decisions: [], - }, - ], - }) as never; - }, - }, - { - name: 'accepts explicit expected delta categories', - expectedDeltaCategory: 'legacy_compat_delta', - expectedMismatchType: 'legacy_compat_delta', - buildEvaluate: () => { - let index = 0; - return () => - ({ - version: 1, - workspace: { - decisions: [], - }, - docs: [ - { - docId: 'doc1', - decisions: [ - { - action: 'Doc.Read', - allowed: index++ === 0, - sources: [], - restrictions: [], - }, - ], - }, - ], - }) as never; - }, - }, -] as const) { - test(`PermissionDiagnosticService ${shadowDocCase.name}`, async t => { - const loader = { - load: async () => ({ version: 1 }) as never, - loadFromNewTables: async () => ({ version: 1 }) as never, - } as never; - const permission = new PermissionService(loader); - permission.evaluate = shadowDocCase.buildEvaluate(); - const service = new PermissionDiagnosticService(loader, permission); - - const result = await service.shadowDocPermissions({ - workspaceId: 'w1', - docs: [{ docId: 'doc1', actions: ['Doc.Read'] }], - expectedDeltaCategory: shadowDocCase.expectedDeltaCategory, - }); - - t.false(result.matched); - t.is(result.mismatchType, shadowDocCase.expectedMismatchType); - }); -} - -test('PermissionDiagnosticService compares SQL predicate shadow doc read results', async t => { - const loader = { - loadFromNewTables: async () => ({ version: 1 }) as never, - } as never; - const permission = new PermissionService(loader); - permission.evaluate = () => - ({ - version: 1, - workspace: { decisions: [] }, - docs: ['ok', 'missing', 'extra'].map(docId => ({ - docId, - decisions: [ - { - action: 'Doc.Read', - allowed: docId !== 'extra', - sources: [], - restrictions: [], - }, - ], - })), - }) as never; - const service = new PermissionDiagnosticService(loader, permission); - - const result = await service.shadowSqlDocRead({ - userId: 'u1', - workspaceId: 'w1', - docs: [{ docId: 'ok' }, { docId: 'missing' }, { docId: 'extra' }], - sqlReadableDocIds: ['ok', 'extra'], - }); - - t.false(result.matched); - t.is(result.mismatchType, 'sql_predicate'); - t.deepEqual(result.missingInSql, ['missing']); - t.deepEqual(result.extraInSql, ['extra']); - t.true(result.predicate.sql.includes('doc_access_policies')); -}); - -test('PermissionDiagnosticService preview shadow flags preview/read coupling', async t => { - const loader = { - load: async () => ({ version: 1 }) as never, - loadFromNewTables: async () => ({ version: 1 }) as never, - } as never; - const permission = new PermissionService(loader); - const service = new PermissionDiagnosticService(loader, permission); - let index = 0; - permission.evaluate = () => - ({ - version: 1, - workspace: { - decisions: [], - }, - docs: [ - { - docId: 'doc1', - decisions: [ - { - action: 'Doc.Preview', - allowed: true, - sources: [], - restrictions: [], - }, - { - action: 'Doc.Read', - allowed: index++ === 1, - sources: [], - restrictions: [], - }, - ], - }, - ], - }) as never; - - const result = await service.shadowPreviewDoc({ - workspaceId: 'w1', - docId: 'doc1', - }); - - t.false(result.matched); - t.is(result.mismatchType, 'preview_read_mapping'); -}); - -test('PermissionDiagnosticService keeps public preview/read shadow as normal match', async t => { - const loader = { - load: async () => ({ version: 1 }) as never, - loadFromNewTables: async () => ({ version: 1 }) as never, - } as never; - const permission = new PermissionService(loader); - const service = new PermissionDiagnosticService(loader, permission); - permission.evaluate = () => - ({ - version: 1, - workspace: { - decisions: [], - }, - docs: [ - { - docId: 'doc1', - decisions: [ - { - action: 'Doc.Preview', - allowed: true, - sources: [], - restrictions: [], - }, - { - action: 'Doc.Read', - allowed: true, - sources: [], - restrictions: [], - }, - ], - }, - ], - }) as never; - - const result = await service.shadowPreviewDoc({ - workspaceId: 'w1', - docId: 'doc1', - }); - - t.true(result.matched); - t.is(result.mismatchType, null); -}); - -test('PermissionDiagnosticService exposes shadow mismatch categories', t => { - t.deepEqual(PERMISSION_SHADOW_MISMATCH_CATEGORIES, [ - 'legacy_compat_delta', - 'projection', - 'rust_rule', - 'loader', - 'sql_predicate', - 'legacy_api_role_mapping', - 'preview_read_mapping', - 'runtime_state', - 'projection_or_loader', - ]); + t.is(calls.runtime, 1); }); test('PermissionService maps native validation errors to internal errors', t => { const service = new PermissionService(createLoader().loader); - - const error = t.throws(() => - service.evaluate({ - version: 2, - } as never) - ); + const error = t.throws(() => service.evaluate({ version: 2 } as never)); t.true(error instanceof InternalServerError); }); -test('PermissionContextLoader memoizes permission context within a request', async t => { - const { loader, calls } = createLoader(); - - await loader.load({ - userId: 'u1', - workspaceId: 'w1', - workspaceActions: ['Workspace.Read'], - docs: [{ docId: 'public', actions: ['Doc.Read'] }], - }); - await loader.load({ - userId: 'u1', - workspaceId: 'w1', - workspaceActions: ['Workspace.Read'], - docs: [{ docId: 'public', actions: ['Doc.Read'] }], - }); - - t.deepEqual(calls, { - workspaceUser: 1, - workspace: 1, - runtime: 1, - docPolicies: 1, - docGrants: 1, - }); -}); - -test('PermissionSqlPredicateBuilder builds legacy-table predicate parameters', t => { - const predicate = - new PermissionSqlPredicateBuilder().docReadableByLegacyTables({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Users.Manage', - docIdColumn: 'docs.id', - }); - - t.true(predicate.sql.includes('workspace_page_user_permissions')); - t.deepEqual(predicate.params.slice(0, 3), ['u1', 'u1', 'w1']); - t.true( - predicate.params - .slice(3) - .filter(Array.isArray) - .every(value => value.every(Number.isInteger)) - ); -}); - test('PermissionSqlPredicateBuilder rejects unsafe raw doc id columns', t => { const builder = new PermissionSqlPredicateBuilder(); t.throws( () => - builder.docReadableByLegacyTables({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Read', - docIdColumn: 'docs.id; DROP TABLE docs' as never, - }), - { message: 'Unsupported doc id column: docs.id; DROP TABLE docs' } - ); - t.throws( - () => - builder.docReadableByNewTables({ + builder.docReadable({ workspaceId: 'w1', userId: 'u1', action: 'Doc.Read', @@ -1096,39 +278,14 @@ test('PermissionSqlPredicateBuilder rejects unsafe raw doc id columns', t => { ); }); -test('PermissionSqlPredicateBuilder drops dirty legacy external explicit grants', t => { - const predicate = - new PermissionSqlPredicateBuilder().docReadableByLegacyTables({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Read', - }); - - const roles = predicate.params[4] as DocRole[]; - t.false(roles.includes(DocRole.External)); - t.true(roles.includes(DocRole.Reader)); -}); - -test('PermissionSqlPredicateBuilder treats legacy workspace external rows as non-members', t => { - const predicate = - new PermissionSqlPredicateBuilder().docReadableByLegacyTables({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Users.Manage', - }); - - const activeMemberRoles = predicate.params[3] as WorkspaceRole[]; - t.false(activeMemberRoles.includes(WorkspaceRole.External)); -}); - -test('PermissionSqlPredicateBuilder caps non-member explicit grants below manager', t => { +test('PermissionSqlPredicateBuilder caps non-member grants below manager', t => { const builder = new PermissionSqlPredicateBuilder(); - const update = builder.docReadableByNewTables({ + const update = builder.docReadable({ workspaceId: 'w1', userId: 'u1', action: 'Doc.Update', }); - const transferOwner = builder.docReadableByNewTables({ + const transferOwner = builder.docReadable({ workspaceId: 'w1', userId: 'u1', action: 'Doc.TransferOwner', @@ -1141,132 +298,8 @@ test('PermissionSqlPredicateBuilder caps non-member explicit grants below manage t.deepEqual(transferOwner.params[4], []); }); -test('PermissionSqlPredicateBuilder suppresses member default when explicit grant exists', t => { - const builder = new PermissionSqlPredicateBuilder(); - const legacy = builder.docReadableByLegacyTablesSql({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Update', - }); - const projection = builder.docReadableByNewTables({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Update', - }); - - t.true(legacy.sql.includes('p.user_id IS NULL')); - t.true(projection.sql.includes('dg.principal_id IS NULL')); -}); - -test('PermissionSqlPredicateBuilder accepts representative doc actions', t => { - const builder = new PermissionSqlPredicateBuilder(); - const actions = [ - 'Doc.Read', - 'Doc.Update', - 'Doc.Duplicate', - 'Doc.Users.Manage', - 'Doc.TransferOwner', - ] as const; - - for (const action of actions) { - const legacy = builder.docReadableByLegacyTables({ - workspaceId: 'w1', - userId: 'u1', - action: action as never, - }); - const projection = builder.docReadableByNewTables({ - workspaceId: 'w1', - userId: 'u1', - action: action as never, - }); - - t.true(legacy.sql.includes('workspace_page_user_permissions')); - t.true(projection.sql.includes('workspace_access_policies')); - } -}); - -test('PermissionService chooses SQL predicate from configured read model and fallback flag', t => { - const { loader } = createLoader(); - const legacy = new PermissionService(loader, undefined, undefined, { - permission: { - readModel: PermissionReadModel.Legacy, - fallbackLegacyLoader: true, - }, - } as never).docReadableSqlPredicate({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Read', - }); - const projection = new PermissionService(loader, undefined, undefined, { - permission: { - readModel: PermissionReadModel.Projection, - fallbackLegacyLoader: false, - }, - } as never).docReadableSqlPredicate({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Read', - }); - const projectionWithFallback = new PermissionService( - loader, - undefined, - undefined, - { - permission: { - readModel: PermissionReadModel.Projection, - fallbackLegacyLoader: true, - }, - } as never - ).docReadableSqlPredicate({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Read', - }); - const fallback = new PermissionService(loader, undefined, undefined, { - permission: { - readModel: PermissionReadModel.Projection, - fallbackLegacyLoader: true, - }, - } as never).fallbackDocReadableSqlPredicate({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Read', - }); - - t.true( - (legacy as unknown as { sql: string }).sql.includes( - 'workspace_user_permissions' - ) - ); - t.false( - (legacy as unknown as { sql: string }).sql.includes( - 'workspace_access_policies' - ) - ); - t.true( - (projection as unknown as { sql: string }).sql.includes( - 'workspace_access_policies' - ) - ); - t.true( - (projectionWithFallback as unknown as { sql: string }).sql.includes( - 'workspace_access_policies' - ) - ); - t.false( - (projectionWithFallback as unknown as { sql: string }).sql.includes( - 'workspace_user_permissions' - ) - ); - t.true( - (fallback as unknown as { sql: string }).sql.includes( - 'workspace_user_permissions' - ) - ); -}); - -test('PermissionSqlPredicateBuilder uses new projection tables for shadow read', t => { - const predicate = new PermissionSqlPredicateBuilder().docReadableByNewTables({ +test('PermissionSqlPredicateBuilder uses terminal permission tables', t => { + const predicate = new PermissionSqlPredicateBuilder().docReadable({ workspaceId: 'w1', userId: 'u1', action: 'Doc.Read', @@ -1277,37 +310,20 @@ test('PermissionSqlPredicateBuilder uses new projection tables for shadow read', t.true(predicate.sql.includes('LEFT JOIN doc_access_policies dap')); t.true(predicate.sql.includes('workspace_members')); t.true(predicate.sql.includes('doc_grants')); - t.true(predicate.sql.includes('dap.doc_id = docs.id')); - t.true( - predicate.sql.includes( - 'COALESCE(dap.member_default_role, wap.member_default_doc_role)' - ) - ); - t.deepEqual(predicate.params.slice(0, 3), ['u1', 'u1', 'w1']); + t.false(predicate.sql.includes('workspace_user_permissions')); + t.false(predicate.sql.includes('workspace_page_user_permissions')); }); -test('PermissionSqlPredicateBuilder emits new-table role parameters as strings', t => { - const builder = new PermissionSqlPredicateBuilder(); - const transferOwner = builder.docReadableByNewTables({ +test('PermissionService always uses the terminal SQL predicate', t => { + const predicate = new PermissionService( + createLoader().loader + ).docReadableSqlPredicate({ workspaceId: 'w1', userId: 'u1', - action: 'Doc.TransferOwner', - }); - const manageUsers = builder.docReadableByNewTables({ - workspaceId: 'w1', - userId: 'u1', - action: 'Doc.Users.Manage', + action: 'Doc.Read', }); + const sql = (predicate as unknown as { sql: string }).sql; - for (const predicate of [transferOwner, manageUsers]) { - t.true( - predicate.params - .slice(3) - .every( - value => - Array.isArray(value) && - value.every(role => typeof role === 'string') - ) - ); - } + t.true(sql.includes('workspace_access_policies')); + t.false(sql.includes('workspace_user_permissions')); }); diff --git a/packages/backend/server/src/core/permission/config.ts b/packages/backend/server/src/core/permission/config.ts deleted file mode 100644 index e0a605e542..0000000000 --- a/packages/backend/server/src/core/permission/config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod'; - -import { defineModuleConfig } from '../../base'; - -export enum PermissionReadModel { - Legacy = 'legacy', - Projection = 'projection', -} - -declare global { - interface AppConfigSchema { - permission: { - readModel: PermissionReadModel; - fallbackLegacyLoader: boolean; - }; - } -} - -defineModuleConfig('permission', { - readModel: { - desc: 'Permission data source for Rust evaluation', - default: PermissionReadModel.Projection, - shape: z.nativeEnum(PermissionReadModel), - env: ['AFFINE_PERMISSION_READ_MODEL', 'string'], - }, - fallbackLegacyLoader: { - desc: 'Fallback from projection loader to legacy loader when projection input loading fails', - default: false, - env: ['AFFINE_PERMISSION_FALLBACK_LEGACY_LOADER', 'boolean'], - }, -}); diff --git a/packages/backend/server/src/core/permission/context-loader.ts b/packages/backend/server/src/core/permission/context-loader.ts index 58e96cec09..5dafdbc28d 100644 --- a/packages/backend/server/src/core/permission/context-loader.ts +++ b/packages/backend/server/src/core/permission/context-loader.ts @@ -2,47 +2,26 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { ClsService } from 'nestjs-cls'; -import { DocRole, Models } from '../../models'; import type { PermissionEvaluationInputV1 } from '../../native'; -import { - toNativeDocRole, - toNativeExplicitDocGrantRole, - toNativeMemberState, - toNativeWorkspaceRole, -} from './context'; import type { DocAction, WorkspaceAction } from './types'; type PermissionRequestCache = { - workspaceMember: Map< - string, - Awaited> - >; - workspacePolicy: Map>>; - workspaceRuntime: Map< - string, - Awaited> - >; - workspaceQuotaRuntime: Map; - docPolicies: Map< - string, - Awaited> - >; - docGrants: Map>>; + workspaceQuotaRuntime: Map; }; -type NewWorkspaceMemberRow = { +type WorkspaceMemberRow = { role: 'owner' | 'admin' | 'member'; state: 'active' | 'suspended' | 'left'; }; -type NewWorkspacePolicyRow = { +type WorkspacePolicyRow = { visibility: 'private' | 'public'; sharingEnabled: boolean; urlPreviewEnabled: boolean; memberDefaultDocRole: 'none' | 'reader' | 'commenter' | 'editor' | 'manager'; }; -type NewDocPolicyRow = { +type DocPolicyRow = { docId: string; visibility: 'private' | 'public'; publicRole: 'external' | null; @@ -56,12 +35,12 @@ type NewDocPolicyRow = { urlPreviewEnabled: boolean; }; -type NewDocGrantRow = { +type DocGrantRow = { docId: string; role: 'owner' | 'manager' | 'editor' | 'commenter' | 'reader'; }; -type NewWorkspaceRuntimeState = { +type WorkspaceRuntimeState = { known: boolean; stale: boolean; readonly: boolean; @@ -73,26 +52,16 @@ const CACHE_KEY = 'permission.context.cache'; function createPermissionRequestCache(): PermissionRequestCache { return { - workspaceMember: new Map(), - workspacePolicy: new Map(), - workspaceRuntime: new Map(), workspaceQuotaRuntime: new Map(), - docPolicies: new Map(), - docGrants: new Map(), }; } export type PermissionWorkspaceAction = WorkspaceAction | 'Workspace.Preview'; export type PermissionDocAction = DocAction | 'Doc.Preview'; -function cacheKey(parts: readonly unknown[]) { - return parts.join('\0'); -} - @Injectable() export class PermissionContextLoader { constructor( - private readonly models: Models, private readonly db: PrismaClient, private readonly cls?: ClsService ) {} @@ -105,94 +74,17 @@ export class PermissionContextLoader { docs?: Array<{ docId: string; actions: PermissionDocAction[] }>; }): Promise { const docs = input.docs ?? []; - const [member, workspace, runtime, docPolicies, docGrants] = + const docIds = docs.map(doc => doc.docId); + const [member, workspacePolicy, runtime, docPolicies, docGrants] = await Promise.all([ input.userId ? this.workspaceMember(input.workspaceId, input.userId) : Promise.resolve(null), this.workspacePolicy(input.workspaceId), this.workspaceRuntime(input.workspaceId), - this.docPolicies( - input.workspaceId, - docs.map(doc => doc.docId) - ), + this.docPolicies(input.workspaceId, docIds), input.userId - ? this.docGrants( - input.workspaceId, - docs.map(doc => doc.docId), - input.userId - ) - : Promise.resolve([]), - ]); - - const docGrantMap = new Map(docGrants.map(grant => [grant.docId, grant])); - const workspaceSharingEnabled = workspace?.enableSharing ?? true; - - return { - version: 1, - legacyCompatMode: true, - subject: { - userId: input.userId, - groupIds: [], - allowLocal: input.allowLocal, - }, - runtime: { - known: runtime.known, - stale: runtime.stale, - readonly: runtime.readonly, - readonlyReason: runtime.readonlyReasons[0], - sharingEnabled: workspaceSharingEnabled, - urlPreviewEnabled: workspace?.enableUrlPreview ?? false, - }, - workspace: { - role: toNativeWorkspaceRole(member?.type), - memberState: toNativeMemberState(member?.status), - public: workspace?.public ?? false, - sharingEnabled: workspaceSharingEnabled, - urlPreviewEnabled: workspace?.enableUrlPreview ?? false, - local: !workspace, - }, - workspaceActions: input.workspaceActions, - docs: docs.map((doc, index) => { - const policy = docPolicies[index]; - const grant = docGrantMap.get(doc.docId); - return { - docId: doc.docId, - actions: doc.actions, - explicitUserRole: toNativeExplicitDocGrantRole(grant?.type), - groupGrants: [], - groupGrantsEnabled: false, - memberDefaultRole: toNativeDocRole( - policy?.workspace ?? DocRole.Manager - ), - publicRole: policy?.external === null ? undefined : 'external', - visibility: policy?.external === null ? 'private' : 'public', - sharingEnabled: workspaceSharingEnabled, - previewEnabled: policy?.external !== null, - }; - }), - }; - } - - async loadFromNewTables(input: { - userId?: string; - workspaceId: string; - allowLocal?: boolean; - workspaceActions?: PermissionWorkspaceAction[]; - docs?: Array<{ docId: string; actions: PermissionDocAction[] }>; - }): Promise { - const docs = input.docs ?? []; - const docIds = docs.map(doc => doc.docId); - const [member, workspacePolicy, runtime, docPolicies, docGrants] = - await Promise.all([ - input.userId - ? this.newWorkspaceMember(input.workspaceId, input.userId) - : Promise.resolve(null), - this.newWorkspacePolicy(input.workspaceId), - this.newWorkspaceRuntime(input.workspaceId), - this.newDocPolicies(input.workspaceId, docIds), - input.userId - ? this.newDocGrants(input.workspaceId, docIds, input.userId) + ? this.docGrants(input.workspaceId, docIds, input.userId) : Promise.resolve([]), ]); const docPolicyMap = new Map( @@ -293,56 +185,16 @@ export class PermissionContextLoader { return promise; } - private workspaceMember(workspaceId: string, userId: string) { - return this.memo( - this.cache.workspaceMember, - cacheKey([workspaceId, userId]), - () => this.models.workspaceUser.get(workspaceId, userId) - ); - } - - private workspacePolicy(workspaceId: string) { - return this.memo(this.cache.workspacePolicy, workspaceId, () => - this.models.workspace.get(workspaceId) - ); - } - - private async workspaceRuntime(workspaceId: string) { - return this.memo(this.cache.workspaceRuntime, workspaceId, () => - this.models.workspaceRuntimeState.get(workspaceId).then(async state => { - if (state.known && !state.stale) { - return state; - } - - const quotaState = await this.newWorkspaceRuntime(workspaceId); - if (!quotaState.known) { - return state; - } - - return { - workspaceId, - known: quotaState.known, - stale: quotaState.stale, - readonly: quotaState.readonly, - readonlyReasons: quotaState.readonlyReasons, - updatedAt: null, - lastReconciledAt: null, - staleAfter: quotaState.staleAfter, - }; - }) - ); - } - invalidateWorkspaceQuotaRuntime(workspaceId: string) { this.cache.workspaceQuotaRuntime.delete(workspaceId); } - private newWorkspaceRuntime(workspaceId: string) { + private workspaceRuntime(workspaceId: string) { return this.memo( this.cache.workspaceQuotaRuntime, workspaceId, async () => { - const rows = await this.db.$queryRaw` + const rows = await this.db.$queryRaw` SELECT known, stale, @@ -374,26 +226,8 @@ export class PermissionContextLoader { ); } - private docPolicies(workspaceId: string, docIds: string[]) { - const uniqueDocIds = [...new Set(docIds)]; - return this.memo( - this.cache.docPolicies, - cacheKey([workspaceId, ...uniqueDocIds]), - () => this.models.doc.findDefaultRoles(workspaceId, uniqueDocIds) - ); - } - - private docGrants(workspaceId: string, docIds: string[], userId: string) { - const uniqueDocIds = [...new Set(docIds)]; - return this.memo( - this.cache.docGrants, - cacheKey([workspaceId, userId, ...uniqueDocIds]), - () => this.models.docUser.findMany(workspaceId, uniqueDocIds, userId) - ); - } - - private async newWorkspaceMember(workspaceId: string, userId: string) { - const rows = await this.db.$queryRaw` + private async workspaceMember(workspaceId: string, userId: string) { + const rows = await this.db.$queryRaw` SELECT role, state FROM workspace_members WHERE workspace_id = ${workspaceId} @@ -404,8 +238,8 @@ export class PermissionContextLoader { return rows[0] ?? null; } - private async newWorkspacePolicy(workspaceId: string) { - const rows = await this.db.$queryRaw` + private async workspacePolicy(workspaceId: string) { + const rows = await this.db.$queryRaw` SELECT visibility, sharing_enabled AS "sharingEnabled", @@ -426,11 +260,11 @@ export class PermissionContextLoader { return !!workspace; } - private async newDocPolicies(workspaceId: string, docIds: string[]) { + private async docPolicies(workspaceId: string, docIds: string[]) { if (docIds.length === 0) { return []; } - return await this.db.$queryRaw` + return await this.db.$queryRaw` SELECT doc_id AS "docId", visibility, @@ -443,7 +277,7 @@ export class PermissionContextLoader { `; } - private async newDocGrants( + private async docGrants( workspaceId: string, docIds: string[], userId: string @@ -451,7 +285,7 @@ export class PermissionContextLoader { if (docIds.length === 0) { return []; } - return await this.db.$queryRaw` + return await this.db.$queryRaw` SELECT doc_id AS "docId", role FROM doc_grants WHERE workspace_id = ${workspaceId} diff --git a/packages/backend/server/src/core/permission/diagnostic.ts b/packages/backend/server/src/core/permission/diagnostic.ts deleted file mode 100644 index 06b81038fe..0000000000 --- a/packages/backend/server/src/core/permission/diagnostic.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; - -import { metrics } from '../../base'; -import type { PermissionEvaluationOutputV1 } from '../../native'; -import { docLegacyBoundary, workspaceLegacyBoundary } from './context'; -import { - PermissionContextLoader, - type PermissionDocAction, - type PermissionWorkspaceAction, -} from './context-loader'; -import { PermissionService } from './service'; -import { PermissionSqlPredicateBuilder } from './sql-predicate'; - -export const PERMISSION_SHADOW_MISMATCH_CATEGORIES = [ - 'legacy_compat_delta', - 'projection', - 'rust_rule', - 'loader', - 'sql_predicate', - 'legacy_api_role_mapping', - 'preview_read_mapping', - 'runtime_state', - 'projection_or_loader', -] as const; - -type PermissionShadowMismatchCategory = - (typeof PERMISSION_SHADOW_MISMATCH_CATEGORIES)[number]; - -@Injectable() -export class PermissionDiagnosticService { - constructor( - private readonly loader: PermissionContextLoader, - private readonly permission: PermissionService, - @Optional() - @Inject(PermissionSqlPredicateBuilder) - private readonly sqlPredicate = new PermissionSqlPredicateBuilder() - ) {} - - async shadowDocPermissions(input: { - userId?: string; - workspaceId: string; - docs: Array<{ docId: string; actions: PermissionDocAction[] }>; - allowLocal?: boolean; - expectedDeltaCategory?: PermissionShadowMismatchCategory; - }) { - const [legacyOutput, newOutput] = await Promise.all([ - this.loader.load(input).then(input => this.permission.evaluate(input)), - this.loader - .loadFromNewTables(input) - .then(input => this.permission.evaluate(input)), - ]); - - const legacy = legacyOutput.docs.map(doc => ({ - docId: doc.docId, - ...docLegacyBoundary(doc), - decisions: doc.decisions, - })); - const current = newOutput.docs.map(doc => ({ - docId: doc.docId, - ...docLegacyBoundary(doc), - decisions: doc.decisions, - })); - const matched = JSON.stringify(legacy) === JSON.stringify(current); - const mismatchType = matched - ? null - : (input.expectedDeltaCategory ?? - this.classifyDocShadowMismatch(legacy, current)); - this.recordShadowMismatch('doc', mismatchType); - - return { - matched, - legacy, - current, - mismatchType, - }; - } - - async shadowWorkspacePermissions(input: { - userId?: string; - workspaceId: string; - actions: PermissionWorkspaceAction[]; - allowLocal?: boolean; - expectedDeltaCategory?: PermissionShadowMismatchCategory; - }) { - const legacyInput = { - userId: input.userId, - workspaceId: input.workspaceId, - workspaceActions: input.actions, - allowLocal: input.allowLocal, - }; - const [legacyOutput, newOutput] = await Promise.all([ - this.loader - .load(legacyInput) - .then(input => this.permission.evaluate(input)), - this.loader - .loadFromNewTables(legacyInput) - .then(input => this.permission.evaluate(input)), - ]); - - const legacy = { - ...workspaceLegacyBoundary(legacyOutput.workspace), - decisions: legacyOutput.workspace.decisions, - }; - const current = { - ...workspaceLegacyBoundary(newOutput.workspace), - decisions: newOutput.workspace.decisions, - }; - const matched = JSON.stringify(legacy) === JSON.stringify(current); - const mismatchType = matched - ? null - : (input.expectedDeltaCategory ?? - this.classifyShadowMismatch(legacyOutput, newOutput)); - this.recordShadowMismatch('workspace', mismatchType); - - return { - matched, - legacy, - current, - mismatchType, - }; - } - - async shadowSqlDocRead(input: { - userId: string; - workspaceId: string; - docs: Array<{ docId: string }>; - sqlReadableDocIds: string[]; - allowLocal?: boolean; - expectedDeltaCategory?: PermissionShadowMismatchCategory; - }) { - const rustOutput = this.permission.evaluate( - await this.loader.loadFromNewTables({ - userId: input.userId, - workspaceId: input.workspaceId, - docs: input.docs.map(doc => ({ - docId: doc.docId, - actions: ['Doc.Read'], - })), - allowLocal: input.allowLocal, - }) - ); - const rustReadable = new Set( - rustOutput.docs - .filter(doc => doc.decisions[0]?.allowed) - .map(doc => doc.docId) - ); - const sqlReadable = new Set(input.sqlReadableDocIds); - const missingInSql = [...rustReadable].filter(id => !sqlReadable.has(id)); - const extraInSql = [...sqlReadable].filter(id => !rustReadable.has(id)); - const mismatchType = - missingInSql.length || extraInSql.length - ? (input.expectedDeltaCategory ?? 'sql_predicate') - : null; - this.recordShadowMismatch('sql_predicate', mismatchType); - - return { - matched: mismatchType === null, - predicate: this.sqlPredicate.docReadableByNewTables({ - workspaceId: input.workspaceId, - userId: input.userId, - action: 'Doc.Read', - }), - rustReadableDocIds: [...rustReadable], - sqlReadableDocIds: [...sqlReadable], - missingInSql, - extraInSql, - mismatchType, - }; - } - - async shadowPreviewDoc(input: { - userId?: string; - workspaceId: string; - docId: string; - allowLocal?: boolean; - }) { - const result = await this.shadowDocPermissions({ - ...input, - docs: [{ docId: input.docId, actions: ['Doc.Preview', 'Doc.Read'] }], - }); - const legacy = result.legacy[0]; - const current = result.current[0]; - const legacyPreviewAllowed = legacy?.decisions.find( - decision => decision.action === 'Doc.Preview' - )?.allowed; - const legacyReadAllowed = legacy?.decisions.find( - decision => decision.action === 'Doc.Read' - )?.allowed; - const previewAllowed = current?.decisions.find( - decision => decision.action === 'Doc.Preview' - )?.allowed; - const readAllowed = current?.decisions.find( - decision => decision.action === 'Doc.Read' - )?.allowed; - const mismatchType = - legacyPreviewAllowed !== previewAllowed || - (previewAllowed && readAllowed && !legacyReadAllowed) - ? 'preview_read_mapping' - : result.mismatchType; - this.recordShadowMismatch('preview', mismatchType); - - return { - ...result, - matched: result.matched && mismatchType === null, - mismatchType, - }; - } - - async shadowPreviewWorkspace(input: { - userId?: string; - workspaceId: string; - allowLocal?: boolean; - }) { - const result = await this.shadowWorkspacePermissions({ - ...input, - actions: ['Workspace.Preview', 'Workspace.Read'], - }); - const legacyPreviewAllowed = result.legacy.decisions.find( - decision => decision.action === 'Workspace.Preview' - )?.allowed; - const legacyReadAllowed = result.legacy.decisions.find( - decision => decision.action === 'Workspace.Read' - )?.allowed; - const previewAllowed = result.current.decisions.find( - decision => decision.action === 'Workspace.Preview' - )?.allowed; - const readAllowed = result.current.decisions.find( - decision => decision.action === 'Workspace.Read' - )?.allowed; - const mismatchType = - legacyPreviewAllowed !== previewAllowed || - (previewAllowed && readAllowed && !legacyReadAllowed) - ? 'preview_read_mapping' - : result.mismatchType; - this.recordShadowMismatch('preview', mismatchType); - - return { - ...result, - matched: result.matched && mismatchType === null, - mismatchType, - }; - } - - private classifyShadowMismatch( - legacyOutput: PermissionEvaluationOutputV1, - newOutput: PermissionEvaluationOutputV1 - ) { - if (JSON.stringify(legacyOutput) === JSON.stringify(newOutput)) { - return null; - } - const legacyRestrictions = - JSON.stringify(legacyOutput).includes('runtime_'); - const newRestrictions = JSON.stringify(newOutput).includes('runtime_'); - if (legacyRestrictions || newRestrictions) { - return 'runtime_state'; - } - if (legacyOutput.docs.length !== newOutput.docs.length) { - return 'loader'; - } - if (JSON.stringify(legacyOutput.docs) !== JSON.stringify(newOutput.docs)) { - return 'rust_rule'; - } - return 'projection'; - } - - private classifyDocShadowMismatch( - legacy: Array< - ReturnType & { decisions: unknown } - >, - current: Array< - ReturnType & { decisions: unknown } - > - ) { - if (JSON.stringify(legacy) === JSON.stringify(current)) { - return null; - } - - const legacyApi = legacy.map(doc => ({ - effectiveRole: doc.effectiveRole, - legacyApiRole: doc.legacyApiRole, - resourceOwnerRole: doc.resourceOwnerRole, - })); - const currentApi = current.map(doc => ({ - effectiveRole: doc.effectiveRole, - legacyApiRole: doc.legacyApiRole, - resourceOwnerRole: doc.resourceOwnerRole, - })); - if (JSON.stringify(legacyApi) !== JSON.stringify(currentApi)) { - return 'legacy_api_role_mapping'; - } - - if ( - JSON.stringify(legacy).includes('runtime_') || - JSON.stringify(current).includes('runtime_') - ) { - return 'runtime_state'; - } - - if (legacy.length !== current.length) { - return 'loader'; - } - - const legacyDecisions = legacy.map(doc => doc.decisions); - const currentDecisions = current.map(doc => doc.decisions); - if (JSON.stringify(legacyDecisions) !== JSON.stringify(currentDecisions)) { - return 'rust_rule'; - } - - return 'projection'; - } - - private recordShadowMismatch( - scope: string, - category: PermissionShadowMismatchCategory | null - ) { - if (!category) { - return; - } - - metrics.permission - .counter('shadow_mismatches', { - description: 'Permission shadow-read mismatch count', - }) - .add(1, { scope, category }); - } -} diff --git a/packages/backend/server/src/core/permission/index.ts b/packages/backend/server/src/core/permission/index.ts index 727ed10090..07f839017b 100644 --- a/packages/backend/server/src/core/permission/index.ts +++ b/packages/backend/server/src/core/permission/index.ts @@ -1,14 +1,10 @@ -import './config'; - import { Module } from '@nestjs/common'; import { QuotaServiceModule } from '../quota/service.module'; import { AccessControllerBuilder } from './builder'; import { PermissionContextLoader } from './context-loader'; -import { PermissionDiagnosticService } from './diagnostic'; import { EventsListener } from './event'; import { WorkspacePolicyService } from './policy'; -import { PermissionProjectionChecker } from './projection-checker'; import { PermissionService } from './service'; import { PermissionSqlPredicateBuilder } from './sql-predicate'; @@ -18,18 +14,14 @@ import { PermissionSqlPredicateBuilder } from './sql-predicate'; AccessControllerBuilder, EventsListener, WorkspacePolicyService, - PermissionProjectionChecker, PermissionSqlPredicateBuilder, PermissionContextLoader, - PermissionDiagnosticService, PermissionService, ], exports: [ AccessControllerBuilder, WorkspacePolicyService, - PermissionProjectionChecker, PermissionSqlPredicateBuilder, - PermissionDiagnosticService, PermissionService, ], }) @@ -37,16 +29,11 @@ export class PermissionModule {} export { AccessControllerBuilder as PermissionAccess } from './builder'; export { PermissionContextLoader } from './context-loader'; -export { - PERMISSION_SHADOW_MISMATCH_CATEGORIES, - PermissionDiagnosticService, -} from './diagnostic'; export { type DotToUnderline, mapPermissionsToGraphqlPermissions, } from './permission-map'; export { WorkspacePolicyService } from './policy'; -export { PermissionProjectionChecker } from './projection-checker'; export { PermissionService } from './service'; export { PermissionSqlPredicateBuilder } from './sql-predicate'; export { diff --git a/packages/backend/server/src/core/permission/policy.ts b/packages/backend/server/src/core/permission/policy.ts index 22be5a6563..13a21ffd22 100644 --- a/packages/backend/server/src/core/permission/policy.ts +++ b/packages/backend/server/src/core/permission/policy.ts @@ -79,7 +79,6 @@ export class WorkspacePolicyService { private async cleanupTeamPlanCanceled(workspaceId: string) { await this.models.workspaceUser.deleteNonAccepted(workspaceId); await this.models.workspaceUser.demoteAcceptedAdmins(workspaceId); - await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1'); } @OnEvent('workspace.members.updated') diff --git a/packages/backend/server/src/core/permission/projection-checker.ts b/packages/backend/server/src/core/permission/projection-checker.ts deleted file mode 100644 index ccb5ef928e..0000000000 --- a/packages/backend/server/src/core/permission/projection-checker.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Injectable, Optional } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; - -import { Models } from '../../models'; -import { - PermissionContextLoader, - type PermissionDocAction, - type PermissionWorkspaceAction, -} from './context-loader'; -import { PermissionService } from './service'; - -type ProjectionDecisionSample = { - category: string; - workspaceId: string; - docId: string | null; - userId: string | null; - workspaceActions: string[] | null; - docActions: string[] | null; -}; - -@Injectable() -export class PermissionProjectionChecker { - constructor( - private readonly db: PrismaClient, - private readonly models: Models, - @Optional() - private readonly loader?: PermissionContextLoader, - @Optional() - private readonly permission?: PermissionService - ) {} - - async checkLegacyProjection() { - const report = - await this.models.permissionProjection.checkLegacyProjection(); - return { - ...report, - oldNewDecisionMismatch: await this.checkOldNewLoaderDecisionMismatch(), - }; - } - - private async checkOldNewLoaderDecisionMismatch() { - const { loader, permission } = this; - if (!loader || !permission) { - return 0; - } - - const samples = await this.db.$queryRaw` - ( - SELECT - 'active_member_doc' AS category, - old_member.workspace_id AS "workspaceId", - old_doc.page_id AS "docId", - old_member.user_id AS "userId", - NULL::text[] AS "workspaceActions", - ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions" - FROM workspace_user_permissions old_member - INNER JOIN workspace_pages old_doc - ON old_doc.workspace_id = old_member.workspace_id - WHERE old_member.status = 'Accepted'::"WorkspaceMemberStatus" - AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL - AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL - ORDER BY md5(old_member.workspace_id || ':' || old_doc.page_id || ':' || old_member.user_id) - LIMIT 80 - ) - UNION ALL - ( - SELECT - 'workspace_invitation' AS category, - old_member.workspace_id AS "workspaceId", - NULL::text AS "docId", - old_member.user_id AS "userId", - ARRAY['Workspace.Read']::text[] AS "workspaceActions", - NULL::text[] AS "docActions" - FROM workspace_user_permissions old_member - WHERE old_member.status <> 'Accepted'::"WorkspaceMemberStatus" - AND affine_permission_workspace_invitation_state(old_member.status) IS NOT NULL - AND affine_permission_legacy_workspace_role(old_member.type) IS NOT NULL - ORDER BY md5(old_member.workspace_id || ':' || old_member.user_id) - LIMIT 40 - ) - UNION ALL - ( - SELECT - 'public_doc_anonymous' AS category, - old_doc.workspace_id AS "workspaceId", - old_doc.page_id AS "docId", - NULL::text AS "userId", - NULL::text[] AS "workspaceActions", - ARRAY['Doc.Read', 'Doc.Preview']::text[] AS "docActions" - FROM workspace_pages old_doc - WHERE old_doc.public - AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL - ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id) - LIMIT 40 - ) - UNION ALL - ( - SELECT - 'workspace_url_preview_private_doc' AS category, - old_doc.workspace_id AS "workspaceId", - old_doc.page_id AS "docId", - NULL::text AS "userId", - NULL::text[] AS "workspaceActions", - ARRAY['Doc.Preview', 'Doc.Read']::text[] AS "docActions" - FROM workspace_pages old_doc - INNER JOIN workspaces old_workspace - ON old_workspace.id = old_doc.workspace_id - WHERE old_workspace.enable_sharing - AND old_workspace.enable_url_preview - AND NOT old_doc.public - AND affine_permission_legacy_default_doc_role(old_doc."defaultRole") IS NOT NULL - ORDER BY md5(old_doc.workspace_id || ':' || old_doc.page_id) - LIMIT 40 - ) - UNION ALL - ( - SELECT - 'explicit_doc_grant' AS category, - old_grant.workspace_id AS "workspaceId", - old_grant.page_id AS "docId", - old_grant.user_id AS "userId", - NULL::text[] AS "workspaceActions", - ARRAY['Doc.Read', 'Doc.Update', 'Doc.Users.Manage', 'Doc.TransferOwner']::text[] AS "docActions" - FROM workspace_page_user_permissions old_grant - WHERE affine_permission_legacy_doc_role(old_grant.type) IS NOT NULL - ORDER BY md5(old_grant.workspace_id || ':' || old_grant.page_id || ':' || old_grant.user_id) - LIMIT 80 - ) - `; - - let mismatches = 0; - for (const sample of samples) { - const input = { - userId: sample.userId ?? undefined, - workspaceId: sample.workspaceId, - workspaceActions: sample.workspaceActions as - | PermissionWorkspaceAction[] - | undefined, - docs: - sample.docId && sample.docActions - ? [ - { - docId: sample.docId, - actions: sample.docActions as PermissionDocAction[], - }, - ] - : undefined, - }; - const [legacy, projection] = await Promise.all([ - loader.load(input).then(input => permission.evaluate(input)), - loader - .loadFromNewTables(input) - .then(input => permission.evaluate(input)), - ]); - if ( - JSON.stringify(legacy.workspace) !== - JSON.stringify(projection.workspace) || - JSON.stringify(legacy.docs) !== JSON.stringify(projection.docs) - ) { - mismatches += 1; - } - } - - return mismatches; - } -} diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts index 04d5de3ffa..3ebc95755f 100644 --- a/packages/backend/server/src/core/permission/service.ts +++ b/packages/backend/server/src/core/permission/service.ts @@ -2,10 +2,8 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { - Config, DocActionDenied, InternalServerError, - metrics, SpaceAccessDenied, } from '../../base'; import { @@ -13,7 +11,6 @@ import { type PermissionEvaluationInputV1, type PermissionEvaluationOutputV1, } from '../../native'; -import { PermissionReadModel } from './config'; import { docLegacyBoundary, workspaceLegacyBoundary } from './context'; import { PermissionContextLoader, @@ -65,42 +62,16 @@ export class PermissionService { @Inject(PermissionSqlPredicateBuilder) private readonly sqlPredicate = new PermissionSqlPredicateBuilder(), @Optional() - private readonly workspacePolicy?: WorkspacePolicyService, - @Optional() - private readonly config?: Config + private readonly workspacePolicy?: WorkspacePolicyService ) {} - readModel() { - return this.config?.permission.readModel ?? PermissionReadModel.Projection; - } - docReadableSqlPredicate(input: { userId: string; workspaceId: string; action: DocAction; docIdColumn?: Prisma.Sql; }) { - if (this.readModel() === PermissionReadModel.Projection) { - return this.sqlPredicate.docReadableByNewTablesSql(input); - } - - return this.sqlPredicate.docReadableByLegacyTablesSql(input); - } - - fallbackDocReadableSqlPredicate(input: { - userId: string; - workspaceId: string; - action: DocAction; - docIdColumn?: Prisma.Sql; - }) { - if ( - this.readModel() === PermissionReadModel.Projection && - (this.config?.permission.fallbackLegacyLoader ?? false) - ) { - return this.sqlPredicate.docReadableByLegacyTablesSql(input); - } - - return null; + return this.sqlPredicate.docReadableSql(input); } evaluate(input: PermissionEvaluationInputV1) { @@ -264,39 +235,28 @@ export class PermissionService { private async evaluateLoaded( input: Parameters[0] ) { - if (this.readModel() === PermissionReadModel.Projection) { - try { - if ( - this.needsFreshRuntimeState(input) && - (await this.loader.workspaceExists(input.workspaceId)) - ) { - await this.workspacePolicy?.getWorkspaceState(input.workspaceId); - this.loader.invalidateWorkspaceQuotaRuntime(input.workspaceId); - } - return this.evaluate(await this.loader.loadFromNewTables(input)); - } catch (error) { - if ( - input.allowLocal && - error instanceof Error && - error.message === 'Workspace owner not found' - ) { - const loaded = await this.loader.loadFromNewTables(input); - if (loaded.workspace?.local) { - return this.evaluate(loaded); - } - } - if (!(this.config?.permission.fallbackLegacyLoader ?? false)) { - throw error; - } - metrics.permission - .counter('projection_loader_fallbacks', { - description: 'Permission projection loader fallback count', - }) - .add(1); + try { + if ( + this.needsFreshRuntimeState(input) && + (await this.loader.workspaceExists(input.workspaceId)) + ) { + await this.workspacePolicy?.getWorkspaceState(input.workspaceId); + this.loader.invalidateWorkspaceQuotaRuntime(input.workspaceId); } + return this.evaluate(await this.loader.load(input)); + } catch (error) { + if ( + input.allowLocal && + error instanceof Error && + error.message === 'Workspace owner not found' + ) { + const loaded = await this.loader.load(input); + if (loaded.workspace?.local) { + return this.evaluate(loaded); + } + } + throw error; } - - return this.evaluate(await this.loader.load(input)); } private needsFreshRuntimeState( diff --git a/packages/backend/server/src/core/permission/sql-predicate.ts b/packages/backend/server/src/core/permission/sql-predicate.ts index 166b3066ba..c8a9b80826 100644 --- a/packages/backend/server/src/core/permission/sql-predicate.ts +++ b/packages/backend/server/src/core/permission/sql-predicate.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { permissionActionRoleMatrixV1 } from '../../native'; -import { type DocAction, DocRole, WorkspaceRole } from './types'; +import type { DocAction } from './types'; export type PermissionSqlPredicate = { sql: string; @@ -18,21 +18,6 @@ export class PermissionSqlPredicateBuilder { workspace?: { roles?: Record }; }; - private readonly legacyDocRoleValues = new Map([ - ['external', DocRole.External], - ['reader', DocRole.Reader], - ['commenter', DocRole.Commenter], - ['editor', DocRole.Editor], - ['manager', DocRole.Manager], - ['owner', DocRole.Owner], - ]); - - private readonly legacyWorkspaceRoleValues = new Map([ - ['member', WorkspaceRole.Collaborator], - ['admin', WorkspaceRole.Admin], - ['owner', WorkspaceRole.Owner], - ]); - private docRolesForAction(action: DocAction) { return Object.entries(this.matrix.doc?.roles ?? {}) .filter(([, actions]) => actions.includes(action)) @@ -60,12 +45,6 @@ export class PermissionSqlPredicateBuilder { return [...roles]; } - private legacyNonMemberDocGrantRolesForAction(action: DocAction) { - return this.nonMemberDocGrantRolesForAction(action) - .map(role => this.legacyDocRoleValues.get(role)) - .filter(role => role !== undefined); - } - private rawDocIdColumn(column: RawDocIdColumn = 'doc_id') { switch (column) { case 'doc_id': @@ -76,144 +55,7 @@ export class PermissionSqlPredicateBuilder { } } - docReadableByLegacyTables(input: { - workspaceId: string; - userId: string; - action: DocAction; - docIdColumn?: RawDocIdColumn; - }): PermissionSqlPredicate { - const roles = this.docRolesForAction(input.action) - .map(role => this.legacyDocRoleValues.get(role)) - .filter(role => role !== undefined); - const grantRoles = roles.filter(role => role !== DocRole.External); - const nonMemberGrantRoles = this.legacyNonMemberDocGrantRolesForAction( - input.action - ); - const legacyActiveMemberRoles = [ - WorkspaceRole.Collaborator, - WorkspaceRole.Admin, - WorkspaceRole.Owner, - ]; - const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction( - input.action - ) - .map(role => this.legacyWorkspaceRoleValues.get(role)) - .filter(role => role !== undefined); - const docIdColumn = this.rawDocIdColumn(input.docIdColumn); - - return { - sql: [ - `EXISTS (SELECT 1 FROM workspaces w`, - `LEFT JOIN workspace_pages wp ON wp.workspace_id = w.id`, - `AND wp.page_id = ${docIdColumn}`, - `LEFT JOIN workspace_user_permissions wup ON wup.workspace_id = w.id`, - `AND wup.user_id = ? AND wup.status = 'Accepted'`, - `LEFT JOIN workspace_page_user_permissions p ON p.workspace_id = w.id`, - `AND p.user_id = ? AND p.page_id = ${docIdColumn}`, - `WHERE w.id = ? AND (`, - `(wup.type = ANY(?::smallint[]) AND p.type = ANY(?::smallint[]))`, - `OR ((wup.id IS NULL OR wup.type <> ALL(?::smallint[])) AND w.enable_sharing AND p.type = ANY(?::smallint[]))`, - `OR wup.type = ANY(?::smallint[])`, - `OR (wup.type = ANY(?::smallint[]) AND (p.user_id IS NULL OR p.type IN (?, ?))`, - `AND COALESCE(wp."defaultRole", 30) = ANY(?::smallint[]))`, - `OR (w.enable_sharing AND wp.public AND ? = ANY(?::smallint[]))`, - `))`, - ].join(' '), - params: [ - input.userId, - input.userId, - input.workspaceId, - legacyActiveMemberRoles, - grantRoles, - legacyActiveMemberRoles, - nonMemberGrantRoles, - inheritedWorkspaceRoles, - legacyActiveMemberRoles, - DocRole.None, - DocRole.External, - grantRoles, - DocRole.External, - roles, - ], - }; - } - - docReadableByLegacyTablesSql(input: { - workspaceId: string; - userId: string; - action: DocAction; - docIdColumn?: Prisma.Sql; - }): Prisma.Sql { - const docRoles = this.docRolesForAction(input.action); - const legacyDocRoles = docRoles - .map(role => this.legacyDocRoleValues.get(role)) - .filter(role => role !== undefined); - const legacyGrantRoles = legacyDocRoles.filter( - role => role !== DocRole.External - ); - const legacyNonMemberGrantRoles = - this.legacyNonMemberDocGrantRolesForAction(input.action); - const inheritedWorkspaceRoles = this.inheritedWorkspaceRolesForDocAction( - input.action - ) - .map(role => this.legacyWorkspaceRoleValues.get(role)) - .filter(role => role !== undefined); - const legacyActiveMemberRoles = [ - WorkspaceRole.Collaborator, - WorkspaceRole.Admin, - WorkspaceRole.Owner, - ]; - const docIdColumn = input.docIdColumn ?? Prisma.raw('doc_id'); - - return Prisma.sql` - EXISTS ( - SELECT 1 - FROM workspaces w - LEFT JOIN workspace_pages wp - ON wp.workspace_id = w.id - AND wp.page_id = ${docIdColumn} - LEFT JOIN workspace_user_permissions wup - ON wup.workspace_id = w.id - AND wup.user_id = ${input.userId} - AND wup.status = 'Accepted'::"WorkspaceMemberStatus" - LEFT JOIN workspace_page_user_permissions p - ON p.workspace_id = w.id - AND p.page_id = ${docIdColumn} - AND p.user_id = ${input.userId} - WHERE w.id = ${input.workspaceId} - AND ( - ( - wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`}) - AND p.type = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`}) - ) - OR ( - ( - wup.id IS NULL - OR wup.type <> ALL(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`}) - ) - AND w.enable_sharing - AND p.type = ANY(${Prisma.sql`${legacyNonMemberGrantRoles}::smallint[]`}) - ) - OR wup.type = ANY(${Prisma.sql`${inheritedWorkspaceRoles}::smallint[]`}) - OR ( - wup.type = ANY(${Prisma.sql`${legacyActiveMemberRoles}::smallint[]`}) - AND ( - p.user_id IS NULL - OR p.type IN (${DocRole.None}, ${DocRole.External}) - ) - AND COALESCE(wp."defaultRole", 30) = ANY(${Prisma.sql`${legacyGrantRoles}::smallint[]`}) - ) - OR ( - w.enable_sharing - AND wp.public - AND 0 = ANY(${Prisma.sql`${legacyDocRoles}::smallint[]`}) - ) - ) - ) - `; - } - - docReadableByNewTables(input: { + docReadable(input: { workspaceId: string; userId?: string; action: DocAction; @@ -260,7 +102,7 @@ export class PermissionSqlPredicateBuilder { }; } - docReadableByNewTablesSql(input: { + docReadableSql(input: { workspaceId: string; userId?: string; action: DocAction; diff --git a/packages/backend/server/src/core/quota/__tests__/state.spec.ts b/packages/backend/server/src/core/quota/__tests__/state.spec.ts index bd647383e2..97037d4f3c 100644 --- a/packages/backend/server/src/core/quota/__tests__/state.spec.ts +++ b/packages/backend/server/src/core/quota/__tests__/state.spec.ts @@ -60,57 +60,7 @@ test.after.always(async t => { await t.context.module.close(); }); -test('quota service ignores dirty legacy commercial features', async t => { - const { owner, workspace } = await createWorkspace(t); - await t.context.state.reconcileUserQuotaState(owner.id); - await t.context.state.reconcileWorkspaceQuotaState(workspace.id); - - await t.context.models.userFeature.add( - owner.id, - 'pro_plan_v1', - 'dirty legacy feature' - ); - await t.context.models.userFeature.add( - owner.id, - 'unlimited_copilot', - 'dirty legacy feature' - ); - await t.context.models.workspaceFeature.add( - workspace.id, - 'team_plan_v1', - 'dirty legacy feature', - { - memberLimit: 100, - } - ); - - const userQuota = await t.context.quota.getUserQuota(owner.id); - const workspaceSeats = await t.context.quota.getWorkspaceSeatQuota( - workspace.id - ); - - t.is(userQuota.name, 'Free'); - t.is(userQuota.copilotActionLimit, 10); - t.is(workspaceSeats.memberLimit, 3); -}); - -test('workspace quota state ignores dirty legacy readonly feature', async t => { - const { workspace } = await createWorkspace(t); - await t.context.models.workspaceFeature.add( - workspace.id, - 'quota_exceeded_readonly_workspace_v1', - 'dirty legacy feature' - ); - - const state = await t.context.state.reconcileWorkspaceQuotaState( - workspace.id - ); - - t.false(state.readonly); - t.deepEqual(state.readonlyReasons, []); -}); - -test('workspace quota state ignores dirty legacy permission rows', async t => { +test('workspace quota state uses current member rows', async t => { const { workspace } = await createWorkspace(t); const member = await t.context.models.user.create({ email: `${randomUUID()}@affine.pro`, @@ -123,16 +73,11 @@ test('workspace quota state ignores dirty legacy permission rows', async t => { status: WorkspaceMemberStatus.Accepted, } ); - await t.context.db.$transaction(async tx => { - await tx.$executeRaw` - SELECT set_config('affine.permission_projection.enabled', 'off', true) - `; - await tx.workspaceMember.deleteMany({ - where: { - workspaceId: workspace.id, - userId: member.id, - }, - }); + await t.context.db.workspaceMember.deleteMany({ + where: { + workspaceId: workspace.id, + userId: member.id, + }, }); const state = await t.context.state.reconcileWorkspaceQuotaState( @@ -194,16 +139,11 @@ test('quota state reconcile does not publish unchanged snapshots', async t => { test('workspace quota state requires owner from new permission table', async t => { const { owner, workspace } = await createWorkspace(t); - await t.context.db.$transaction(async tx => { - await tx.$executeRaw` - SELECT set_config('affine.permission_projection.enabled', 'off', true) - `; - await tx.workspaceMember.deleteMany({ - where: { - workspaceId: workspace.id, - userId: owner.id, - }, - }); + await t.context.db.workspaceMember.deleteMany({ + where: { + workspaceId: workspace.id, + userId: owner.id, + }, }); await t.throwsAsync( @@ -217,16 +157,11 @@ test('user quota state aggregates owned storage from new permission table only', await addBlob(t, workspace, 'blob', ONE_GB); const first = await t.context.state.reconcileUserQuotaState(owner.id); - await t.context.db.$transaction(async tx => { - await tx.$executeRaw` - SELECT set_config('affine.permission_projection.enabled', 'off', true) - `; - await tx.workspaceMember.deleteMany({ - where: { - workspaceId: workspace.id, - userId: owner.id, - }, - }); + await t.context.db.workspaceMember.deleteMany({ + where: { + workspaceId: workspace.id, + userId: owner.id, + }, }); const second = await t.context.state.reconcileUserQuotaState(owner.id); @@ -315,19 +250,10 @@ test('ai entitlement is a capability overlay on free quota', async t => { t.is(quota.copilotActionLimit, undefined); }); -test('workspace team status ignores dirty legacy feature', async t => { +test('workspace team status follows entitlement', async t => { const { workspace } = await createWorkspace(t); await t.context.state.reconcileWorkspaceQuotaState(workspace.id); - await t.context.models.workspaceFeature.add( - workspace.id, - 'team_plan_v1', - 'dirty legacy feature', - { - memberLimit: 100, - } - ); - t.false(await t.context.models.workspace.isTeamWorkspace(workspace.id)); await t.context.entitlement.upsertFromCloudSubscription({ diff --git a/packages/backend/server/src/core/user/realtime.ts b/packages/backend/server/src/core/user/realtime.ts index 58d7dfc7fe..f09fb4c345 100644 --- a/packages/backend/server/src/core/user/realtime.ts +++ b/packages/backend/server/src/core/user/realtime.ts @@ -117,7 +117,13 @@ export class UserRealtimeProvider emailVerified: current.emailVerified, hasPassword: current.hasPassword, avatarUrl: current.avatarUrl ?? null, - features: (await this.models.userFeature.list(userId)) + features: ( + await this.models.userFeature.list( + userId, + undefined, + Array.from(this.availableUserFeatures()) + ) + ) .filter(feature => this.availableUserFeatures().has(feature)) .map(feature => this.serializeFeature(feature)), }; diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 9de611a15e..48949cf6d2 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -25,12 +25,7 @@ import { SafeIntResolver } from 'graphql-scalars'; import { PaginationInput, URLHelper } from '../../../base'; import { PageInfo } from '../../../base/graphql/pagination'; -import { - Feature, - Models, - WorkspaceFeatureName, - WorkspaceMemberStatus, -} from '../../../models'; +import { Models, WorkspaceMemberStatus } from '../../../models'; import { Admin } from '../../common'; import { WorkspaceUserType } from '../../user'; import { TimeWindow } from './analytics-types'; @@ -118,9 +113,6 @@ class ListWorkspaceInput { @Field(() => String, { nullable: true }) keyword?: string; - @Field(() => [Feature], { nullable: true }) - features?: WorkspaceFeatureName[]; - @Field(() => AdminWorkspaceSort, { nullable: true }) orderBy?: AdminWorkspaceSort; @@ -397,9 +389,6 @@ export class AdminWorkspace { @Field() enableDocEmbedding!: boolean; - @Field(() => [Feature]) - features!: WorkspaceFeatureName[]; - @Field(() => WorkspaceUserType, { nullable: true }) owner?: WorkspaceUserType | null; @@ -469,7 +458,6 @@ export class AdminWorkspaceResolver { first: filter.first, skip: filter.skip, keyword: filter.keyword, - features: filter.features, order: this.mapSort(filter.orderBy), flags: { public: filter.public ?? undefined, @@ -491,7 +479,6 @@ export class AdminWorkspaceResolver { this.assertCloudOnly(); const total = await this.models.workspace.adminCountWorkspaces({ keyword: filter.keyword, - features: filter.features, flags: { public: filter.public ?? undefined, enableAi: filter.enableAi ?? undefined, diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 764ecc5aa3..a51edd845e 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -415,24 +415,11 @@ export class WorkspaceDocResolver { action: 'Doc.Read', docIdColumn: Prisma.raw('"workspace_pages"."page_id"'), }); - const fallbackPredicate = this.permission.fallbackDocReadableSqlPredicate({ - userId: me.id, - workspaceId: workspace.id, - action: 'Doc.Read', - docIdColumn: Prisma.raw('"workspace_pages"."page_id"'), - }); - const [count, rows] = await this.models.doc - .paginateDocInfoByUpdatedAt(workspace.id, pagination, predicate) - .catch(error => { - if (!fallbackPredicate) { - throw error; - } - return this.models.doc.paginateDocInfoByUpdatedAt( - workspace.id, - pagination, - fallbackPredicate - ); - }); + const [count, rows] = await this.models.doc.paginateDocInfoByUpdatedAt( + workspace.id, + pagination, + predicate + ); return paginate(rows, 'updatedAt', pagination, count); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index ad23e11a53..3ab1afb64d 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -9,11 +9,7 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { - WorkspaceMemberSource, - WorkspaceMemberStatus, - WorkspaceUserRole, -} from '@prisma/client'; +import { WorkspaceMemberSource, WorkspaceMemberStatus } from '@prisma/client'; import { nanoid } from 'nanoid'; import { @@ -41,7 +37,7 @@ import { UserNotFound, } from '../../../base'; import type { GraphqlContext } from '../../../base/graphql'; -import { Models } from '../../../models'; +import { Models, type WorkspaceUserCompat } from '../../../models'; import { CurrentUser, Public } from '../../auth'; import { BackendRuntimeProvider } from '../../backend-runtime'; import { containsUrlOrDomain } from '../../content-policy'; @@ -788,7 +784,7 @@ export class WorkspaceMemberResolver { return true; } - private async acceptInvitationByEmail(role: WorkspaceUserRole) { + private async acceptInvitationByEmail(role: WorkspaceUserCompat) { await this.assertWorkspaceAcceptsMemberChange(role.workspaceId); const hasSeat = await this.quota.tryCheckSeat(role.workspaceId, true); diff --git a/packages/backend/server/src/core/workspaces/stats.job.ts b/packages/backend/server/src/core/workspaces/stats.job.ts index f9bbd659b0..d732b95c3e 100644 --- a/packages/backend/server/src/core/workspaces/stats.job.ts +++ b/packages/backend/server/src/core/workspaces/stats.job.ts @@ -283,15 +283,10 @@ export class WorkspaceStatsJob { ), public_page_stats AS ( SELECT workspace_id, COUNT(*) AS public_page_count - FROM workspace_pages - WHERE public = TRUE AND workspace_id IN (SELECT workspace_id FROM targets) - GROUP BY workspace_id - ), - feature_stats AS ( - SELECT workspace_id, - ARRAY_AGG(DISTINCT name ORDER BY name) FILTER (WHERE activated) AS features - FROM workspace_features - WHERE workspace_id IN (SELECT workspace_id FROM targets) + FROM doc_access_policies + WHERE visibility = 'public' + AND public_role = 'external' + AND workspace_id IN (SELECT workspace_id FROM targets) GROUP BY workspace_id ), aggregated AS ( @@ -301,14 +296,12 @@ export class WorkspaceStatsJob { COALESCE(bs.blob_count, 0) AS blob_count, COALESCE(bs.blob_size, 0) AS blob_size, COALESCE(ms.member_count, 0) AS member_count, - COALESCE(pp.public_page_count, 0) AS public_page_count, - COALESCE(fs.features, ARRAY[]::text[]) AS features + COALESCE(pp.public_page_count, 0) AS public_page_count FROM targets t LEFT JOIN snapshot_stats ss ON ss.workspace_id = t.workspace_id LEFT JOIN blob_stats bs ON bs.workspace_id = t.workspace_id LEFT JOIN member_stats ms ON ms.workspace_id = t.workspace_id LEFT JOIN public_page_stats pp ON pp.workspace_id = t.workspace_id - LEFT JOIN feature_stats fs ON fs.workspace_id = t.workspace_id ) INSERT INTO workspace_admin_stats ( workspace_id, @@ -318,7 +311,6 @@ export class WorkspaceStatsJob { blob_size, member_count, public_page_count, - features, updated_at ) SELECT @@ -329,7 +321,6 @@ export class WorkspaceStatsJob { blob_size, member_count, public_page_count, - features, NOW() FROM aggregated ON CONFLICT (workspace_id) DO UPDATE SET @@ -339,7 +330,6 @@ export class WorkspaceStatsJob { blob_size = EXCLUDED.blob_size, member_count = EXCLUDED.member_count, public_page_count = EXCLUDED.public_page_count, - features = EXCLUDED.features, updated_at = EXCLUDED.updated_at `; } diff --git a/packages/backend/server/src/data/__tests__/migrations.spec.ts b/packages/backend/server/src/data/__tests__/migrations.spec.ts index cc5d1bd5b4..d491e9d74a 100644 --- a/packages/backend/server/src/data/__tests__/migrations.spec.ts +++ b/packages/backend/server/src/data/__tests__/migrations.spec.ts @@ -30,13 +30,13 @@ test.after.always(async t => { test('permission backfill repairs ownerless workspaces before runtime state projection', async t => { const emptyWorkspace = await t.context.db.workspace.create({ - data: { public: false }, + data: { accessPolicy: { create: {} } }, }); const member = await t.context.models.user.create({ email: 'member@affine.pro', }); const memberWorkspace = await t.context.db.workspace.create({ - data: { public: false }, + data: { accessPolicy: { create: {} } }, }); await t.context.db.workspaceMember.create({ data: { @@ -80,13 +80,4 @@ test('permission backfill repairs ownerless workspaces before runtime state proj }), { role: 'owner' } ); - t.like( - await t.context.db.workspaceUserRole.findFirstOrThrow({ - where: { - workspaceId: memberWorkspace.id, - userId: member.id, - }, - }), - { type: 99 } - ); }); diff --git a/packages/backend/server/src/data/migrations/1765500000000-backfill-permission-projection.ts b/packages/backend/server/src/data/migrations/1765500000000-backfill-permission-projection.ts index 7a2764a310..d2b78ee77d 100644 --- a/packages/backend/server/src/data/migrations/1765500000000-backfill-permission-projection.ts +++ b/packages/backend/server/src/data/migrations/1765500000000-backfill-permission-projection.ts @@ -1,12 +1,8 @@ import { ModuleRef } from '@nestjs/core'; import { PrismaClient } from '@prisma/client'; -import { Models } from '../../models'; - export class BackfillPermissionProjection1765500000000 { - static async up(db: PrismaClient, ref: ModuleRef) { - const models = ref.get(Models, { strict: false }); - await models.permissionProjection.backfillLegacyProjection(); + static async up(db: PrismaClient, _ref: ModuleRef) { await ensureWorkspaceAdminStatsDirtyTriggerGuard(db); await repairOwnerlessWorkspaces(db); await backfillUnknownQuotaRuntimeStates(db); @@ -134,29 +130,6 @@ async function backfillUnknownQuotaRuntimeStates(db: PrismaClient) { stale = true, updated_at = now() `; - - await db.$executeRaw` - INSERT INTO workspace_runtime_states ( - workspace_id, - known, - readonly, - readonly_reasons, - last_reconciled_at, - stale_after, - updated_at - ) - SELECT - workspace_id, - false, - false, - ARRAY[]::text[], - NULL, - NULL, - now() - FROM effective_workspace_quota_states - ON CONFLICT (workspace_id) - DO NOTHING - `; } async function repairOwnerlessWorkspaces(db: PrismaClient) { diff --git a/packages/backend/server/src/data/migrations/1765600000000-backfill-entitlement-projection.ts b/packages/backend/server/src/data/migrations/1765600000000-backfill-entitlement-projection.ts index 7c5e83bc84..f5d46c5085 100644 --- a/packages/backend/server/src/data/migrations/1765600000000-backfill-entitlement-projection.ts +++ b/packages/backend/server/src/data/migrations/1765600000000-backfill-entitlement-projection.ts @@ -1,15 +1,8 @@ import { ModuleRef } from '@nestjs/core'; import { PrismaClient } from '@prisma/client'; -import { LegacyEntitlementProjectionService } from '../../core/entitlement'; - export class BackfillEntitlementProjection1765600000000 { - static async up(_db: PrismaClient, ref: ModuleRef) { - const projection = ref.get(LegacyEntitlementProjectionService, { - strict: false, - }); - await projection.shadowBackfillEntitlementsAndQuotaStates(); - } + static async up(_db: PrismaClient, _ref: ModuleRef) {} static async down(_db: PrismaClient) {} } diff --git a/packages/backend/server/src/models/base.ts b/packages/backend/server/src/models/base.ts index 4e226474e0..832be3c320 100644 --- a/packages/backend/server/src/models/base.ts +++ b/packages/backend/server/src/models/base.ts @@ -19,13 +19,4 @@ export class BaseModel { // See https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional#using-the-injecttransaction-decorator return this.txHost.tx; } - - protected async withPermissionProjectionMetric(operation: Promise) { - try { - return await operation; - } catch (err) { - this.models.permissionProjection.recordTriggerErrorMetric(err); - throw err; - } - } } diff --git a/packages/backend/server/src/models/common/feature.ts b/packages/backend/server/src/models/common/feature.ts index 2a9cb61d17..6c96c1264b 100644 --- a/packages/backend/server/src/models/common/feature.ts +++ b/packages/backend/server/src/models/common/feature.ts @@ -1,39 +1,18 @@ import { z } from 'zod'; -import { OneDay, OneGB, OneMB } from '../../base'; +export interface UserQuota { + name: string; + blobLimit: number; + businessBlobLimit?: number; + storageQuota: number; + historyPeriod: number; + memberLimit: number; + copilotActionLimit?: number; +} -const UserPlanQuotaConfig = z.object({ - // quota name - name: z.string(), - // single blob limit - blobLimit: z.number(), - // server limit will larger then client to handle a edge case: - // when a user downgrades from pro to free, he can still continue - // to upload previously added files that exceed the free limit - // NOTE: this is a product decision, may change in future - businessBlobLimit: z.number().optional(), - // total blob limit - storageQuota: z.number(), - // history period of validity - historyPeriod: z.number(), - // member limit - memberLimit: z.number(), - // copilot action limit - copilotActionLimit: z.number().optional(), -}); - -export type UserQuota = z.infer; - -const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({ - // seat quota - seatQuota: z.number(), -}).omit({ - copilotActionLimit: true, -}); - -export type WorkspaceQuota = z.infer; - -const EMPTY_CONFIG = z.object({}); +export interface WorkspaceQuota extends UserQuota { + seatQuota: number; +} export enum FeatureType { Feature, @@ -41,120 +20,25 @@ export enum FeatureType { } export enum Feature { - // user Admin = 'administrator', - UnlimitedCopilot = 'unlimited_copilot', - FreePlan = 'free_plan_v1', - ProPlan = 'pro_plan_v1', - LifetimeProPlan = 'lifetime_pro_plan_v1', - - // workspace - UnlimitedWorkspace = 'unlimited_workspace', - TeamPlan = 'team_plan_v1', - QuotaExceededReadonlyWorkspace = 'quota_exceeded_readonly_workspace_v1', } -// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`? export const FeaturesShapes = { - unlimited_workspace: EMPTY_CONFIG, - unlimited_copilot: EMPTY_CONFIG, - administrator: EMPTY_CONFIG, - free_plan_v1: UserPlanQuotaConfig, - pro_plan_v1: UserPlanQuotaConfig, - lifetime_pro_plan_v1: UserPlanQuotaConfig, - team_plan_v1: WorkspaceQuotaConfig, - quota_exceeded_readonly_workspace_v1: EMPTY_CONFIG, -} satisfies Record>; + administrator: z.object({}), +}; -export type UserFeatureName = keyof Pick< - typeof FeaturesShapes, - | 'unlimited_copilot' - | 'administrator' - | 'free_plan_v1' - | 'pro_plan_v1' - | 'lifetime_pro_plan_v1' ->; -export type WorkspaceFeatureName = keyof Pick< - typeof FeaturesShapes, - | 'unlimited_workspace' - | 'team_plan_v1' - | 'quota_exceeded_readonly_workspace_v1' ->; - -export type FeatureName = UserFeatureName | WorkspaceFeatureName; +export type UserFeatureName = 'administrator'; +export type FeatureName = UserFeatureName; export type FeatureConfig = z.infer< (typeof FeaturesShapes)[T] >; -const FreeFeature = { - type: FeatureType.Quota, - configs: { - // quota name - name: 'Free', - blobLimit: 10 * OneMB, - businessBlobLimit: 100 * OneMB, - storageQuota: 10 * OneGB, - historyPeriod: 7 * OneDay, - memberLimit: 3, - copilotActionLimit: 10, +export const FeatureConfigs = { + administrator: { + type: FeatureType.Feature, + configs: {}, }, -} as const; - -const ProFeature = { - type: FeatureType.Quota, - configs: { - name: 'Pro', - blobLimit: 100 * OneMB, - storageQuota: 100 * OneGB, - historyPeriod: 30 * OneDay, - memberLimit: 10, - copilotActionLimit: 10, - }, -} as const; - -const LifetimeProFeature = { - type: FeatureType.Quota, - configs: { - name: 'Lifetime Pro', - blobLimit: 100 * OneMB, - storageQuota: 1024 * OneGB, - historyPeriod: 30 * OneDay, - memberLimit: 10, - copilotActionLimit: 10, - }, -} as const; - -const TeamFeature = { - type: FeatureType.Quota, - configs: { - name: 'Team Workspace', - blobLimit: 500 * OneMB, - storageQuota: 100 * OneGB, - seatQuota: 20 * OneGB, - historyPeriod: 30 * OneDay, - memberLimit: 1, - }, -} as const; - -const EmptyFeature = { - type: FeatureType.Feature, - configs: {}, -} as const; - -export const FeatureConfigs: { - [K in FeatureName]: { - type: FeatureType; - configs: FeatureConfig; - }; -} = { - get free_plan_v1() { - return env.selfhosted ? ProFeature : FreeFeature; - }, - pro_plan_v1: ProFeature, - lifetime_pro_plan_v1: LifetimeProFeature, - team_plan_v1: TeamFeature, - unlimited_workspace: EmptyFeature, - quota_exceeded_readonly_workspace_v1: EmptyFeature, - unlimited_copilot: EmptyFeature, - administrator: EmptyFeature, -}; +} satisfies Record< + FeatureName, + { type: FeatureType; configs: FeatureConfig } +>; diff --git a/packages/backend/server/src/models/doc-user.ts b/packages/backend/server/src/models/doc-user.ts index 0ad6e6145a..0be5969198 100644 --- a/packages/backend/server/src/models/doc-user.ts +++ b/packages/backend/server/src/models/doc-user.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma'; -import { DocGrant, WorkspaceDocUserRole } from '@prisma/client'; +import { DocGrant } from '@prisma/client'; import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base'; import { BaseModel } from './base'; @@ -82,20 +82,12 @@ export class DocUserModel extends BaseModel { @Transactional() async deleteByUserId(userId: string) { - await this.models.permissionProjection.markNewWriteOrigin(); await this.db.docGrant.deleteMany({ where: { principalType: 'user', principalId: userId, }, }); - await this.withPermissionProjectionMetric( - this.db.workspaceDocUserRole.deleteMany({ - where: { - userId, - }, - }) - ); } async getOwner(workspaceId: string, docId: string) { @@ -160,7 +152,7 @@ export class DocUserModel extends BaseModel { workspaceId: string, docId: string, pagination: PaginationInput - ): Promise<[WorkspaceDocUserRole[], number]> { + ): Promise<[DocUserCompat[], number]> { const [grants, total] = await Promise.all([ this.db.docGrant.findMany({ where: { @@ -184,7 +176,7 @@ export class DocUserModel extends BaseModel { return [grants.map(grant => this.docGrantToCompat(grant)), total]; } - private docGrantToCompat(grant: DocGrant): WorkspaceDocUserRole { + private docGrantToCompat(grant: DocGrant): DocUserCompat { return { workspaceId: grant.workspaceId, docId: grant.docId, @@ -194,3 +186,11 @@ export class DocUserModel extends BaseModel { }; } } + +type DocUserCompat = { + workspaceId: string; + docId: string; + userId: string; + type: DocRole; + createdAt: Date; +}; diff --git a/packages/backend/server/src/models/doc.ts b/packages/backend/server/src/models/doc.ts index 3e14cba6a7..9d66a5de39 100644 --- a/packages/backend/server/src/models/doc.ts +++ b/packages/backend/server/src/models/doc.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; -import type { Update } from '@prisma/client'; +import type { Update, WorkspaceDoc } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { EventBus, PaginationInput } from '../base'; @@ -33,7 +33,10 @@ declare global { export type DocMetaUpsertInput = Omit< Prisma.WorkspaceDocUncheckedCreateInput, 'workspaceId' | 'docId' ->; +> & { + public?: boolean; + defaultRole?: DocRole; +}; /** * Workspace Doc Model @@ -50,6 +53,24 @@ export class DocModel extends BaseModel { super(); } + private docRoleFromPolicy(role: string | null | undefined) { + switch (role) { + case 'none': + return DocRole.None; + case 'reader': + return DocRole.Reader; + case 'commenter': + return DocRole.Commenter; + case 'editor': + return DocRole.Editor; + case 'owner': + return DocRole.Owner; + case 'manager': + default: + return DocRole.Manager; + } + } + // #region Update private updateToDocRecord(row: Update): Doc { @@ -365,56 +386,64 @@ export class DocModel extends BaseModel { docId: string, data?: DocMetaUpsertInput ) { - if ( - data && - ('public' in data || 'defaultRole' in data || 'publishedAt' in data) - ) { + const { public: isPublic, defaultRole, ...meta } = data ?? {}; + if (data && ('public' in data || 'defaultRole' in data)) { await this.models.docAccessPolicy.upsert(workspaceId, docId, { - public: data.public, - defaultRole: data.defaultRole, - publishedAt: - typeof data.publishedAt === 'string' - ? new Date(data.publishedAt) - : data.publishedAt, + public: isPublic, + defaultRole, }); } - const doc = await this.withPermissionProjectionMetric( - this.db.workspaceDoc.upsert({ - where: { - workspaceId_docId: { - workspaceId, - docId, - }, - }, - update: { - ...data, - }, - create: { - ...data, + const doc = await this.db.workspaceDoc.upsert({ + where: { + workspaceId_docId: { workspaceId, docId, }, - }) - ); + }, + update: { + ...meta, + }, + create: { + ...meta, + workspaceId, + docId, + }, + }); this.event.emit('doc.updated', { workspaceId, docId, }); - return doc; + const policy = await this.db.docAccessPolicy.findUnique({ + where: { workspaceId_docId: { workspaceId, docId } }, + }); + return { + ...doc, + public: policy?.visibility === 'public', + defaultRole: this.docRoleFromPolicy(policy?.memberDefaultRole), + }; } /** * Get the doc meta. */ + async getMeta( + workspaceId: string, + docId: string + ): Promise<(WorkspaceDoc & { public: boolean; defaultRole: DocRole }) | null>; async getMeta