feat(server): cleanup legacy compatibility (#15239)

This commit is contained in:
DarkSky
2026-07-15 03:01:51 +08:00
committed by GitHub
parent 00d4ab10a1
commit e145d87d56
104 changed files with 3184 additions and 8883 deletions
+57 -48
View File
@@ -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
`;
}