mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-04 11:01:44 +08:00
feat(server): cleanup legacy compatibility (#15239)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Context>;
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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<Context>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string, Entitlement>();
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -60,10 +60,6 @@ declare global {
|
||||
|
||||
@Injectable()
|
||||
export class EntitlementService {
|
||||
private readonly legacyCloudSubscriptionSyncs = new Map<
|
||||
string,
|
||||
Promise<void>
|
||||
>();
|
||||
private readonly remoteSelfhostLicenseVerifications = new Map<
|
||||
string,
|
||||
Promise<Entitlement | null>
|
||||
@@ -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<TargetType, 'instance'>,
|
||||
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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
|
||||
@@ -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<Array<{ current: boolean }>>`
|
||||
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')
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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<ReturnType<Models['workspaceUser']['get']>>
|
||||
>;
|
||||
workspacePolicy: Map<string, Awaited<ReturnType<Models['workspace']['get']>>>;
|
||||
workspaceRuntime: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['workspaceRuntimeState']['get']>>
|
||||
>;
|
||||
workspaceQuotaRuntime: Map<string, NewWorkspaceRuntimeState>;
|
||||
docPolicies: Map<
|
||||
string,
|
||||
Awaited<ReturnType<Models['doc']['findDefaultRoles']>>
|
||||
>;
|
||||
docGrants: Map<string, Awaited<ReturnType<Models['docUser']['findMany']>>>;
|
||||
workspaceQuotaRuntime: Map<string, WorkspaceRuntimeState>;
|
||||
};
|
||||
|
||||
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<PermissionEvaluationInputV1> {
|
||||
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<PermissionEvaluationInputV1> {
|
||||
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<NewWorkspaceRuntimeState[]>`
|
||||
const rows = await this.db.$queryRaw<WorkspaceRuntimeState[]>`
|
||||
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<NewWorkspaceMemberRow[]>`
|
||||
private async workspaceMember(workspaceId: string, userId: string) {
|
||||
const rows = await this.db.$queryRaw<WorkspaceMemberRow[]>`
|
||||
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<NewWorkspacePolicyRow[]>`
|
||||
private async workspacePolicy(workspaceId: string) {
|
||||
const rows = await this.db.$queryRaw<WorkspacePolicyRow[]>`
|
||||
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<NewDocPolicyRow[]>`
|
||||
return await this.db.$queryRaw<DocPolicyRow[]>`
|
||||
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<NewDocGrantRow[]>`
|
||||
return await this.db.$queryRaw<DocGrantRow[]>`
|
||||
SELECT doc_id AS "docId", role
|
||||
FROM doc_grants
|
||||
WHERE workspace_id = ${workspaceId}
|
||||
|
||||
@@ -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<typeof docLegacyBoundary> & { decisions: unknown }
|
||||
>,
|
||||
current: Array<
|
||||
ReturnType<typeof docLegacyBoundary> & { 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ProjectionDecisionSample[]>`
|
||||
(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<PermissionContextLoader['load']>[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(
|
||||
|
||||
@@ -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<string, string[]> };
|
||||
};
|
||||
|
||||
private readonly legacyDocRoleValues = new Map<string, DocRole>([
|
||||
['external', DocRole.External],
|
||||
['reader', DocRole.Reader],
|
||||
['commenter', DocRole.Commenter],
|
||||
['editor', DocRole.Editor],
|
||||
['manager', DocRole.Manager],
|
||||
['owner', DocRole.Owner],
|
||||
]);
|
||||
|
||||
private readonly legacyWorkspaceRoleValues = new Map<string, number>([
|
||||
['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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user