mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 06:18:45 +08:00
chore: cleanup legacy logic (#15072)
This commit is contained in:
@@ -38,7 +38,7 @@ test('change email', async t => {
|
||||
const jwt = signedIn?.token.token;
|
||||
t.truthy(jwt);
|
||||
|
||||
await sendChangeEmail(app, u1Email, '/email-change');
|
||||
await sendChangeEmail(app, '/email-change');
|
||||
|
||||
const changeMail = app.mails.last('ChangeEmail');
|
||||
|
||||
@@ -157,12 +157,11 @@ test('should forbid graphql callbackUrl to external origin', async t => {
|
||||
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||
.send({
|
||||
query: `
|
||||
mutation($email: String!, $callbackUrl: String!) {
|
||||
sendChangeEmail(email: $email, callbackUrl: $callbackUrl)
|
||||
mutation($callbackUrl: String!) {
|
||||
sendChangeEmail(callbackUrl: $callbackUrl)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
email: u1Email,
|
||||
callbackUrl: 'https://evil.example',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -787,7 +787,7 @@ test('test key failure disables a saved key and success restores it', async t =>
|
||||
apiKey: 'sk-test-primary',
|
||||
});
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch');
|
||||
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch');
|
||||
fetch
|
||||
.onFirstCall()
|
||||
.resolves(
|
||||
@@ -858,7 +858,7 @@ test('local key test does not mutate saved server config', async t => {
|
||||
apiKey: 'sk-server',
|
||||
});
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch').resolves(
|
||||
new Response('{"error":"invalid sk-local"}', { status: 401 })
|
||||
);
|
||||
t.teardown(() => fetch.restore());
|
||||
@@ -889,7 +889,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch').resolves(
|
||||
new Response(
|
||||
'failed https://generativelanguage.googleapis.com/v1beta/models?key=gemini-secret',
|
||||
{ status: 401 }
|
||||
@@ -916,6 +916,7 @@ test('Gemini key test sends key in header and returns safe failure message', asy
|
||||
],
|
||||
'gemini-secret'
|
||||
);
|
||||
t.deepEqual(fetch.firstCall.args[2]?.allowedHeaders, ['x-goog-api-key']);
|
||||
t.false(result.message?.includes('gemini-secret'));
|
||||
t.is(result.message, 'Provider rejected the BYOK key.');
|
||||
});
|
||||
@@ -924,7 +925,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
const { user, workspace } = await createUserWorkspace(t);
|
||||
await grantUserPlan(t, user.id);
|
||||
|
||||
const fetch = Sinon.stub(globalThis, 'fetch').resolves(
|
||||
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch').resolves(
|
||||
new Response('{}', { status: 200 })
|
||||
);
|
||||
t.teardown(() => fetch.restore());
|
||||
@@ -943,6 +944,7 @@ test('FAL key test uses read-only platform API probe endpoint', async t => {
|
||||
(fetch.firstCall.args[1]!.headers as Record<string, string>).Authorization,
|
||||
'Key fal-secret'
|
||||
);
|
||||
t.deepEqual(fetch.firstCall.args[2]?.allowedHeaders, ['Authorization']);
|
||||
});
|
||||
|
||||
test('provider test failures do not return raw provider response body', async t => {
|
||||
@@ -970,7 +972,7 @@ test('provider test failures do not return raw provider response body', async t
|
||||
message: 'Provider service is unavailable.',
|
||||
},
|
||||
];
|
||||
const fetch = Sinon.stub(globalThis, 'fetch');
|
||||
const fetch = Sinon.stub(t.context.byok as any, 'probeFetch');
|
||||
for (const [index, matrixCase] of cases.entries()) {
|
||||
fetch
|
||||
.onCall(index)
|
||||
|
||||
@@ -1673,6 +1673,7 @@ test('should be able to manage context', async t => {
|
||||
'workspace.file.embed.finished',
|
||||
{
|
||||
contextId: session.id,
|
||||
workspaceId: session.workspaceId,
|
||||
fileId: file.id,
|
||||
chunkSize: 1,
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ test('admin feature resolver rejects commercial projection features', async t =>
|
||||
|
||||
test('should get null if user feature not found', async t => {
|
||||
const { model, u1 } = t.context;
|
||||
const userFeature = await model.get(u1.id, 'ai_early_access');
|
||||
const userFeature = await model.get(u1.id, 'administrator');
|
||||
t.is(userFeature, null);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ test('should directly test user feature existence', async t => {
|
||||
|
||||
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
|
||||
t.true(await model.has(u1.id, 'free_plan_v1'));
|
||||
t.false(await model.has(u1.id, 'ai_early_access'));
|
||||
t.false(await model.has(u1.id, 'administrator'));
|
||||
});
|
||||
|
||||
test('should add user feature', async t => {
|
||||
|
||||
@@ -924,7 +924,7 @@ test('oidc should not fall back to default email claim when custom claim is conf
|
||||
|
||||
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
|
||||
const { provider, factory, server } = createOidcRegistrationHarness();
|
||||
const fetchStub = Sinon.stub(globalThis, 'fetch');
|
||||
const fetchStub = Sinon.stub(provider as any, 'oidcFetch');
|
||||
const scheduledRetries: Array<() => void> = [];
|
||||
const retryDelays: number[] = [];
|
||||
const setTimeoutStub = Sinon.stub(globalThis, 'setTimeout').callsFake(((
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# Snapshot report for `src/__tests__/payment/service.spec.ts`
|
||||
|
||||
The actual snapshot is saved in `service.spec.ts.snap`.
|
||||
|
||||
Generated by [AVA](https://avajs.dev).
|
||||
|
||||
## should list normal price for unauthenticated user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list normal prices for authenticated user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should not show lifetime price if not enabled
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list early access prices for pro ea user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_lifetime',
|
||||
'pro_yearly_earlyaccess',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list normal prices for pro ea user with old subscriptions
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list early access prices for ai ea user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly_earlyaccess',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list early access prices for pro and ai ea user
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_lifetime',
|
||||
'pro_yearly_earlyaccess',
|
||||
'ai_yearly_earlyaccess',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should list normal prices for ai ea user with old subscriptions
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
|
||||
## should be able to list prices for team
|
||||
|
||||
> Snapshot 1
|
||||
|
||||
[
|
||||
'pro_monthly',
|
||||
'pro_yearly',
|
||||
'pro_lifetime',
|
||||
'ai_yearly',
|
||||
'team_monthly',
|
||||
'team_yearly',
|
||||
]
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@ import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { LicenseService } from '../../plugins/license/service';
|
||||
import { licenseClient, LicenseService } from '../../plugins/license/service';
|
||||
import { PaymentEventHandlers } from '../../plugins/payment/event';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
@@ -19,6 +19,12 @@ type Context = Record<string, never>;
|
||||
|
||||
const test = ava as TestFn<Context>;
|
||||
|
||||
const originalActivateLicense = licenseClient.activate;
|
||||
|
||||
test.afterEach.always(() => {
|
||||
licenseClient.activate = originalActivateLicense;
|
||||
});
|
||||
|
||||
test('workspace subscription activation only sends upgrade notification', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
let reconciled = false;
|
||||
@@ -120,7 +126,6 @@ test('onetime selfhost license seat allocation ignores projected license quantit
|
||||
|
||||
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
|
||||
const events: Array<{ name: string; payload: unknown }> = [];
|
||||
const affineProRequests: string[] = [];
|
||||
const upserts: unknown[] = [];
|
||||
const entitlements: unknown[] = [];
|
||||
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
@@ -154,28 +159,17 @@ test('recurring selfhost license activation returns activation projection withou
|
||||
{} as unknown as QuotaStateService
|
||||
);
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
fetchAffinePro: (path: string) => Promise<{
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
endAt: number;
|
||||
res: Response;
|
||||
}>;
|
||||
}
|
||||
).fetchAffinePro = async (path: string) => {
|
||||
affineProRequests.push(path);
|
||||
let activatedLicenseKey: string | undefined;
|
||||
licenseClient.activate = async ({ licenseKey }) => {
|
||||
activatedLicenseKey = licenseKey;
|
||||
return {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
endAt: expiresAt,
|
||||
res: new Response(null, {
|
||||
headers: {
|
||||
'x-next-validate-key': 'next-validate-key',
|
||||
},
|
||||
}),
|
||||
license: {
|
||||
plan: SubscriptionPlan.SelfHostedTeam,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
quantity: 3,
|
||||
expiresAt,
|
||||
validateKey: 'next-validate-key',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -189,7 +183,7 @@ test('recurring selfhost license activation returns activation projection withou
|
||||
});
|
||||
t.is(entitlements.length, 1);
|
||||
t.is(upserts.length, 1);
|
||||
t.deepEqual(affineProRequests, ['/api/team/licenses/license-key/activate']);
|
||||
t.is(activatedLicenseKey, 'license-key');
|
||||
t.deepEqual(events, [
|
||||
{
|
||||
name: 'workspace.subscription.activated',
|
||||
|
||||
@@ -10,17 +10,14 @@ import { EventBus } from '../../base';
|
||||
import { ConfigFactory, ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { EarlyAccessType, FeatureService } from '../../core/features';
|
||||
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||
import { SubscriptionService } from '../../plugins/payment/service';
|
||||
import { StripeFactory } from '../../plugins/payment/stripe';
|
||||
import {
|
||||
CouponType,
|
||||
encodeLookupKey,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from '../../plugins/payment/types';
|
||||
import { createTestingApp, type TestingApp } from '../utils';
|
||||
|
||||
@@ -31,15 +28,25 @@ const unixNow = () => {
|
||||
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
|
||||
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
|
||||
const PRO_LIFETIME = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`;
|
||||
const PRO_EA_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`;
|
||||
const AI_YEARLY = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`;
|
||||
const AI_YEARLY_EA = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`;
|
||||
const TEAM_MONTHLY = `${SubscriptionPlan.Team}_${SubscriptionRecurring.Monthly}`;
|
||||
const TEAM_YEARLY = `${SubscriptionPlan.Team}_${SubscriptionRecurring.Yearly}`;
|
||||
// prices for code redeeming
|
||||
const PRO_MONTHLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`;
|
||||
const PRO_YEARLY_CODE = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`;
|
||||
const AI_YEARLY_CODE = `${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`;
|
||||
const NORMAL_USER_PRICES = [
|
||||
PRO_MONTHLY,
|
||||
PRO_YEARLY,
|
||||
PRO_LIFETIME,
|
||||
AI_YEARLY,
|
||||
TEAM_MONTHLY,
|
||||
TEAM_YEARLY,
|
||||
];
|
||||
|
||||
const NORMAL_USER_PRICES_WITHOUT_LIFETIME = [
|
||||
PRO_MONTHLY,
|
||||
PRO_YEARLY,
|
||||
AI_YEARLY,
|
||||
TEAM_MONTHLY,
|
||||
TEAM_YEARLY,
|
||||
];
|
||||
|
||||
const PRICES = {
|
||||
[PRO_MONTHLY]: {
|
||||
@@ -66,15 +73,6 @@ const PRICES = {
|
||||
id: PRO_LIFETIME,
|
||||
lookup_key: PRO_LIFETIME,
|
||||
},
|
||||
[PRO_EA_YEARLY]: {
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
unit_amount: 5000,
|
||||
currency: 'usd',
|
||||
id: PRO_EA_YEARLY,
|
||||
lookup_key: PRO_EA_YEARLY,
|
||||
},
|
||||
[AI_YEARLY]: {
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
@@ -84,33 +82,6 @@ const PRICES = {
|
||||
id: AI_YEARLY,
|
||||
lookup_key: AI_YEARLY,
|
||||
},
|
||||
[AI_YEARLY_EA]: {
|
||||
recurring: {
|
||||
interval: 'year',
|
||||
},
|
||||
unit_amount: 9999,
|
||||
currency: 'usd',
|
||||
id: AI_YEARLY_EA,
|
||||
lookup_key: AI_YEARLY_EA,
|
||||
},
|
||||
[PRO_MONTHLY_CODE]: {
|
||||
unit_amount: 799,
|
||||
currency: 'usd',
|
||||
id: PRO_MONTHLY_CODE,
|
||||
lookup_key: PRO_MONTHLY_CODE,
|
||||
},
|
||||
[PRO_YEARLY_CODE]: {
|
||||
unit_amount: 8100,
|
||||
currency: 'usd',
|
||||
id: PRO_YEARLY_CODE,
|
||||
lookup_key: PRO_YEARLY_CODE,
|
||||
},
|
||||
[AI_YEARLY_CODE]: {
|
||||
unit_amount: 10680,
|
||||
currency: 'usd',
|
||||
id: AI_YEARLY_CODE,
|
||||
lookup_key: AI_YEARLY_CODE,
|
||||
},
|
||||
[TEAM_MONTHLY]: {
|
||||
unit_amount: 1500,
|
||||
currency: 'usd',
|
||||
@@ -164,7 +135,6 @@ const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
service: SubscriptionService;
|
||||
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||
feature: Sinon.SinonStubbedInstance<FeatureService>;
|
||||
stripe: {
|
||||
customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>;
|
||||
prices: Sinon.SinonStubbedInstance<Stripe.PricesResource>;
|
||||
@@ -203,16 +173,12 @@ test.before(async t => {
|
||||
AppModule,
|
||||
],
|
||||
tapModule: m => {
|
||||
m.overrideProvider(FeatureService).useValue(
|
||||
Sinon.createStubInstance(FeatureService)
|
||||
);
|
||||
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
|
||||
},
|
||||
});
|
||||
|
||||
t.context.event = app.get(EventBus);
|
||||
t.context.service = app.get(SubscriptionService);
|
||||
t.context.feature = app.get(FeatureService);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.app = app;
|
||||
|
||||
@@ -286,18 +252,21 @@ test('should list normal price for unauthenticated user', async t => {
|
||||
|
||||
const prices = await service.listPrices();
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
t.deepEqual(
|
||||
prices.map(p => encodeLookupKey(p.lookupKey)),
|
||||
NORMAL_USER_PRICES
|
||||
);
|
||||
});
|
||||
|
||||
test('should list normal prices for authenticated user', async t => {
|
||||
const { feature, service, u1 } = t.context;
|
||||
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
|
||||
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
|
||||
const { service, u1 } = t.context;
|
||||
|
||||
const prices = await service.listPrices(u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
t.deepEqual(
|
||||
prices.map(p => encodeLookupKey(p.lookupKey)),
|
||||
NORMAL_USER_PRICES
|
||||
);
|
||||
});
|
||||
|
||||
test('should not show lifetime price if not enabled', async t => {
|
||||
@@ -311,25 +280,14 @@ test('should not show lifetime price if not enabled', async t => {
|
||||
|
||||
const prices = await service.listPrices(t.context.u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
t.deepEqual(
|
||||
prices.map(p => encodeLookupKey(p.lookupKey)),
|
||||
NORMAL_USER_PRICES_WITHOUT_LIFETIME
|
||||
);
|
||||
});
|
||||
|
||||
test('should list early access prices for pro ea user', async t => {
|
||||
const { feature, service, u1 } = t.context;
|
||||
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
|
||||
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
|
||||
|
||||
const prices = await service.listPrices(u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
});
|
||||
|
||||
test('should list normal prices for pro ea user with old subscriptions', async t => {
|
||||
const { feature, service, u1, stripe } = t.context;
|
||||
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
|
||||
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(false);
|
||||
test('should list normal prices for user with old pro subscriptions', async t => {
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
@@ -352,35 +310,14 @@ test('should list normal prices for pro ea user with old subscriptions', async t
|
||||
|
||||
const prices = await service.listPrices(u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
t.deepEqual(
|
||||
prices.map(p => encodeLookupKey(p.lookupKey)),
|
||||
NORMAL_USER_PRICES
|
||||
);
|
||||
});
|
||||
|
||||
test('should list early access prices for ai ea user', async t => {
|
||||
const { feature, service, u1 } = t.context;
|
||||
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
|
||||
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true);
|
||||
|
||||
const prices = await service.listPrices(u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
});
|
||||
|
||||
test('should list early access prices for pro and ai ea user', async t => {
|
||||
const { feature, service, u1 } = t.context;
|
||||
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
|
||||
|
||||
const prices = await service.listPrices(u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
});
|
||||
|
||||
test('should list normal prices for ai ea user with old subscriptions', async t => {
|
||||
const { feature, service, u1, stripe } = t.context;
|
||||
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
|
||||
feature.isEarlyAccessUser.withArgs(u1.id, EarlyAccessType.AI).resolves(true);
|
||||
test('should list normal prices for user with old ai subscriptions', async t => {
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
@@ -403,7 +340,10 @@ test('should list normal prices for ai ea user with old subscriptions', async t
|
||||
|
||||
const prices = await service.listPrices(u1);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
t.deepEqual(
|
||||
prices.map(p => encodeLookupKey(p.lookupKey)),
|
||||
NORMAL_USER_PRICES
|
||||
);
|
||||
});
|
||||
|
||||
// ============= end prices ================
|
||||
@@ -470,11 +410,9 @@ test('should allow checkout after local subscription period ended', async t => {
|
||||
});
|
||||
|
||||
test('should get correct pro plan price for checking out', async t => {
|
||||
const { app, service, u1, stripe, feature } = t.context;
|
||||
// non-ea user
|
||||
const { app, service, u1, stripe } = t.context;
|
||||
// monthly
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(false);
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
@@ -490,27 +428,8 @@ test('should get correct pro plan price for checking out', async t => {
|
||||
});
|
||||
}
|
||||
|
||||
// ea user, but monthly
|
||||
// yearly
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(true);
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: PRO_MONTHLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ea user, yearly
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(true);
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
@@ -521,14 +440,13 @@ test('should get correct pro plan price for checking out', async t => {
|
||||
);
|
||||
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: PRO_EA_YEARLY,
|
||||
coupon: CouponType.ProEarlyAccessOneYearFree,
|
||||
price: PRO_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ea user, yearly recurring, but has old subscription
|
||||
// yearly recurring, but has old subscription
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(true);
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
{
|
||||
@@ -561,27 +479,10 @@ test('should get correct pro plan price for checking out', async t => {
|
||||
price: PRO_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
variant: SubscriptionVariant.EA,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
),
|
||||
{
|
||||
message: 'You are trying to access a unknown subscription plan.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// any user, lifetime recurring
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(false);
|
||||
app.get(ConfigFactory).override({
|
||||
payment: {
|
||||
showLifetimePrice: true,
|
||||
@@ -605,12 +506,10 @@ test('should get correct pro plan price for checking out', async t => {
|
||||
});
|
||||
|
||||
test('should get correct ai plan price for checking out', async t => {
|
||||
const { service, u1, stripe, feature } = t.context;
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
// non-ea user
|
||||
// user
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(false);
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
@@ -626,111 +525,8 @@ test('should get correct ai plan price for checking out', async t => {
|
||||
});
|
||||
}
|
||||
|
||||
// ea user
|
||||
// user with old subscription
|
||||
{
|
||||
feature.isEarlyAccessUser.resolves(true);
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: AI_YEARLY_EA,
|
||||
coupon: CouponType.AIEarlyAccessOneYearFree,
|
||||
});
|
||||
}
|
||||
|
||||
// ea user, but has old subscription
|
||||
{
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(false);
|
||||
feature.isEarlyAccessUser
|
||||
.withArgs(u1.id, EarlyAccessType.AI)
|
||||
.resolves(true);
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
{
|
||||
id: 'sub_1',
|
||||
status: 'canceled',
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
// @ts-expect-error stub
|
||||
price: {
|
||||
lookup_key: AI_YEARLY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: AI_YEARLY,
|
||||
coupon: undefined,
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
variant: SubscriptionVariant.EA,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
),
|
||||
{
|
||||
message: 'You are trying to access a unknown subscription plan.',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// pro ea user
|
||||
{
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
|
||||
feature.isEarlyAccessUser
|
||||
.withArgs(u1.id, EarlyAccessType.AI)
|
||||
.resolves(false);
|
||||
// @ts-expect-error stub
|
||||
stripe.subscriptions.list.resolves({ data: [] });
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{ user: u1 }
|
||||
);
|
||||
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: AI_YEARLY,
|
||||
coupon: CouponType.ProEarlyAccessAIOneYearFree,
|
||||
});
|
||||
}
|
||||
|
||||
// pro ea user, but has old subscription
|
||||
{
|
||||
feature.isEarlyAccessUser.withArgs(u1.id).resolves(true);
|
||||
feature.isEarlyAccessUser
|
||||
.withArgs(u1.id, EarlyAccessType.AI)
|
||||
.resolves(false);
|
||||
stripe.subscriptions.list.resolves({
|
||||
data: [
|
||||
{
|
||||
@@ -1218,44 +1014,6 @@ const lifetimeInvoice: Stripe.Invoice = {
|
||||
},
|
||||
};
|
||||
|
||||
const onetimeMonthlyInvoice: Stripe.Invoice = {
|
||||
id: 'in_2',
|
||||
object: 'invoice',
|
||||
amount_paid: 799,
|
||||
total: 799,
|
||||
customer: 'cus_1',
|
||||
customer_email: 'u1@affine.pro',
|
||||
currency: 'usd',
|
||||
status: 'paid',
|
||||
lines: {
|
||||
data: [
|
||||
// @ts-expect-error stub
|
||||
{
|
||||
price: PRICES[PRO_MONTHLY_CODE],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const onetimeYearlyInvoice: Stripe.Invoice = {
|
||||
id: 'in_3',
|
||||
object: 'invoice',
|
||||
amount_paid: 8100,
|
||||
total: 8100,
|
||||
customer: 'cus_1',
|
||||
customer_email: 'u1@affine.pro',
|
||||
currency: 'usd',
|
||||
status: 'paid',
|
||||
lines: {
|
||||
data: [
|
||||
// @ts-expect-error stub
|
||||
{
|
||||
price: PRICES[PRO_YEARLY_CODE],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test('should not be able to checkout for lifetime recurring if not enabled', async t => {
|
||||
const { service, u1, app } = t.context;
|
||||
app.get(ConfigFactory).override({
|
||||
@@ -1337,32 +1095,6 @@ test('should not be able to checkout for lifetime recurring if already subscribe
|
||||
),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
|
||||
await db.subscription.updateMany({
|
||||
where: { targetId: u1.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
end: new Date(Date.now() + 100000),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
variant: null,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{
|
||||
user: u1,
|
||||
}
|
||||
),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to subscribe to lifetime recurring', async t => {
|
||||
@@ -1474,226 +1206,16 @@ test('should not be able to update lifetime recurring', async t => {
|
||||
);
|
||||
});
|
||||
|
||||
// ============== Onetime Subscription ===============
|
||||
test('should be able to checkout for onetime payment', async t => {
|
||||
const { service, u1, stripe } = t.context;
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{
|
||||
user: u1,
|
||||
}
|
||||
);
|
||||
|
||||
t.true(stripe.checkout.sessions.create.calledOnce);
|
||||
const arg = stripe.checkout.sessions.create.firstCall
|
||||
.args[0] as Stripe.Checkout.SessionCreateParams;
|
||||
t.is(arg.mode, 'payment');
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: PRO_MONTHLY_CODE,
|
||||
coupon: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should be able to checkout onetime payment if previous subscription is onetime', async t => {
|
||||
const { service, u1, stripe, db } = t.context;
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: u1.id,
|
||||
stripeSubscriptionId: 'sub_1',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
end: new Date(Date.now() + 100000),
|
||||
},
|
||||
});
|
||||
|
||||
await service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{
|
||||
user: u1,
|
||||
}
|
||||
);
|
||||
|
||||
t.true(stripe.checkout.sessions.create.calledOnce);
|
||||
const arg = stripe.checkout.sessions.create.firstCall
|
||||
.args[0] as Stripe.Checkout.SessionCreateParams;
|
||||
t.is(arg.mode, 'payment');
|
||||
t.deepEqual(getLastCheckoutPrice(stripe.checkout.sessions.create), {
|
||||
price: PRO_MONTHLY_CODE,
|
||||
coupon: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('should not be able to checkout out onetime payment if previous subscription is not onetime', async t => {
|
||||
const { service, u1, db } = t.context;
|
||||
|
||||
await db.subscription.create({
|
||||
data: {
|
||||
targetId: u1.id,
|
||||
stripeSubscriptionId: 'sub_1',
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
status: SubscriptionStatus.Active,
|
||||
start: new Date(),
|
||||
end: new Date(Date.now() + 100000),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{
|
||||
user: u1,
|
||||
}
|
||||
),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
|
||||
await db.subscription.updateMany({
|
||||
where: { targetId: u1.id },
|
||||
data: {
|
||||
stripeSubscriptionId: null,
|
||||
recurring: SubscriptionRecurring.Lifetime,
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(
|
||||
() =>
|
||||
service.checkout(
|
||||
{
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
variant: SubscriptionVariant.Onetime,
|
||||
successCallbackLink: '',
|
||||
},
|
||||
{
|
||||
user: u1,
|
||||
}
|
||||
),
|
||||
{ message: 'You have already subscribed to the pro plan.' }
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to subscribe onetime payment subscription', async t => {
|
||||
const { service, db, u1, event } = t.context;
|
||||
|
||||
await service.saveStripeInvoice(onetimeMonthlyInvoice);
|
||||
|
||||
const subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
t.true(
|
||||
event.emit.calledOnceWith('user.subscription.activated', {
|
||||
userId: u1.id,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
})
|
||||
);
|
||||
t.is(subInDB?.plan, SubscriptionPlan.Pro);
|
||||
t.is(subInDB?.recurring, SubscriptionRecurring.Monthly);
|
||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||
t.is(subInDB?.stripeSubscriptionId, null);
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to accumulate onetime payment subscription period', async t => {
|
||||
const { service, db, u1 } = t.context;
|
||||
|
||||
await service.saveStripeInvoice(onetimeMonthlyInvoice);
|
||||
|
||||
let subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
t.truthy(subInDB);
|
||||
|
||||
let end = subInDB!.end!;
|
||||
await service.saveStripeInvoice(onetimeYearlyInvoice);
|
||||
subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
// add 365 days
|
||||
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('should be able to recalculate onetime payment subscription period after expiration', async t => {
|
||||
const { service, db, u1 } = t.context;
|
||||
|
||||
await service.saveStripeInvoice(onetimeMonthlyInvoice);
|
||||
|
||||
let subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
// make subscription expired
|
||||
await db.subscription.update({
|
||||
where: { id: subInDB!.id },
|
||||
data: {
|
||||
end: new Date(Date.now() - 1000),
|
||||
},
|
||||
});
|
||||
await service.saveStripeInvoice(onetimeYearlyInvoice);
|
||||
subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
// add 365 days from now
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
});
|
||||
|
||||
test('should not accumulate onetime payment subscription period for redeemed invoices', async t => {
|
||||
const { service, db, u1 } = t.context;
|
||||
|
||||
// save invoices received more than once, should only redeem them once.
|
||||
await service.saveStripeInvoice(onetimeYearlyInvoice);
|
||||
await service.saveStripeInvoice(onetimeYearlyInvoice);
|
||||
await service.saveStripeInvoice(onetimeYearlyInvoice);
|
||||
|
||||
const subInDB = await db.subscription.findFirst({
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
});
|
||||
|
||||
// TEAM
|
||||
test('should be able to list prices for team', async t => {
|
||||
const { service } = t.context;
|
||||
|
||||
const prices = await service.listPrices(undefined);
|
||||
|
||||
t.snapshot(prices.map(p => encodeLookupKey(p.lookupKey)));
|
||||
t.deepEqual(
|
||||
prices.map(p => encodeLookupKey(p.lookupKey)),
|
||||
NORMAL_USER_PRICES
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to checkout for team', async t => {
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function inviteUsers(
|
||||
app: TestingApp,
|
||||
workspaceId: string,
|
||||
emails: string[]
|
||||
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
|
||||
): Promise<Array<{ email: string; inviteId?: string }>> {
|
||||
const res = await app.gql(
|
||||
`
|
||||
mutation inviteMembers($workspaceId: String!, $emails: [String!]!) {
|
||||
@@ -31,7 +31,6 @@ export async function inviteUsers(
|
||||
) {
|
||||
email
|
||||
inviteId
|
||||
sentSuccess
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -42,19 +42,6 @@ export async function listNotifications(
|
||||
return res.currentUser.notifications;
|
||||
}
|
||||
|
||||
export async function getNotificationCount(app: TestingApp): Promise<number> {
|
||||
const res = await app.gql(
|
||||
`
|
||||
query notificationCount {
|
||||
currentUser {
|
||||
notificationCount
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
return res.currentUser.notificationCount;
|
||||
}
|
||||
|
||||
export async function mentionUser(
|
||||
app: TestingApp,
|
||||
input: MentionInput
|
||||
|
||||
@@ -34,12 +34,11 @@ export async function getPublicUserById(
|
||||
|
||||
export async function sendChangeEmail(
|
||||
app: TestingApp,
|
||||
email: string,
|
||||
callbackUrl: string
|
||||
): Promise<boolean> {
|
||||
const res = await app.gql(`
|
||||
mutation {
|
||||
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
|
||||
sendChangeEmail(callbackUrl: "${callbackUrl}")
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ import { ModelsModule } from './models';
|
||||
import { CalendarModule } from './plugins/calendar';
|
||||
import { CaptchaModule } from './plugins/captcha';
|
||||
import { CopilotModule, CopilotRealtimeModule } from './plugins/copilot';
|
||||
import { CustomerIoModule } from './plugins/customerio';
|
||||
import { GCloudModule } from './plugins/gcloud';
|
||||
import { IndexerModule } from './plugins/indexer';
|
||||
import { LicenseModule } from './plugins/license';
|
||||
@@ -205,7 +204,6 @@ export function buildAppModule(env: Env) {
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CalendarModule,
|
||||
CustomerIoModule,
|
||||
TelemetryModule,
|
||||
CommentModule,
|
||||
AccessTokenModule,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type SSRFBlockReason =
|
||||
| 'disallowed_protocol'
|
||||
| 'url_has_credentials'
|
||||
| 'blocked_hostname'
|
||||
| 'host_not_allowed'
|
||||
| 'unresolvable_hostname'
|
||||
| 'blocked_ip'
|
||||
| 'too_many_redirects';
|
||||
@@ -19,6 +20,7 @@ const SSRF_REASONS = new Set<string>([
|
||||
'disallowed_protocol',
|
||||
'url_has_credentials',
|
||||
'blocked_hostname',
|
||||
'host_not_allowed',
|
||||
'unresolvable_hostname',
|
||||
'blocked_ip',
|
||||
'too_many_redirects',
|
||||
@@ -47,6 +49,12 @@ export interface SafeFetchOptions {
|
||||
timeoutMs?: number;
|
||||
maxRedirects?: number;
|
||||
maxBytes?: number;
|
||||
allowedHeaders?: string[];
|
||||
allowedHosts?: string[];
|
||||
allowHttp?: boolean;
|
||||
allowPrivateTargetOrigin?: boolean;
|
||||
enableEch?: boolean;
|
||||
echConfigList?: Buffer;
|
||||
}
|
||||
|
||||
export async function assertSsrFSafeUrl(rawUrl: string | URL): Promise<URL> {
|
||||
@@ -72,20 +80,25 @@ export async function safeFetch(
|
||||
): Promise<Response> {
|
||||
const url = rawUrl.toString();
|
||||
const method = String(init.method ?? 'GET').toUpperCase();
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
if (!['GET', 'HEAD', 'POST', 'PUT', 'PROPFIND', 'REPORT'].includes(method)) {
|
||||
throw new Error(`Unsupported safeFetch method: ${method}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await safeFetchFromNative({
|
||||
url,
|
||||
method: (method === 'HEAD' ? 'head' : 'get') as NonNullable<
|
||||
SafeFetchRequest['method']
|
||||
>,
|
||||
method: method.toLowerCase() as NonNullable<SafeFetchRequest['method']>,
|
||||
headers: normalizeHeaders(init.headers),
|
||||
body: normalizeBody(init.body),
|
||||
timeoutMs: options.timeoutMs,
|
||||
maxRedirects: options.maxRedirects,
|
||||
maxBytes: options.maxBytes,
|
||||
allowedHeaders: options.allowedHeaders,
|
||||
allowedHosts: options.allowedHosts,
|
||||
allowHttp: options.allowHttp,
|
||||
allowPrivateTargetOrigin: options.allowPrivateTargetOrigin,
|
||||
enableEch: options.enableEch,
|
||||
echConfigList: options.echConfigList,
|
||||
});
|
||||
const body =
|
||||
method === 'HEAD' || [204, 205, 304].includes(response.status)
|
||||
@@ -117,6 +130,22 @@ function normalizeHeaders(headers: RequestInit['headers'] | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBody(body: RequestInit['body'] | null | undefined) {
|
||||
if (body === null || body === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return Buffer.from(body);
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return Buffer.from(body);
|
||||
}
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
||||
}
|
||||
throw new Error('Unsupported safeFetch body type.');
|
||||
}
|
||||
|
||||
export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
const copy = new Uint8Array(buffer.byteLength);
|
||||
copy.set(buffer);
|
||||
|
||||
@@ -187,9 +187,7 @@ export class AuthResolver {
|
||||
@Mutation(() => Boolean)
|
||||
async sendChangeEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
// @deprecated
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new EmailVerificationRequired();
|
||||
|
||||
@@ -299,18 +299,13 @@ export class CommentResolver {
|
||||
@CurrentUser() me: UserType,
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('docId') docId: string,
|
||||
@Args({
|
||||
name: 'pagination',
|
||||
})
|
||||
@Args({ name: 'pagination' })
|
||||
pagination: PaginationInput
|
||||
): Promise<PaginatedCommentChangeObjectType> {
|
||||
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
|
||||
await this.assertPermission(
|
||||
me,
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
},
|
||||
{ workspaceId: workspace.id, docId },
|
||||
'Doc.Comments.Read'
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Config,
|
||||
CryptoHelper,
|
||||
getOrGenRequestId,
|
||||
safeFetch,
|
||||
UserFriendlyError,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
@@ -303,7 +304,17 @@ export class RpcDocReader extends DatabaseDocReader {
|
||||
if (body) {
|
||||
requestInit.body = body;
|
||||
}
|
||||
const res = await fetch(url, requestInit);
|
||||
const res = await safeFetch(url, requestInit, {
|
||||
timeoutMs: 10_000,
|
||||
maxRedirects: 0,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
allowedHeaders: [
|
||||
'content-type',
|
||||
'x-access-token',
|
||||
'x-cloud-trace-context',
|
||||
],
|
||||
allowPrivateTargetOrigin: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Entitlement, Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { BadRequest, CryptoHelper, EventBus } from '../../base';
|
||||
import { resolveEntitlementV1 } from '../../native';
|
||||
import { checkLicenseHealth, resolveEntitlementV1 } from '../../native';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
@@ -47,15 +47,7 @@ export interface SelfhostLicenseEntitlementInput {
|
||||
license?: Buffer | null;
|
||||
}
|
||||
|
||||
interface RemoteSelfhostLicense {
|
||||
plan: string;
|
||||
recurring: string;
|
||||
quantity: number;
|
||||
endAt: number;
|
||||
}
|
||||
|
||||
const REMOTE_SELFHOST_LICENSE_REVALIDATE_INTERVAL = 1000 * 60 * 10;
|
||||
const REMOTE_SELFHOST_LICENSE_HEALTH_TIMEOUT = 10_000;
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
@@ -844,44 +836,25 @@ export class EntitlementService {
|
||||
return cached.entitlement;
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
process.env.AFFINE_PRO_SERVER_ENDPOINT ?? 'https://app.affine.pro';
|
||||
const signal = AbortSignal.timeout(REMOTE_SELFHOST_LICENSE_HEALTH_TIMEOUT);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${endpoint}/api/team/licenses/${entitlement.subjectId}/health`,
|
||||
{
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-validate-key': metadata.validateKey,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500) {
|
||||
const res = await checkLicenseHealth({
|
||||
licenseKey: entitlement.subjectId,
|
||||
validateKey: metadata.validateKey,
|
||||
});
|
||||
if (res.error) {
|
||||
if (res.error.status >= 500) {
|
||||
return this.remoteSelfhostFallbackEntitlement(entitlement);
|
||||
}
|
||||
|
||||
await this.markRemoteSelfhostLicenseNeedsReupload(
|
||||
entitlement,
|
||||
`Remote license health check failed: ${res.status}`
|
||||
`Remote license health check failed: ${res.error.status}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = (await res
|
||||
.json()
|
||||
.catch(() => null)) as RemoteSelfhostLicense | null;
|
||||
if (!payload) {
|
||||
return this.remoteSelfhostFallbackEntitlement(entitlement);
|
||||
}
|
||||
const expiresAt = this.remoteSelfhostLicenseExpiresAt(payload.endAt);
|
||||
if (
|
||||
payload.plan !== SubscriptionPlan.SelfHostedTeam ||
|
||||
payload.quantity < 1 ||
|
||||
!expiresAt
|
||||
) {
|
||||
const license = res.license;
|
||||
if (!license || license.plan !== SubscriptionPlan.SelfHostedTeam) {
|
||||
await this.markRemoteSelfhostLicenseNeedsReupload(
|
||||
entitlement,
|
||||
'Remote license health payload is invalid.'
|
||||
@@ -889,17 +862,17 @@ export class EntitlementService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validateKey =
|
||||
res.headers.get('x-next-validate-key') ?? metadata.validateKey;
|
||||
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(payload.quantity),
|
||||
quantity: this.normalizedQuantity(license.quantity),
|
||||
metadata: {
|
||||
...metadata,
|
||||
recurring: payload.recurring,
|
||||
recurring: license.recurring,
|
||||
validateKey,
|
||||
remoteValidated: true,
|
||||
errorCode: null,
|
||||
@@ -913,8 +886,8 @@ export class EntitlementService {
|
||||
.updateMany({
|
||||
where: { key: entitlement.subjectId },
|
||||
data: {
|
||||
quantity: this.normalizedQuantity(payload.quantity),
|
||||
recurring: payload.recurring,
|
||||
quantity: this.normalizedQuantity(license.quantity),
|
||||
recurring: license.recurring,
|
||||
validateKey,
|
||||
validatedAt: new Date(),
|
||||
expiredAt: expiresAt,
|
||||
@@ -950,14 +923,6 @@ export class EntitlementService {
|
||||
return cached.entitlement;
|
||||
}
|
||||
|
||||
private remoteSelfhostLicenseExpiresAt(endAt: unknown) {
|
||||
const expiresAt = new Date(endAt as string | number | Date);
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= new Date()) {
|
||||
return null;
|
||||
}
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
private async markRemoteSelfhostLicenseNeedsReupload(
|
||||
entitlement: Entitlement,
|
||||
reason: string
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AdminFeatureManagementResolver,
|
||||
UserFeatureResolver,
|
||||
} from './resolver';
|
||||
import { EarlyAccessType, FeatureService } from './service';
|
||||
import { FeatureService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [EntitlementModule],
|
||||
@@ -18,5 +18,5 @@ import { EarlyAccessType, FeatureService } from './service';
|
||||
})
|
||||
export class FeatureModule {}
|
||||
|
||||
export { EarlyAccessType, FeatureService };
|
||||
export { FeatureService };
|
||||
export { AvailableUserFeatureConfig } from './types';
|
||||
|
||||
@@ -4,11 +4,6 @@ import { Models } from '../../models';
|
||||
|
||||
const STAFF = ['@toeverything.info', '@affine.pro'];
|
||||
|
||||
export enum EarlyAccessType {
|
||||
App = 'app',
|
||||
AI = 'ai',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
protected logger = new Logger(FeatureService.name);
|
||||
@@ -32,15 +27,4 @@ export class FeatureService {
|
||||
addAdmin(userId: string) {
|
||||
return this.models.userFeature.add(userId, 'administrator', 'Admin user');
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
async isEarlyAccessUser(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return await this.models.userFeature.has(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,10 @@ import { Feature, UserFeatureName } from '../../models';
|
||||
@Injectable()
|
||||
export class AvailableUserFeatureConfig {
|
||||
availableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set([Feature.Admin, Feature.EarlyAccess, Feature.AIEarlyAccess]);
|
||||
return new Set([Feature.Admin]);
|
||||
}
|
||||
|
||||
configurableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set(
|
||||
env.selfhosted
|
||||
? [Feature.Admin]
|
||||
: [Feature.EarlyAccess, Feature.AIEarlyAccess, Feature.Admin]
|
||||
);
|
||||
return new Set([Feature.Admin]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Int,
|
||||
Mutation,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { Args, ID, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
MentionUserDocAccessDenied,
|
||||
@@ -45,16 +38,6 @@ export class UserNotificationResolver {
|
||||
return paginate(notifications, 'createdAt', pagination, totalCount);
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
description: 'Get user notification count',
|
||||
deprecationReason:
|
||||
'Use realtime subscription "notification.count.changed" instead.',
|
||||
})
|
||||
async notificationCount(@CurrentUser() me: UserType): Promise<number> {
|
||||
// DEPRECATED-0.26-COMPAT(realtime): remove after server no longer supports 0.26.x clients.
|
||||
return await this.service.countByUserId(me.id);
|
||||
}
|
||||
|
||||
@Mutation(() => ID, {
|
||||
description: 'mention user in a doc',
|
||||
})
|
||||
|
||||
@@ -155,7 +155,6 @@ test('quota service exposes history period in seconds', async t => {
|
||||
usedStorageQuota: 0,
|
||||
memberCount: 1,
|
||||
overcapacityMemberCount: 0,
|
||||
usedSize: 0,
|
||||
}).historyPeriod,
|
||||
'30 days'
|
||||
);
|
||||
|
||||
@@ -76,7 +76,6 @@ export class QuotaService {
|
||||
usedStorageQuota: Number(state.usedStorageQuota),
|
||||
memberCount: state.memberCount,
|
||||
overcapacityMemberCount: state.overcapacityMemberCount,
|
||||
usedSize: Number(state.usedStorageQuota),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -118,12 +118,4 @@ export class WorkspaceQuotaType implements Partial<WorkspaceQuota> {
|
||||
|
||||
@Field()
|
||||
humanReadable!: WorkspaceQuotaHumanReadableType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field(() => SafeIntResolver, {
|
||||
deprecationReason: 'use `usedStorageQuota` instead',
|
||||
})
|
||||
usedSize!: number;
|
||||
}
|
||||
|
||||
@@ -166,16 +166,6 @@ export class InviteResult {
|
||||
})
|
||||
inviteId?: string;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field(() => Boolean, {
|
||||
description: 'Invite email sent success',
|
||||
deprecationReason: 'Notification will be sent asynchronously',
|
||||
defaultValue: true,
|
||||
})
|
||||
sentSuccess?: boolean;
|
||||
|
||||
@Field(() => GraphQLJSONObject, {
|
||||
nullable: true,
|
||||
description: 'Invite error',
|
||||
|
||||
@@ -43,8 +43,6 @@ export enum FeatureType {
|
||||
export enum Feature {
|
||||
// user
|
||||
Admin = 'administrator',
|
||||
EarlyAccess = 'early_access',
|
||||
AIEarlyAccess = 'ai_early_access',
|
||||
UnlimitedCopilot = 'unlimited_copilot',
|
||||
FreePlan = 'free_plan_v1',
|
||||
ProPlan = 'pro_plan_v1',
|
||||
@@ -58,10 +56,8 @@ export enum Feature {
|
||||
|
||||
// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`?
|
||||
export const FeaturesShapes = {
|
||||
early_access: z.object({ whitelist: z.array(z.string()).readonly() }),
|
||||
unlimited_workspace: EMPTY_CONFIG,
|
||||
unlimited_copilot: EMPTY_CONFIG,
|
||||
ai_early_access: EMPTY_CONFIG,
|
||||
administrator: EMPTY_CONFIG,
|
||||
free_plan_v1: UserPlanQuotaConfig,
|
||||
pro_plan_v1: UserPlanQuotaConfig,
|
||||
@@ -72,8 +68,6 @@ export const FeaturesShapes = {
|
||||
|
||||
export type UserFeatureName = keyof Pick<
|
||||
typeof FeaturesShapes,
|
||||
| 'early_access'
|
||||
| 'ai_early_access'
|
||||
| 'unlimited_copilot'
|
||||
| 'administrator'
|
||||
| 'free_plan_v1'
|
||||
@@ -142,11 +136,6 @@ const TeamFeature = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const WhitelistFeature = {
|
||||
type: FeatureType.Feature,
|
||||
configs: { whitelist: [] },
|
||||
} as const;
|
||||
|
||||
const EmptyFeature = {
|
||||
type: FeatureType.Feature,
|
||||
configs: {},
|
||||
@@ -164,10 +153,8 @@ export const FeatureConfigs: {
|
||||
pro_plan_v1: ProFeature,
|
||||
lifetime_pro_plan_v1: LifetimeProFeature,
|
||||
team_plan_v1: TeamFeature,
|
||||
early_access: WhitelistFeature,
|
||||
unlimited_workspace: EmptyFeature,
|
||||
quota_exceeded_readonly_workspace_v1: EmptyFeature,
|
||||
unlimited_copilot: EmptyFeature,
|
||||
ai_early_access: EmptyFeature,
|
||||
administrator: EmptyFeature,
|
||||
};
|
||||
|
||||
@@ -9,8 +9,16 @@ import serverNativeModule, {
|
||||
type CanonicalStructuredRequestContract,
|
||||
type CapabilityAttachmentContract,
|
||||
type CapabilityModelCapability,
|
||||
type CommandResponse,
|
||||
type ImageInspection,
|
||||
type ImageInspectionOptions,
|
||||
type LicenseError,
|
||||
type LicenseHealthRequest,
|
||||
type LicenseInfo,
|
||||
type LicenseKeyRequest,
|
||||
type LicenseRecurringRequest,
|
||||
type LicenseResponse,
|
||||
type LicenseSeatsRequest,
|
||||
type LlmCoreMessage,
|
||||
type LlmEmbeddingRequestContract,
|
||||
type LlmImageRequestContract,
|
||||
@@ -20,6 +28,7 @@ import serverNativeModule, {
|
||||
type ModelConditionsContract,
|
||||
type ModelRegistryMatchResponse,
|
||||
type ModelRegistryResolveResponse,
|
||||
type PortalResponse,
|
||||
type PromptMessageContract,
|
||||
type PromptMetadataContract,
|
||||
type PromptMetadataResult,
|
||||
@@ -45,9 +54,18 @@ export type {
|
||||
AssertSafeUrlRequest,
|
||||
CapabilityAttachmentContract,
|
||||
CapabilityModelCapability,
|
||||
CommandResponse,
|
||||
ImageInspection,
|
||||
ImageInspectionOptions,
|
||||
LicenseError,
|
||||
LicenseHealthRequest,
|
||||
LicenseInfo,
|
||||
LicenseKeyRequest,
|
||||
LicenseRecurringRequest,
|
||||
LicenseResponse,
|
||||
LicenseSeatsRequest,
|
||||
ModelConditionsContract,
|
||||
PortalResponse,
|
||||
PromptMessageContract,
|
||||
PromptStructuredResponseContract,
|
||||
RemoteAttachmentFetchRequest,
|
||||
@@ -143,6 +161,13 @@ export const fetchRemoteAttachment = serverNativeModule.fetchRemoteAttachment;
|
||||
export const inferRemoteMimeType = serverNativeModule.inferRemoteMimeType;
|
||||
export const assertSafeUrl = serverNativeModule.assertSafeUrl;
|
||||
export const safeFetch = serverNativeModule.safeFetch;
|
||||
export const activateLicense = serverNativeModule.activateLicense;
|
||||
export const checkLicenseHealth = serverNativeModule.checkLicenseHealth;
|
||||
export const createCustomerPortal =
|
||||
serverNativeModule.createLicenseCustomerPortal;
|
||||
export const deactivateLicense = serverNativeModule.deactivateLicense;
|
||||
export const updateLicenseRecurring = serverNativeModule.updateLicenseRecurring;
|
||||
export const updateLicenseSeats = serverNativeModule.updateLicenseSeats;
|
||||
export const parseDoc = serverNativeModule.parseDoc;
|
||||
export const htmlSanitize = serverNativeModule.htmlSanitize;
|
||||
export const processImage = serverNativeModule.processImage;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { XMLParser } from 'fast-xml-parser';
|
||||
import { escape } from 'lodash-es';
|
||||
|
||||
import {
|
||||
assertSsrFSafeUrl,
|
||||
CalendarProviderRequestError,
|
||||
GraphqlBadRequest,
|
||||
safeFetch,
|
||||
SsrfBlockedError,
|
||||
} from '../../../base';
|
||||
import type {
|
||||
@@ -35,6 +35,8 @@ const XML_PARSER = new XMLParser({
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_REDIRECTS = 5;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
||||
const CALDAV_SAFE_FETCH_HEADERS = ['authorization', 'content-type', 'depth'];
|
||||
|
||||
type CalDAVCredentials = {
|
||||
username: string;
|
||||
@@ -106,9 +108,6 @@ const formatUtcForIcal = (iso: string) => {
|
||||
].join('');
|
||||
};
|
||||
|
||||
const isRedirectStatus = (status: number) =>
|
||||
[301, 302, 303, 307, 308].includes(status);
|
||||
|
||||
const splitHeaderTokens = (value: string) =>
|
||||
value
|
||||
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
||||
@@ -536,36 +535,24 @@ class CalDAVRequestPolicy {
|
||||
return this.config.calendar.caldav.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
||||
}
|
||||
|
||||
async fetch(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
redirects = 0
|
||||
): Promise<Response> {
|
||||
async fetch(url: string, init: RequestInit): Promise<Response> {
|
||||
await this.assertAllowedUrl(url);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
if (isRedirectStatus(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
if (redirects >= this.maxRedirects) {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_max_redirects',
|
||||
message: 'CalDAV request exceeded redirect limit.',
|
||||
});
|
||||
}
|
||||
const nextUrl = resolveHref(location, url);
|
||||
return this.fetch(nextUrl, init, redirects + 1);
|
||||
}
|
||||
try {
|
||||
return await safeFetch(url, init, {
|
||||
timeoutMs: this.timeoutMs,
|
||||
maxRedirects: this.maxRedirects,
|
||||
maxBytes: DEFAULT_MAX_RESPONSE_BYTES,
|
||||
allowedHeaders: CALDAV_SAFE_FETCH_HEADERS,
|
||||
allowedHosts: this.allowedHosts,
|
||||
allowHttp: this.allowInsecureHttp,
|
||||
allowPrivateTargetOrigin: !this.blockPrivateNetwork,
|
||||
});
|
||||
} catch (error) {
|
||||
const ssrfError = this.toGraphqlSsrfError(error);
|
||||
if (ssrfError) throw ssrfError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async assertAllowedUrl(urlValue: string) {
|
||||
@@ -599,52 +586,62 @@ class CalDAVRequestPolicy {
|
||||
message: 'CalDAV host is not allowed.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.blockPrivateNetwork) {
|
||||
return;
|
||||
private toGraphqlSsrfError(error: unknown) {
|
||||
if (!(error instanceof SsrfBlockedError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await assertSsrFSafeUrl(url);
|
||||
} catch (error) {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
const reason = String(error.data?.reason ?? '');
|
||||
const reason = String(error.data?.reason ?? '');
|
||||
|
||||
if (reason === 'blocked_ip') {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_private_network',
|
||||
message: 'CalDAV host is in a private network.',
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'unresolvable_hostname') {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_dns_failed',
|
||||
message: 'Unable to resolve CalDAV host.',
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'disallowed_protocol') {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_insecure_url',
|
||||
message: 'CalDAV URL must use https.',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
reason === 'invalid_url' ||
|
||||
reason === 'blocked_hostname' ||
|
||||
reason === 'url_has_credentials'
|
||||
) {
|
||||
throw new GraphqlBadRequest({
|
||||
code: 'caldav_invalid_url',
|
||||
message: 'CalDAV URL is invalid.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
if (reason === 'blocked_ip') {
|
||||
return new GraphqlBadRequest({
|
||||
code: 'caldav_private_network',
|
||||
message: 'CalDAV host is in a private network.',
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'unresolvable_hostname') {
|
||||
return new GraphqlBadRequest({
|
||||
code: 'caldav_dns_failed',
|
||||
message: 'Unable to resolve CalDAV host.',
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'disallowed_protocol') {
|
||||
return new GraphqlBadRequest({
|
||||
code: 'caldav_insecure_url',
|
||||
message: 'CalDAV URL must use https.',
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'too_many_redirects') {
|
||||
return new GraphqlBadRequest({
|
||||
code: 'caldav_max_redirects',
|
||||
message: 'CalDAV request exceeded redirect limit.',
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'host_not_allowed') {
|
||||
return new GraphqlBadRequest({
|
||||
code: 'caldav_host_blocked',
|
||||
message: 'CalDAV host is not allowed.',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
reason === 'invalid_url' ||
|
||||
reason === 'blocked_hostname' ||
|
||||
reason === 'url_has_credentials'
|
||||
) {
|
||||
return new GraphqlBadRequest({
|
||||
code: 'caldav_invalid_url',
|
||||
message: 'CalDAV URL is invalid.',
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import { BadRequest, Cache, CryptoHelper, metrics } from '../../../base';
|
||||
import {
|
||||
BadRequest,
|
||||
Cache,
|
||||
CryptoHelper,
|
||||
metrics,
|
||||
safeFetch,
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import type { CopilotProviderProfile } from '../config';
|
||||
import { ByokEntitlementPolicy } from './policy';
|
||||
@@ -103,6 +109,8 @@ type ByokProfileMeta = {
|
||||
|
||||
@Injectable()
|
||||
export class ByokService {
|
||||
private readonly probeFetch = safeFetch;
|
||||
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly crypto: CryptoHelper,
|
||||
@@ -755,22 +763,24 @@ export class ByokService {
|
||||
apiKey: string,
|
||||
endpoint: string | null
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
try {
|
||||
const request = this.buildProbeRequest(provider, apiKey, endpoint);
|
||||
const response = await fetch(request.url, {
|
||||
const request = this.buildProbeRequest(provider, apiKey, endpoint);
|
||||
const response = await this.probeFetch(
|
||||
request.url,
|
||||
{
|
||||
method: request.method,
|
||||
headers: request.headers as unknown as Record<string, string>,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(
|
||||
this.providerProbeFailureMessage(response.status)
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
maxRedirects: 3,
|
||||
maxBytes: 1024,
|
||||
allowedHeaders: Object.keys(request.headers),
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(
|
||||
this.providerProbeFailureMessage(response.status)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,12 @@ export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
|
||||
|
||||
@OnEvent('workspace.file.embed.finished', { suppressError: true })
|
||||
async onFileEmbedFinished(payload: Events['workspace.file.embed.finished']) {
|
||||
await this.publishContext(payload.contextId, 'finished');
|
||||
await this.publishEmbeddingProgress(payload, 'finished');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.file.embed.failed', { suppressError: true })
|
||||
async onFileEmbedFailed(payload: Events['workspace.file.embed.failed']) {
|
||||
await this.publishContext(payload.contextId, 'failed');
|
||||
await this.publishEmbeddingProgress(payload, 'failed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blob.embed.finished', { suppressError: true })
|
||||
@@ -90,11 +90,29 @@ export class CopilotEmbeddingRealtimeProvider implements OnModuleInit {
|
||||
) {
|
||||
if (!this.publisher) return;
|
||||
const context = await this.context.get(contextId);
|
||||
this.publishWorkspace(context.workspaceId, reason);
|
||||
}
|
||||
|
||||
private async publishEmbeddingProgress(
|
||||
payload:
|
||||
| Events['workspace.file.embed.finished']
|
||||
| Events['workspace.file.embed.failed'],
|
||||
reason: 'finished' | 'failed'
|
||||
) {
|
||||
if (!this.publisher) return;
|
||||
if (payload.contextId) {
|
||||
await this.publishContext(payload.contextId, reason);
|
||||
return;
|
||||
}
|
||||
this.publishWorkspace(payload.workspaceId, reason);
|
||||
}
|
||||
|
||||
private publishWorkspace(workspaceId: string, reason: 'finished' | 'failed') {
|
||||
this.publisher.publish(
|
||||
'workspace.embedding.progress.changed',
|
||||
{ workspaceId: context.workspaceId },
|
||||
{ workspaceId },
|
||||
{ reason },
|
||||
{ room: workspaceEmbeddingRoom(context.workspaceId) }
|
||||
{ room: workspaceEmbeddingRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -354,6 +354,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
fileId,
|
||||
chunkSize,
|
||||
}: Events['workspace.file.embed.finished']) {
|
||||
if (!contextId) return;
|
||||
const context = await this.get(contextId);
|
||||
await context.saveFileRecord(fileId, file => ({
|
||||
...(file as ContextFile),
|
||||
@@ -368,6 +369,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
fileId,
|
||||
error,
|
||||
}: Events['workspace.file.embed.failed']) {
|
||||
if (!contextId) return;
|
||||
const context = await this.get(contextId);
|
||||
await context.saveFileRecord(fileId, file => ({
|
||||
...(file as ContextFile),
|
||||
|
||||
@@ -300,21 +300,19 @@ export class CopilotEmbeddingJob {
|
||||
}
|
||||
}
|
||||
|
||||
if (contextId) {
|
||||
this.event.emit('workspace.file.embed.finished', {
|
||||
contextId,
|
||||
fileId,
|
||||
chunkSize: total,
|
||||
});
|
||||
}
|
||||
this.event.emit('workspace.file.embed.finished', {
|
||||
contextId,
|
||||
workspaceId,
|
||||
fileId,
|
||||
chunkSize: total,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (contextId) {
|
||||
this.event.emit('workspace.file.embed.failed', {
|
||||
contextId,
|
||||
fileId,
|
||||
error: mapAnyError(error).message,
|
||||
});
|
||||
}
|
||||
this.event.emit('workspace.file.embed.failed', {
|
||||
contextId,
|
||||
workspaceId,
|
||||
fileId,
|
||||
error: mapAnyError(error).message,
|
||||
});
|
||||
|
||||
// passthrough error to job queue
|
||||
throw error;
|
||||
|
||||
@@ -43,13 +43,15 @@ declare global {
|
||||
};
|
||||
|
||||
'workspace.file.embed.finished': {
|
||||
contextId: string;
|
||||
contextId?: string;
|
||||
workspaceId: string;
|
||||
fileId: string;
|
||||
chunkSize: number;
|
||||
};
|
||||
|
||||
'workspace.file.embed.failed': {
|
||||
contextId: string;
|
||||
contextId?: string;
|
||||
workspaceId: string;
|
||||
fileId: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
declare global {
|
||||
interface AppConfigSchema {
|
||||
customerIo: {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
defineModuleConfig('customerIo', {
|
||||
enabled: {
|
||||
desc: 'Enable customer.io integration',
|
||||
default: false,
|
||||
},
|
||||
token: {
|
||||
desc: 'Customer.io token',
|
||||
default: '',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CustomerIoService } from './service';
|
||||
|
||||
@Module({
|
||||
providers: [CustomerIoService],
|
||||
})
|
||||
export class CustomerIoModule {}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config, OnEvent } from '../../base';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerIoService {
|
||||
#fetch: ((url: string, options?: RequestInit) => Promise<Response>) | null =
|
||||
null;
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@OnEvent('config.init')
|
||||
setup() {
|
||||
const { enabled, token } = this.config.customerIo;
|
||||
if (enabled && token) {
|
||||
this.#fetch = (url, options) => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Basic ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
} else {
|
||||
this.#fetch = null;
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('config.changed')
|
||||
onConfigChanged(event: Events['config.changed']) {
|
||||
if (event.updates.customerIo) {
|
||||
this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('user.created')
|
||||
@OnEvent('user.updated')
|
||||
async onUserUpdated(user: Events['user.updated'] | Events['user.created']) {
|
||||
await this.#fetch?.(
|
||||
`https://track.customer.io/api/v1/customers/${user.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
created_at: Number(user.createdAt) / 1000,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: Events['user.deleted']) {
|
||||
if (user.emailVerifiedAt) {
|
||||
// suppress email if email is verified
|
||||
await this.#fetch?.(
|
||||
`https://track.customer.io/api/v1/customers/${user.email}/suppress`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await this.#fetch?.(
|
||||
`https://track.customer.io/api/v1/customers/${user.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+35
@@ -1803,6 +1803,41 @@ test('should parse es query with custom term mapping field work', async t => {
|
||||
t.snapshot(result3);
|
||||
});
|
||||
|
||||
test('should parse es query with parent and nested must_not work', async t => {
|
||||
const nestedMust = {
|
||||
must: [
|
||||
{ term: { workspace_id: 'workspaceId1' } },
|
||||
{ bool: { must_not: { term: { doc_id: 'docId1' } } } },
|
||||
],
|
||||
};
|
||||
const parentMustNot = { term: { doc_id: 'docId2' } };
|
||||
const expectedMust = [{ equals: { workspace_id: 'workspaceId1' } }];
|
||||
const expectedMustNot = ['docId1', 'docId2'];
|
||||
|
||||
const queryWithParentMustNotFirst = {
|
||||
bool: { must_not: parentMustNot, must: nestedMust.must },
|
||||
};
|
||||
const queryWithParentMustNotLast = {
|
||||
bool: { must: nestedMust.must, must_not: parentMustNot },
|
||||
};
|
||||
|
||||
// @ts-expect-error use private method
|
||||
const result = searchProvider.parseESQuery(queryWithParentMustNotFirst);
|
||||
// @ts-expect-error use private method
|
||||
const result2 = searchProvider.parseESQuery(queryWithParentMustNotLast);
|
||||
|
||||
t.deepEqual(result.bool.must, expectedMust);
|
||||
t.deepEqual(result2.bool.must, expectedMust);
|
||||
t.deepEqual(
|
||||
result.bool.must_not.map((clause: any) => clause.equals.doc_id).sort(),
|
||||
expectedMustNot
|
||||
);
|
||||
t.deepEqual(
|
||||
result2.bool.must_not.map((clause: any) => clause.equals.doc_id).sort(),
|
||||
expectedMustNot
|
||||
);
|
||||
});
|
||||
|
||||
test('should parse es query exists work', async t => {
|
||||
const query = {
|
||||
exists: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
InternalServerError,
|
||||
InvalidSearchProviderRequest,
|
||||
safeFetch,
|
||||
} from '../../../base';
|
||||
import { SearchProviderType } from '../config';
|
||||
import { DateFieldNames, SearchTable, SearchTableUniqueId } from '../tables';
|
||||
@@ -61,6 +62,14 @@ interface ESAggregateResponse extends ESSearchResponse {
|
||||
};
|
||||
}
|
||||
|
||||
const INDEXER_FETCH_OPTIONS = {
|
||||
timeoutMs: 30_000,
|
||||
maxRedirects: 0,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
allowedHeaders: ['authorization', 'content-type'],
|
||||
allowPrivateTargetOrigin: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ElasticsearchProvider extends SearchProvider {
|
||||
type = SearchProviderType.Elasticsearch;
|
||||
@@ -278,11 +287,11 @@ export class ElasticsearchProvider extends SearchProvider {
|
||||
} else if (this.config.provider.password) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.config.provider.username}:${this.config.provider.password}`).toString('base64')}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
const response = await safeFetch(
|
||||
url,
|
||||
{ method, body, headers },
|
||||
INDEXER_FETCH_OPTIONS
|
||||
);
|
||||
const data = await response.json();
|
||||
if (ignoreErrorStatus?.includes(response.status)) {
|
||||
return data;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { omit } from 'lodash-es';
|
||||
|
||||
import { InternalServerError } from '../../../base';
|
||||
import { InternalServerError, safeFetch } from '../../../base';
|
||||
import { SearchProviderType } from '../config';
|
||||
import { SearchTable } from '../tables';
|
||||
import {
|
||||
@@ -32,6 +32,14 @@ interface MSSearchResponse {
|
||||
scroll: string;
|
||||
}
|
||||
|
||||
const INDEXER_FETCH_OPTIONS = {
|
||||
timeoutMs: 30_000,
|
||||
maxRedirects: 0,
|
||||
maxBytes: 50 * 1024 * 1024,
|
||||
allowedHeaders: ['authorization', 'content-type'],
|
||||
allowPrivateTargetOrigin: true,
|
||||
};
|
||||
|
||||
const SupportIndexedAttributes = [
|
||||
'flavour',
|
||||
'parent_flavour',
|
||||
@@ -58,6 +66,11 @@ const ConvertEmptyStringToNullValueFields = new Set([
|
||||
'parent_flavour',
|
||||
]);
|
||||
|
||||
function boolClauses(value: unknown) {
|
||||
if (value === undefined) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ManticoresearchProvider extends ElasticsearchProvider {
|
||||
override type = SearchProviderType.Manticoresearch;
|
||||
@@ -282,7 +295,9 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
|
||||
for (const occur in query.bool) {
|
||||
const conditions = query.bool[occur];
|
||||
if (Array.isArray(conditions)) {
|
||||
node.bool[occur] = [];
|
||||
const parsedConditions: Record<string, any>[] = [];
|
||||
const existing = node.bool[occur];
|
||||
node.bool[occur] = parsedConditions;
|
||||
// { must: [ { term: [Object] }, { bool: [Object] } ] }
|
||||
// {
|
||||
// must: [ { term: [Object] }, { term: [Object] }, { bool: [Object] } ]
|
||||
@@ -290,16 +305,41 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
|
||||
for (const item of conditions) {
|
||||
this.parseESQuery(item, {
|
||||
...options,
|
||||
parentNodes: node.bool[occur],
|
||||
parentNodes: parsedConditions,
|
||||
});
|
||||
}
|
||||
if (existing !== undefined) {
|
||||
node.bool[occur] = [...boolClauses(existing), ...parsedConditions];
|
||||
}
|
||||
if (occur === 'must') {
|
||||
for (let i = node.bool.must.length - 1; i >= 0; i--) {
|
||||
const child = node.bool.must[i];
|
||||
const childBool = child.bool;
|
||||
if (
|
||||
childBool &&
|
||||
Object.keys(childBool).length === 1 &&
|
||||
childBool.must_not !== undefined
|
||||
) {
|
||||
node.bool.must.splice(i, 1);
|
||||
node.bool.must_not = [
|
||||
...boolClauses(node.bool.must_not),
|
||||
...boolClauses(childBool.must_not),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// {
|
||||
// must_not: { term: { doc_id: 'docId' } }
|
||||
// }
|
||||
node.bool[occur] = this.parseESQuery(conditions, {
|
||||
const parsed = this.parseESQuery(conditions, {
|
||||
termMappingField: options?.termMappingField,
|
||||
});
|
||||
if (node.bool[occur] !== undefined) {
|
||||
node.bool[occur] = [...boolClauses(node.bool[occur]), parsed];
|
||||
} else {
|
||||
node.bool[occur] = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (query.term) {
|
||||
@@ -460,11 +500,11 @@ export class ManticoresearchProvider extends ElasticsearchProvider {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.config.provider.username}:${this.config.provider.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: sql,
|
||||
headers,
|
||||
});
|
||||
const response = await safeFetch(
|
||||
url,
|
||||
{ method: 'POST', body: sql, headers },
|
||||
INDEXER_FETCH_OPTIONS
|
||||
);
|
||||
const text = (await response.text()).trim();
|
||||
if (!response.ok) {
|
||||
this.logger.error(`failed to execute SQL "${sql}", response: ${text}`);
|
||||
|
||||
@@ -17,21 +17,34 @@ import { EntitlementService } from '../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { Models } from '../../models';
|
||||
import { ResolvedEntitlement, resolveEntitlementV1 } from '../../native';
|
||||
import {
|
||||
activateLicense,
|
||||
checkLicenseHealth,
|
||||
type CommandResponse,
|
||||
createCustomerPortal,
|
||||
deactivateLicense,
|
||||
type LicenseError,
|
||||
type LicenseResponse,
|
||||
type PortalResponse,
|
||||
type ResolvedEntitlement,
|
||||
resolveEntitlementV1,
|
||||
updateLicenseRecurring,
|
||||
updateLicenseSeats,
|
||||
} from '../../native';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from '../payment/types';
|
||||
|
||||
interface License {
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
endAt: number;
|
||||
}
|
||||
|
||||
const AFFINE_PRO_REQUEST_TIMEOUT = 10_000;
|
||||
export const licenseClient = {
|
||||
activate: activateLicense,
|
||||
checkHealth: checkLicenseHealth,
|
||||
createCustomerPortal,
|
||||
deactivate: deactivateLicense,
|
||||
updateRecurring: updateLicenseRecurring,
|
||||
updateSeats: updateLicenseSeats,
|
||||
};
|
||||
|
||||
export interface LicensePreview {
|
||||
id: string;
|
||||
@@ -157,31 +170,27 @@ export class LicenseService {
|
||||
throw new WorkspaceLicenseAlreadyExists();
|
||||
}
|
||||
|
||||
const data = await this.fetchAffinePro<License>(
|
||||
`/api/team/licenses/${licenseKey}/activate`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
const data = this.remoteLicense(
|
||||
await licenseClient.activate({ licenseKey })
|
||||
);
|
||||
|
||||
const validatedAt = new Date();
|
||||
const expiresAt = this.remoteLicenseExpiresAt(data);
|
||||
const validateKey = data.res.headers.get('x-next-validate-key') ?? '';
|
||||
const expiresAt = new Date(data.expiresAt);
|
||||
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
plan: data.plan as SubscriptionPlan,
|
||||
recurring: data.recurring as SubscriptionRecurring,
|
||||
quantity: data.quantity,
|
||||
});
|
||||
await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
workspaceId,
|
||||
licenseKey,
|
||||
recurring: data.recurring,
|
||||
recurring: data.recurring as SubscriptionRecurring,
|
||||
quantity: data.quantity,
|
||||
expiresAt,
|
||||
validatedAt,
|
||||
validateKey,
|
||||
validateKey: data.validateKey,
|
||||
});
|
||||
|
||||
return this.db.installedLicense.upsert({
|
||||
@@ -189,9 +198,9 @@ export class LicenseService {
|
||||
update: {
|
||||
key: licenseKey,
|
||||
quantity: data.quantity,
|
||||
recurring: data.recurring,
|
||||
recurring: data.recurring as SubscriptionRecurring,
|
||||
variant: null,
|
||||
validateKey,
|
||||
validateKey: data.validateKey,
|
||||
validatedAt,
|
||||
expiredAt: expiresAt,
|
||||
license: null,
|
||||
@@ -200,9 +209,9 @@ export class LicenseService {
|
||||
workspaceId,
|
||||
key: licenseKey,
|
||||
quantity: data.quantity,
|
||||
recurring: data.recurring,
|
||||
recurring: data.recurring as SubscriptionRecurring,
|
||||
variant: null,
|
||||
validateKey,
|
||||
validateKey: data.validateKey,
|
||||
validatedAt,
|
||||
expiredAt: expiresAt,
|
||||
license: null,
|
||||
@@ -242,18 +251,18 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
async deactivateTeamLicense(license: InstalledLicense) {
|
||||
await this.fetchAffinePro(`/api/team/licenses/${license.key}/deactivate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
this.remoteCommand(
|
||||
await licenseClient.deactivate({ licenseKey: license.key })
|
||||
);
|
||||
}
|
||||
|
||||
async updateTeamRecurring(key: string, recurring: SubscriptionRecurring) {
|
||||
await this.fetchAffinePro(`/api/team/licenses/${key}/recurring`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
this.remoteCommand(
|
||||
await licenseClient.updateRecurring({
|
||||
licenseKey: key,
|
||||
recurring,
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async createCustomerPortal(workspaceId: string) {
|
||||
@@ -267,12 +276,12 @@ export class LicenseService {
|
||||
throw new LicenseNotFound();
|
||||
}
|
||||
|
||||
return this.fetchAffinePro<{ url: string }>(
|
||||
`/api/team/licenses/${license.key}/create-customer-portal`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
const portal = this.remotePortal(
|
||||
await licenseClient.createCustomerPortal({
|
||||
licenseKey: license.key,
|
||||
})
|
||||
);
|
||||
return { url: portal.url };
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated')
|
||||
@@ -301,12 +310,12 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
const count = await this.models.workspaceUser.chargedCount(workspaceId);
|
||||
await this.fetchAffinePro(`/api/team/licenses/${license.key}/seats`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
this.remoteCommand(
|
||||
await licenseClient.updateSeats({
|
||||
licenseKey: license.key,
|
||||
seats: count,
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// stripe payment is async, we can't directly the charge result in update calling
|
||||
await this.waitUntilLicenseUpdated(license, count);
|
||||
@@ -356,30 +365,28 @@ export class LicenseService {
|
||||
|
||||
private async revalidateRecurringLicense(license: InstalledLicense) {
|
||||
try {
|
||||
const res = await this.fetchAffinePro<License>(
|
||||
`/api/team/licenses/${license.key}/health`,
|
||||
{
|
||||
headers: {
|
||||
'x-validate-key': license.validateKey,
|
||||
},
|
||||
}
|
||||
const res = this.remoteLicense(
|
||||
await licenseClient.checkHealth({
|
||||
licenseKey: license.key,
|
||||
validateKey: license.validateKey,
|
||||
})
|
||||
);
|
||||
|
||||
const validatedAt = new Date();
|
||||
const expiresAt = this.remoteLicenseExpiresAt(res);
|
||||
const validateKey = res.res.headers.get('x-next-validate-key') ?? '';
|
||||
const expiresAt = new Date(res.expiresAt);
|
||||
const validateKey = res.validateKey || license.validateKey;
|
||||
|
||||
this.event.emit('workspace.subscription.activated', {
|
||||
workspaceId: license.workspaceId,
|
||||
plan: res.plan,
|
||||
recurring: res.recurring,
|
||||
plan: res.plan as SubscriptionPlan,
|
||||
recurring: res.recurring as SubscriptionRecurring,
|
||||
quantity: res.quantity,
|
||||
});
|
||||
await this.db.installedLicense.update({
|
||||
where: { key: license.key },
|
||||
data: {
|
||||
quantity: res.quantity,
|
||||
recurring: res.recurring,
|
||||
recurring: res.recurring as SubscriptionRecurring,
|
||||
validateKey,
|
||||
validatedAt,
|
||||
expiredAt: expiresAt,
|
||||
@@ -390,7 +397,7 @@ export class LicenseService {
|
||||
await this.entitlement.upsertFromSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: res.recurring,
|
||||
recurring: res.recurring as SubscriptionRecurring,
|
||||
quantity: res.quantity,
|
||||
expiresAt,
|
||||
validatedAt,
|
||||
@@ -401,7 +408,7 @@ export class LicenseService {
|
||||
await this.entitlement.upsertFromValidatedSelfhostLicense({
|
||||
workspaceId: license.workspaceId,
|
||||
licenseKey: license.key,
|
||||
recurring: res.recurring,
|
||||
recurring: res.recurring as SubscriptionRecurring,
|
||||
quantity: res.quantity,
|
||||
expiresAt,
|
||||
validatedAt,
|
||||
@@ -430,55 +437,38 @@ export class LicenseService {
|
||||
}
|
||||
}
|
||||
|
||||
private remoteLicenseExpiresAt(license: Pick<License, 'endAt'>) {
|
||||
const expiresAt = new Date(license.endAt);
|
||||
if (!Number.isFinite(expiresAt.getTime()) || expiresAt <= new Date()) {
|
||||
throw new LicenseExpired();
|
||||
private remoteLicense(response: LicenseResponse) {
|
||||
this.throwRemoteLicenseError(response.error);
|
||||
if (!response.license) {
|
||||
throw new InternalServerError('Invalid AFFiNE Pro license response.');
|
||||
}
|
||||
return expiresAt;
|
||||
return response.license;
|
||||
}
|
||||
|
||||
private async fetchAffinePro<T = any>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T & { res: Response }> {
|
||||
const endpoint =
|
||||
process.env.AFFINE_PRO_SERVER_ENDPOINT ?? 'https://app.affine.pro';
|
||||
private remoteCommand(response: CommandResponse) {
|
||||
this.throwRemoteLicenseError(response.error);
|
||||
}
|
||||
|
||||
private remotePortal(response: PortalResponse) {
|
||||
this.throwRemoteLicenseError(response.error);
|
||||
if (!response.url) {
|
||||
throw new InternalServerError('Invalid AFFiNE Pro portal response.');
|
||||
}
|
||||
return { url: response.url };
|
||||
}
|
||||
|
||||
private throwRemoteLicenseError(
|
||||
error?: LicenseError | null
|
||||
): asserts error is null | undefined {
|
||||
if (!error) return;
|
||||
try {
|
||||
const signal =
|
||||
init?.signal ??
|
||||
(AbortSignal.timeout
|
||||
? AbortSignal.timeout(AFFINE_PRO_REQUEST_TIMEOUT)
|
||||
: undefined);
|
||||
const res = await fetch(endpoint + path, {
|
||||
...init,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json()) as UserFriendlyError;
|
||||
throw UserFriendlyError.fromUserFriendlyErrorJSON(body);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as T;
|
||||
return {
|
||||
...data,
|
||||
res,
|
||||
};
|
||||
throw UserFriendlyError.fromUserFriendlyErrorJSON(JSON.parse(error.body));
|
||||
} catch (e) {
|
||||
if (e instanceof UserFriendlyError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new InternalServerError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: 'Failed to contact with https://app.affine.pro'
|
||||
'Failed to contact with https://app.affine.pro'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
InvalidOauthCallbackCode,
|
||||
InvalidOauthResponse,
|
||||
OnEvent,
|
||||
safeFetch,
|
||||
} from '../../../base';
|
||||
import { OAuthProviderName } from '../config';
|
||||
import { OAuthProviderFactory } from '../factory';
|
||||
@@ -83,10 +84,16 @@ export abstract class OAuthProvider {
|
||||
init?: RequestInit,
|
||||
options?: { treatServerErrorAsInvalid?: boolean }
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { ...init?.headers, Accept: 'application/json' },
|
||||
});
|
||||
const response = await safeFetch(
|
||||
url,
|
||||
{ ...init, headers: { ...init?.headers, Accept: 'application/json' } },
|
||||
{
|
||||
timeoutMs: 10_000,
|
||||
maxRedirects: 3,
|
||||
maxBytes: 1024 * 1024,
|
||||
allowedHeaders: ['authorization', 'content-type', 'accept'],
|
||||
}
|
||||
);
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose';
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
customFetch,
|
||||
type JWTPayload,
|
||||
jwtVerify,
|
||||
} from 'jose';
|
||||
import { omit } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -7,6 +12,7 @@ import {
|
||||
ExponentialBackoffScheduler,
|
||||
InvalidAuthState,
|
||||
InvalidOauthResponse,
|
||||
safeFetch,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
|
||||
@@ -59,12 +65,19 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
|
||||
|
||||
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
|
||||
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
|
||||
const OIDC_FETCH_OPTIONS = {
|
||||
timeoutMs: 10_000,
|
||||
maxRedirects: 3,
|
||||
maxBytes: 1024 * 1024,
|
||||
allowedHeaders: ['accept'],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
||||
override provider = OAuthProviderName.OIDC;
|
||||
#endpoints: OIDCConfiguration | null = null;
|
||||
#jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
private readonly oidcFetch = safeFetch;
|
||||
readonly #retryScheduler = new ExponentialBackoffScheduler({
|
||||
baseDelayMs: OIDC_DISCOVERY_INITIAL_RETRY_DELAY,
|
||||
maxDelayMs: OIDC_DISCOVERY_MAX_RETRY_DELAY,
|
||||
@@ -132,12 +145,10 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
const res = await this.oidcFetch(
|
||||
`${config.issuer}/.well-known/openid-configuration`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } },
|
||||
OIDC_FETCH_OPTIONS
|
||||
);
|
||||
|
||||
if (generation !== this.#validationGeneration) {
|
||||
@@ -163,7 +174,10 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
this.#endpoints = configuration;
|
||||
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri));
|
||||
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), {
|
||||
[customFetch]: (url, init) =>
|
||||
this.oidcFetch(url, init, OIDC_FETCH_OPTIONS),
|
||||
});
|
||||
this.#retryScheduler.reset();
|
||||
super.setup();
|
||||
} catch (e) {
|
||||
|
||||
@@ -27,14 +27,6 @@ declare global {
|
||||
payment: {
|
||||
enabled: boolean;
|
||||
showLifetimePrice: boolean;
|
||||
/**
|
||||
* @deprecated use payment.stripe.apiKey
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @deprecated use payment.stripe.webhookKey
|
||||
*/
|
||||
webhookKey: string;
|
||||
stripe: ConfigItem<
|
||||
{
|
||||
/** Preferred place for Stripe API key */
|
||||
@@ -70,16 +62,6 @@ defineModuleConfig('payment', {
|
||||
desc: 'Whether enable lifetime price and allow user to pay for it.',
|
||||
default: true,
|
||||
},
|
||||
apiKey: {
|
||||
desc: '[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.',
|
||||
default: '',
|
||||
env: 'STRIPE_API_KEY',
|
||||
},
|
||||
webhookKey: {
|
||||
desc: '[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.',
|
||||
default: '',
|
||||
env: 'STRIPE_WEBHOOK_KEY',
|
||||
},
|
||||
stripe: {
|
||||
desc: 'Stripe sdk options and credentials',
|
||||
default: {
|
||||
|
||||
@@ -19,9 +19,7 @@ export class StripeWebhookController {
|
||||
@Public()
|
||||
@Post('/webhook')
|
||||
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
|
||||
const nestedWebhookKey = this.config.payment.stripe?.webhookKey;
|
||||
const legacyWebhookKey = this.config.payment.webhookKey;
|
||||
const webhookKey = nestedWebhookKey || legacyWebhookKey || '';
|
||||
const webhookKey = this.config.payment.stripe?.webhookKey || '';
|
||||
// Retrieve the event by verifying the signature using the raw body and secret.
|
||||
const signature = req.headers['stripe-signature'];
|
||||
try {
|
||||
|
||||
@@ -256,19 +256,21 @@ export abstract class SubscriptionManager {
|
||||
}
|
||||
|
||||
async getPrice(lookupKey: LookupKey): Promise<KnownStripePrice | null> {
|
||||
let key: string;
|
||||
try {
|
||||
key = encodeLookupKey(lookupKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prices = await this.stripe.prices.list({
|
||||
lookup_keys: [encodeLookupKey(lookupKey)],
|
||||
lookup_keys: [key],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const price = prices.data[0];
|
||||
|
||||
return price
|
||||
? {
|
||||
lookupKey,
|
||||
price,
|
||||
}
|
||||
: null;
|
||||
return price ? { lookupKey, price } : null;
|
||||
}
|
||||
|
||||
protected async getCouponFromPromotionCode(
|
||||
|
||||
@@ -7,23 +7,18 @@ import { z } from 'zod';
|
||||
import {
|
||||
Config,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
InvalidCheckoutParameters,
|
||||
ManagedByAppStoreOrPlay,
|
||||
Mutex,
|
||||
OneMonth,
|
||||
OnEvent,
|
||||
OneYear,
|
||||
SubscriptionAlreadyExists,
|
||||
SubscriptionPlanNotFound,
|
||||
TooManyRequest,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { EarlyAccessType, FeatureService } from '../../../core/features';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
CouponType,
|
||||
KnownStripeInvoice,
|
||||
KnownStripePrice,
|
||||
KnownStripeSubscription,
|
||||
@@ -32,7 +27,6 @@ import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from '../types';
|
||||
import {
|
||||
activeSubscriptionWhere,
|
||||
@@ -42,11 +36,8 @@ import {
|
||||
} from './common';
|
||||
|
||||
interface PriceStrategyStatus {
|
||||
proEarlyAccess: boolean;
|
||||
aiEarlyAccess: boolean;
|
||||
proSubscribed: boolean;
|
||||
aiSubscribed: boolean;
|
||||
onetime: boolean;
|
||||
}
|
||||
|
||||
export const UserSubscriptionIdentity = z.object({
|
||||
@@ -67,7 +58,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
stripeProvider: StripeFactory,
|
||||
db: PrismaClient,
|
||||
private readonly config: Config,
|
||||
private readonly feature: FeatureService,
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper,
|
||||
private readonly mutex: Mutex,
|
||||
@@ -78,22 +68,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
async filterPrices(
|
||||
prices: KnownStripePrice[],
|
||||
customer?: UserStripeCustomer
|
||||
_customer?: UserStripeCustomer
|
||||
) {
|
||||
const strategyStatus = customer
|
||||
? await this.strategyStatus(customer)
|
||||
: {
|
||||
proEarlyAccess: false,
|
||||
aiEarlyAccess: false,
|
||||
proSubscribed: false,
|
||||
aiSubscribed: false,
|
||||
onetime: false,
|
||||
};
|
||||
|
||||
const availablePrices: KnownStripePrice[] = [];
|
||||
|
||||
for (const price of prices) {
|
||||
if (await this.isPriceAvailable(price, strategyStatus)) {
|
||||
if (await this.isPriceAvailable(price)) {
|
||||
availablePrices.push(price);
|
||||
}
|
||||
}
|
||||
@@ -107,8 +87,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
{ user }: z.infer<typeof UserSubscriptionCheckoutArgs>
|
||||
) {
|
||||
if (
|
||||
lookupKey.plan !== SubscriptionPlan.Pro &&
|
||||
lookupKey.plan !== SubscriptionPlan.AI
|
||||
(lookupKey.plan !== SubscriptionPlan.Pro &&
|
||||
lookupKey.plan !== SubscriptionPlan.AI) ||
|
||||
lookupKey.variant !== null
|
||||
) {
|
||||
throw new InvalidCheckoutParameters();
|
||||
}
|
||||
@@ -125,15 +106,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
active &&
|
||||
// do not allow to re-subscribe unless
|
||||
!(
|
||||
/* current subscription is a onetime subscription and so as the one that's checking out */
|
||||
(
|
||||
(active.variant === SubscriptionVariant.Onetime &&
|
||||
lookupKey.variant === SubscriptionVariant.Onetime) ||
|
||||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
|
||||
(active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
active.variant !== SubscriptionVariant.Onetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime)
|
||||
)
|
||||
active.recurring !== SubscriptionRecurring.Lifetime &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
)
|
||||
) {
|
||||
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
|
||||
@@ -141,12 +115,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
const customer = await this.getOrCreateCustomer(user.id);
|
||||
const strategy = await this.strategyStatus(customer);
|
||||
const price = await this.autoPrice(lookupKey, strategy);
|
||||
const price = await this.getPrice(lookupKey);
|
||||
|
||||
if (
|
||||
!price ||
|
||||
!(await this.isPriceAvailable(price, { ...strategy, onetime: true }))
|
||||
) {
|
||||
if (!price || !(await this.isPriceAvailable(price))) {
|
||||
throw new SubscriptionPlanNotFound({
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
@@ -154,10 +125,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
const discounts = await (async () => {
|
||||
const coupon = await this.getBuildInCoupon(customer, price);
|
||||
if (coupon) {
|
||||
return { discounts: [{ coupon }] };
|
||||
} else if (params.coupon) {
|
||||
if (params.coupon) {
|
||||
const couponId = await this.getCouponFromPromotionCode(
|
||||
params.coupon,
|
||||
customer
|
||||
@@ -179,10 +147,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// mode: 'subscription' or 'payment' for lifetime and onetime payment
|
||||
// mode: 'subscription' or 'payment' for lifetime payment
|
||||
const mode =
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
|
||||
lookupKey.variant === SubscriptionVariant.Onetime
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
? {
|
||||
mode: 'payment' as const,
|
||||
invoice_creation: {
|
||||
@@ -339,37 +306,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async getBuildInCoupon(
|
||||
customer: UserStripeCustomer,
|
||||
price: KnownStripePrice
|
||||
) {
|
||||
const strategyStatus = await this.strategyStatus(customer);
|
||||
|
||||
// onetime price is allowed for checkout
|
||||
strategyStatus.onetime = true;
|
||||
|
||||
if (!(await this.isPriceAvailable(price, strategyStatus))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let coupon: CouponType | undefined;
|
||||
|
||||
if (price.lookupKey.variant === SubscriptionVariant.EA) {
|
||||
if (price.lookupKey.plan === SubscriptionPlan.Pro) {
|
||||
coupon = CouponType.ProEarlyAccessOneYearFree;
|
||||
} else if (price.lookupKey.plan === SubscriptionPlan.AI) {
|
||||
coupon = CouponType.AIEarlyAccessOneYearFree;
|
||||
}
|
||||
} else if (price.lookupKey.plan === SubscriptionPlan.AI) {
|
||||
const { proEarlyAccess, aiSubscribed } = strategyStatus;
|
||||
if (proEarlyAccess && !aiSubscribed) {
|
||||
coupon = CouponType.ProEarlyAccessAIOneYearFree;
|
||||
}
|
||||
}
|
||||
|
||||
return coupon;
|
||||
}
|
||||
|
||||
async saveInvoice(knownInvoice: KnownStripeInvoice) {
|
||||
const { userId, lookupKey, stripeInvoice } = knownInvoice;
|
||||
this.assertUserIdExists(userId);
|
||||
@@ -387,11 +323,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
},
|
||||
});
|
||||
|
||||
// onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system
|
||||
// we track the deals by invoice only.
|
||||
// Lifetime subscription does not get involved with the Stripe subscription system.
|
||||
// We track the deal by invoice only.
|
||||
if (stripeInvoice.status === 'paid') {
|
||||
await using lock = await this.mutex.acquire(
|
||||
`redeem-onetime-subscription:${stripeInvoice.id}`
|
||||
`redeem-lifetime-subscription:${stripeInvoice.id}`
|
||||
);
|
||||
|
||||
if (!lock) {
|
||||
@@ -400,8 +336,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
|
||||
await this.saveLifetimeSubscription(knownInvoice);
|
||||
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
|
||||
await this.saveOnetimePaymentSubscription(knownInvoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,106 +404,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
async saveOnetimePaymentSubscription(knownInvoice: KnownStripeInvoice) {
|
||||
this.assertUserIdExists(knownInvoice.userId);
|
||||
const { userId, lookupKey, stripeInvoice } = knownInvoice;
|
||||
|
||||
const invoice = await this.db.invoice.findUnique({
|
||||
where: {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
// never happens
|
||||
throw new InternalServerError('Invoice not found');
|
||||
}
|
||||
|
||||
if (invoice.onetimeSubscriptionRedeemed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.invoice.update({
|
||||
select: {
|
||||
onetimeSubscriptionRedeemed: true,
|
||||
},
|
||||
where: {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
},
|
||||
data: { onetimeSubscriptionRedeemed: true },
|
||||
});
|
||||
|
||||
const existingSubscription = await this.db.subscription.findUnique({
|
||||
where: {
|
||||
targetId_plan: {
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const subscriptionTime =
|
||||
lookupKey.recurring === SubscriptionRecurring.Monthly
|
||||
? OneMonth
|
||||
: OneYear;
|
||||
|
||||
let subscription: Subscription;
|
||||
|
||||
// extends the subscription time if exists
|
||||
if (existingSubscription) {
|
||||
if (!existingSubscription.end) {
|
||||
throw new InternalServerError(
|
||||
'Unexpected onetime subscription with no end date'
|
||||
);
|
||||
}
|
||||
|
||||
const period =
|
||||
// expired, reset the period
|
||||
existingSubscription.end <= new Date()
|
||||
? {
|
||||
start: new Date(),
|
||||
end: new Date(Date.now() + subscriptionTime),
|
||||
}
|
||||
: {
|
||||
end: new Date(
|
||||
existingSubscription.end.getTime() + subscriptionTime
|
||||
),
|
||||
};
|
||||
|
||||
subscription = await this.db.subscription.update({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
data: period,
|
||||
});
|
||||
} else {
|
||||
subscription = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: userId,
|
||||
stripeSubscriptionId: null,
|
||||
...lookupKey,
|
||||
start: new Date(),
|
||||
end: new Date(Date.now() + subscriptionTime),
|
||||
status: SubscriptionStatus.Active,
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.event.emit('user.subscription.activated', {
|
||||
userId,
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription({
|
||||
...subscription,
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async revokeOnetimeOrLifetime(knownInvoice: KnownStripeInvoice) {
|
||||
async revokeLifetime(knownInvoice: KnownStripeInvoice) {
|
||||
this.assertUserIdExists(knownInvoice.userId);
|
||||
const { userId, lookupKey } = knownInvoice;
|
||||
|
||||
@@ -609,7 +444,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreOnetimeOrLifetime(knownInvoice: KnownStripeInvoice) {
|
||||
async restoreLifetime(knownInvoice: KnownStripeInvoice) {
|
||||
this.assertUserIdExists(knownInvoice.userId);
|
||||
const { userId, lookupKey, stripeInvoice } = knownInvoice;
|
||||
|
||||
@@ -627,18 +462,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
? stripeInvoice.created
|
||||
: Date.now() / 1000);
|
||||
|
||||
let end: Date | null = null;
|
||||
|
||||
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
|
||||
end = null;
|
||||
} else if (lookupKey.variant === SubscriptionVariant.Onetime) {
|
||||
const isMonthly = lookupKey.recurring === SubscriptionRecurring.Monthly;
|
||||
const duration = isMonthly ? OneMonth : OneYear;
|
||||
end = subscription?.end ?? new Date(start * 1000 + duration);
|
||||
} else {
|
||||
end = subscription?.end ?? null;
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
const saved = await this.db.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
@@ -647,7 +470,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
canceledAt: null,
|
||||
nextBillAt: null,
|
||||
start: subscription.start ?? new Date(start * 1000),
|
||||
end,
|
||||
end: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
@@ -658,7 +481,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
stripeSubscriptionId: null,
|
||||
...lookupKey,
|
||||
start: new Date(start * 1000),
|
||||
end,
|
||||
end: null,
|
||||
status: SubscriptionStatus.Active,
|
||||
nextBillAt: null,
|
||||
},
|
||||
@@ -673,111 +496,43 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async autoPrice(lookupKey: LookupKey, strategy: PriceStrategyStatus) {
|
||||
// auto select ea variant when available if not specified
|
||||
let variant: SubscriptionVariant | null = lookupKey.variant;
|
||||
|
||||
if (!variant) {
|
||||
// make the if conditions separated, more readable
|
||||
// pro early access
|
||||
if (
|
||||
lookupKey.plan === SubscriptionPlan.Pro &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Yearly &&
|
||||
strategy.proEarlyAccess &&
|
||||
!strategy.proSubscribed
|
||||
) {
|
||||
variant = SubscriptionVariant.EA;
|
||||
}
|
||||
|
||||
// ai early access
|
||||
if (
|
||||
lookupKey.plan === SubscriptionPlan.AI &&
|
||||
lookupKey.recurring === SubscriptionRecurring.Yearly &&
|
||||
strategy.aiEarlyAccess &&
|
||||
!strategy.aiSubscribed
|
||||
) {
|
||||
variant = SubscriptionVariant.EA;
|
||||
}
|
||||
}
|
||||
|
||||
return this.getPrice({
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
variant,
|
||||
});
|
||||
}
|
||||
|
||||
private async isPriceAvailable(
|
||||
price: KnownStripePrice,
|
||||
strategy: PriceStrategyStatus
|
||||
) {
|
||||
private async isPriceAvailable(price: KnownStripePrice) {
|
||||
if (price.lookupKey.plan === SubscriptionPlan.Pro) {
|
||||
return this.isProPriceAvailable(price, strategy);
|
||||
return this.isProPriceAvailable(price);
|
||||
}
|
||||
|
||||
if (price.lookupKey.plan === SubscriptionPlan.AI) {
|
||||
return this.isAIPriceAvailable(price, strategy);
|
||||
return this.isAIPriceAvailable(price);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async isProPriceAvailable(
|
||||
{ lookupKey }: KnownStripePrice,
|
||||
{ proEarlyAccess, proSubscribed, onetime }: PriceStrategyStatus
|
||||
) {
|
||||
private async isProPriceAvailable({ lookupKey }: KnownStripePrice) {
|
||||
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
|
||||
return this.config.payment.showLifetimePrice;
|
||||
}
|
||||
|
||||
if (lookupKey.variant === SubscriptionVariant.Onetime) {
|
||||
return onetime;
|
||||
}
|
||||
|
||||
// no special price for monthly plan
|
||||
if (lookupKey.recurring === SubscriptionRecurring.Monthly) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// show EA price instead of normal price if early access is available
|
||||
return proEarlyAccess && !proSubscribed
|
||||
? lookupKey.variant === SubscriptionVariant.EA
|
||||
: lookupKey.variant !== SubscriptionVariant.EA;
|
||||
return lookupKey.variant === null;
|
||||
}
|
||||
|
||||
private async isAIPriceAvailable(
|
||||
{ lookupKey }: KnownStripePrice,
|
||||
{ aiEarlyAccess, aiSubscribed, onetime }: PriceStrategyStatus
|
||||
) {
|
||||
private async isAIPriceAvailable({ lookupKey }: KnownStripePrice) {
|
||||
// no lifetime price for AI
|
||||
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// never show onetime prices
|
||||
if (lookupKey.variant === SubscriptionVariant.Onetime) {
|
||||
return onetime;
|
||||
}
|
||||
|
||||
// show EA price instead of normal price if early access is available
|
||||
return aiEarlyAccess && !aiSubscribed
|
||||
? lookupKey.variant === SubscriptionVariant.EA
|
||||
: lookupKey.variant !== SubscriptionVariant.EA;
|
||||
return lookupKey.variant === null;
|
||||
}
|
||||
|
||||
private async strategyStatus(
|
||||
customer: UserStripeCustomer
|
||||
): Promise<PriceStrategyStatus> {
|
||||
const proEarlyAccess = await this.feature.isEarlyAccessUser(
|
||||
customer.userId,
|
||||
EarlyAccessType.App
|
||||
);
|
||||
|
||||
const aiEarlyAccess = await this.feature.isEarlyAccessUser(
|
||||
customer.userId,
|
||||
EarlyAccessType.AI
|
||||
);
|
||||
|
||||
let proSubscribed = false;
|
||||
let aiSubscribed = false;
|
||||
|
||||
@@ -786,8 +541,6 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
status: 'all',
|
||||
});
|
||||
|
||||
// if the early access user had early access subscription in the past, but it got canceled or past due,
|
||||
// the user will lose the early access privilege
|
||||
for (const sub of subscriptions.data) {
|
||||
const lookupKey = retriveLookupKeyFromStripeSubscription(sub);
|
||||
if (!lookupKey) {
|
||||
@@ -803,13 +556,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
proEarlyAccess,
|
||||
aiEarlyAccess,
|
||||
proSubscribed,
|
||||
aiSubscribed,
|
||||
onetime: false,
|
||||
};
|
||||
return { proSubscribed, aiSubscribed };
|
||||
}
|
||||
|
||||
private assertUserIdExists(
|
||||
|
||||
@@ -166,14 +166,6 @@ export class InvoiceType implements Partial<Invoice> {
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
|
||||
// deprecated fields
|
||||
@Field(() => String, {
|
||||
name: 'id',
|
||||
nullable: true,
|
||||
deprecationReason: 'removed',
|
||||
})
|
||||
stripeInvoiceId?: string;
|
||||
|
||||
@Field(() => SubscriptionPlan, {
|
||||
nullable: true,
|
||||
deprecationReason: 'removed',
|
||||
@@ -475,12 +467,7 @@ export class UserSubscriptionResolver {
|
||||
) {}
|
||||
|
||||
private normalizeSubscription(s: Subscription) {
|
||||
if (
|
||||
s.variant &&
|
||||
![SubscriptionVariant.EA, SubscriptionVariant.Onetime].includes(
|
||||
s.variant as SubscriptionVariant
|
||||
)
|
||||
) {
|
||||
if (s.variant && s.variant !== SubscriptionVariant.Onetime) {
|
||||
s.variant = null;
|
||||
}
|
||||
return s;
|
||||
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from './types';
|
||||
|
||||
export const CheckoutExtraArgs = z.union([
|
||||
@@ -536,9 +535,8 @@ export class SubscriptionService {
|
||||
reason === 'dispute_open' ||
|
||||
reason === 'dispute_lost';
|
||||
const restore = reason === 'dispute_won';
|
||||
const isOneTimeOrLifetime =
|
||||
knownInvoice.lookupKey.recurring === SubscriptionRecurring.Lifetime ||
|
||||
knownInvoice.lookupKey.variant === SubscriptionVariant.Onetime;
|
||||
const isLifetime =
|
||||
knownInvoice.lookupKey.recurring === SubscriptionRecurring.Lifetime;
|
||||
|
||||
if (restore) {
|
||||
if (invoice.subscription) {
|
||||
@@ -564,11 +562,11 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
if (
|
||||
isOneTimeOrLifetime &&
|
||||
isLifetime &&
|
||||
(knownInvoice.lookupKey.plan === SubscriptionPlan.Pro ||
|
||||
knownInvoice.lookupKey.plan === SubscriptionPlan.AI)
|
||||
) {
|
||||
await this.userManager.restoreOnetimeOrLifetime(knownInvoice);
|
||||
await this.userManager.restoreLifetime(knownInvoice);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -615,11 +613,11 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
if (
|
||||
isOneTimeOrLifetime &&
|
||||
isLifetime &&
|
||||
(knownInvoice.lookupKey.plan === SubscriptionPlan.Pro ||
|
||||
knownInvoice.lookupKey.plan === SubscriptionPlan.AI)
|
||||
) {
|
||||
await this.userManager.revokeOnetimeOrLifetime(knownInvoice);
|
||||
await this.userManager.revokeLifetime(knownInvoice);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
decodeLookupKey,
|
||||
DEFAULT_PRICES,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from './types';
|
||||
|
||||
@Injectable()
|
||||
@@ -39,20 +38,18 @@ export class StripeFactory {
|
||||
}
|
||||
|
||||
setup() {
|
||||
// Prefer new keys under payment.stripe.*, fallback to legacy root keys for backward compatibility
|
||||
const {
|
||||
apiKey: nestedApiKey,
|
||||
apiKey,
|
||||
webhookKey: _,
|
||||
...config
|
||||
} = this.config.payment.stripe || {};
|
||||
// NOTE:
|
||||
// we always fake a key if not set because `new Stripe` will complain if it's empty string
|
||||
// this will make code cleaner than providing `Stripe` instance as optional one.
|
||||
const apiKey =
|
||||
nestedApiKey || this.config.payment.apiKey || 'stripe-api-key';
|
||||
const stripeApiKey = apiKey || 'stripe-api-key';
|
||||
|
||||
// TODO@(@darkskygit): use per-requests api key injection
|
||||
this.#stripe = new Stripe(apiKey, config);
|
||||
this.#stripe = new Stripe(stripeApiKey, config);
|
||||
if (this.config.payment.enabled) {
|
||||
this.server.enableFeature(ServerFeature.Payment);
|
||||
} else {
|
||||
@@ -107,8 +104,7 @@ export class StripeFactory {
|
||||
lookup_key: key,
|
||||
tax_behavior: 'inclusive',
|
||||
recurring:
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
|
||||
lookupKey.variant === SubscriptionVariant.Onetime
|
||||
lookupKey.recurring === SubscriptionRecurring.Lifetime
|
||||
? undefined
|
||||
: {
|
||||
interval:
|
||||
|
||||
@@ -20,7 +20,6 @@ export enum SubscriptionPlan {
|
||||
}
|
||||
|
||||
export enum SubscriptionVariant {
|
||||
EA = 'earlyaccess',
|
||||
Onetime = 'onetime',
|
||||
}
|
||||
|
||||
@@ -44,12 +43,6 @@ export enum InvoiceStatus {
|
||||
Uncollectible = 'uncollectible',
|
||||
}
|
||||
|
||||
export enum CouponType {
|
||||
ProEarlyAccessOneYearFree = 'pro_ea_one_year_free',
|
||||
AIEarlyAccessOneYearFree = 'ai_ea_one_year_free',
|
||||
ProEarlyAccessAIOneYearFree = 'ai_pro_ea_one_year_free',
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.subscription.activated': {
|
||||
@@ -199,11 +192,6 @@ export const DEFAULT_PRICES = new Map([
|
||||
price: 8100,
|
||||
},
|
||||
],
|
||||
// only EA for yearly pro
|
||||
[
|
||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
|
||||
{ product: 'AFFiNE Pro', price: 5000 },
|
||||
],
|
||||
[
|
||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Lifetime}`,
|
||||
{
|
||||
@@ -211,29 +199,11 @@ export const DEFAULT_PRICES = new Map([
|
||||
price: 49900,
|
||||
},
|
||||
],
|
||||
[
|
||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}_${SubscriptionVariant.Onetime}`,
|
||||
{ product: 'AFFiNE Pro - One Month', price: 799 },
|
||||
],
|
||||
[
|
||||
`${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
|
||||
{ product: 'AFFiNE Pro - One Year', price: 8100 },
|
||||
],
|
||||
|
||||
// ai
|
||||
[
|
||||
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}`,
|
||||
{ product: 'AFFiNE AI', price: 10680 },
|
||||
],
|
||||
// only EA for yearly AI
|
||||
[
|
||||
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.EA}`,
|
||||
{ product: 'AFFiNE AI', price: 9900 },
|
||||
],
|
||||
[
|
||||
`${SubscriptionPlan.AI}_${SubscriptionRecurring.Yearly}_${SubscriptionVariant.Onetime}`,
|
||||
{ product: 'AFFiNE AI - One Year', price: 10680 },
|
||||
],
|
||||
|
||||
// team
|
||||
[
|
||||
@@ -284,7 +254,7 @@ export function decodeLookupKey(key: string): LookupKey {
|
||||
return {
|
||||
plan: plan as SubscriptionPlan,
|
||||
recurring: recurring as SubscriptionRecurring,
|
||||
variant: variant as SubscriptionVariant,
|
||||
variant: (variant as SubscriptionVariant) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ function toBadRequestReason(reason: SSRFBlockReason) {
|
||||
return 'Failed to resolve hostname';
|
||||
case 'too_many_redirects':
|
||||
return 'Too many redirects';
|
||||
default:
|
||||
return 'Blocked by SSRF protection';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1113,9 +1113,7 @@ type ExpectToUpdateDocUserRoleDataType {
|
||||
}
|
||||
|
||||
enum FeatureType {
|
||||
AIEarlyAccess
|
||||
Admin
|
||||
EarlyAccess
|
||||
FreePlan
|
||||
LifetimeProPlan
|
||||
ProPlan
|
||||
@@ -1340,9 +1338,6 @@ type InviteResult {
|
||||
|
||||
"""Invite id, null if invite record create failed"""
|
||||
inviteId: String
|
||||
|
||||
"""Invite email sent success"""
|
||||
sentSuccess: Boolean! @deprecated(reason: "Notification will be sent asynchronously")
|
||||
}
|
||||
|
||||
type InviteUserType {
|
||||
@@ -1393,7 +1388,6 @@ type InvoiceType {
|
||||
amount: Int!
|
||||
createdAt: DateTime!
|
||||
currency: String!
|
||||
id: String @deprecated(reason: "removed")
|
||||
lastPaymentError: String
|
||||
link: String
|
||||
plan: SubscriptionPlan @deprecated(reason: "removed")
|
||||
@@ -1695,7 +1689,7 @@ type Mutation {
|
||||
revokeMember(userId: String!, workspaceId: String!): Boolean!
|
||||
revokePublicDoc(docId: String!, workspaceId: String!): DocType!
|
||||
revokeUserAccessToken(id: String!): Boolean!
|
||||
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
||||
sendChangeEmail(callbackUrl: String!): Boolean!
|
||||
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
sendTestEmail(config: JSONObject!): Boolean!
|
||||
@@ -2399,7 +2393,6 @@ type SubscriptionType {
|
||||
}
|
||||
|
||||
enum SubscriptionVariant {
|
||||
EA
|
||||
Onetime
|
||||
}
|
||||
|
||||
@@ -2667,9 +2660,6 @@ type UserType {
|
||||
"""User name"""
|
||||
name: String!
|
||||
|
||||
"""Get user notification count"""
|
||||
notificationCount: Int! @deprecated(reason: "Use realtime subscription \"notification.count.changed\" instead.")
|
||||
|
||||
"""Get current user notifications"""
|
||||
notifications(pagination: PaginationInput!): PaginatedNotificationObjectType!
|
||||
quota: UserQuotaType!
|
||||
@@ -2835,7 +2825,6 @@ type WorkspaceQuotaType {
|
||||
name: String!
|
||||
overcapacityMemberCount: Int!
|
||||
storageQuota: SafeInt!
|
||||
usedSize: SafeInt! @deprecated(reason: "use `usedStorageQuota` instead")
|
||||
usedStorageQuota: SafeInt!
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user