chore: cleanup legacy logic (#15072)

This commit is contained in:
DarkSky
2026-06-03 16:20:15 +08:00
committed by GitHub
parent 8c0e1ba04e
commit 81760fd45c
142 changed files with 3351 additions and 2449 deletions
@@ -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',
]
@@ -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,
+33 -4
View File
@@ -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'
);
+12 -1
View File
@@ -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,
};
+25
View File
@@ -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',
}
);
}
}
@@ -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';
}
}
+1 -12
View File
@@ -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!
}