Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/deploy-cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ jobs:
uses: appleboy/ssh-action@v1
env:
CRON_SECRET: ${{ secrets.ONBOARDING_CRON_SECRET }}
LICENSE_SERVICE_TOKEN: ${{ secrets.LICENSE_SERVICE_TOKEN }}
with:
host: ${{ secrets.CLOUD_HOST }}
username: root
key: ${{ secrets.CLOUD_SSH_KEY }}
envs: CRON_SECRET
envs: CRON_SECRET,LICENSE_SERVICE_TOKEN
script: |
set -e
cd /opt/anythingmcp-cloud
Expand Down Expand Up @@ -68,6 +69,17 @@ jobs:
echo "CRON_SECRET=${CRON_SECRET}" >> .env
fi

# Same for the licence service token, which anythingmcp.com checks
# to tell this backend apart from the public when it asks for a
# trial. Must match LICENSE_SERVICE_TOKEN on the website droplet.
if [ -n "${LICENSE_SERVICE_TOKEN}" ]; then
if grep -q '^LICENSE_SERVICE_TOKEN=' .env; then
sed -i "s#^LICENSE_SERVICE_TOKEN=.*#LICENSE_SERVICE_TOKEN=${LICENSE_SERVICE_TOKEN}#" .env
else
echo "LICENSE_SERVICE_TOKEN=${LICENSE_SERVICE_TOKEN}" >> .env
fi
fi

# Recreate ONLY the app to pick up the new image/config (postgres,
# redis and motis keep running — no data churn, no needless
# downtime), and wait until it reports healthy. The old command
Expand Down
14 changes: 13 additions & 1 deletion docker-compose.cloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ services:
# Must match the repo secret ONBOARDING_CRON_SECRET. Unset = cron
# endpoint refuses all calls (self-host default).
- CRON_SECRET=${CRON_SECRET:-}
# Shared secret presented to anythingmcp.com as x-amcp-service-token when
# this backend asks for a licence on a user's behalf. The licence API
# rate-limits anonymous callers per IP, and every cloud trial request
# leaves from this one droplet, so without it the whole cloud shared a
# bucket of three trials an hour. Must match LICENSE_SERVICE_TOKEN on the
# website. Unset = public limit (self-host default).
- LICENSE_SERVICE_TOKEN=${LICENSE_SERVICE_TOKEN:-}
- CORS_ORIGIN=https://${DOMAIN}
- SERVER_URL=https://${DOMAIN}
- FRONTEND_URL=https://${DOMAIN}
Expand Down Expand Up @@ -177,7 +184,12 @@ services:
environment:
- MOTIS_REFRESH_DAYS=${MOTIS_REFRESH_DAYS:-7}
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"]
# 127.0.0.1, not localhost: MOTIS binds IPv4 only and localhost resolves
# to ::1 first inside the container, so the probe was refused every time.
# /metrics, not /: MOTIS answers 404 on the root, which --spider treats as
# a failure. Between the two, the container reported unhealthy for its
# whole life while serving traffic perfectly.
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/metrics"]
interval: 30s
timeout: 5s
retries: 3
Expand Down
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ services:
volumes:
- motis_data:/data
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"]
# 127.0.0.1, not localhost: MOTIS binds IPv4 only and localhost resolves
# to ::1 first inside the container, so the probe was refused every time.
# /metrics, not /: MOTIS answers 404 on the root, which --spider treats as
# a failure. Between the two, the container reported unhealthy for its
# whole life while serving traffic perfectly.
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/metrics"]
interval: 30s
timeout: 5s
retries: 3
Expand Down
4 changes: 3 additions & 1 deletion packages/backend/src/ee/cloud/cloud.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { SettingsModule } from '../../settings/settings.module';
import { LicenseModule } from '../../license/license.module';
import { OnboardingCronController } from './onboarding-cron.controller';
import { OnboardingCronService } from './onboarding-cron.service';
import { KgCronController } from './kg-cron.controller';
Expand All @@ -11,10 +12,11 @@ import { KgCronService } from './kg-cron.service';
* Groups cloud-specific providers and controllers:
* - Onboarding drip cron (sends nudge emails to users with 0 connectors)
* - Knowledge-graph discovery cron + audit retention
* - Trial repair (workspaces that ended up with no licence)
* - Future: usage metering, multi-tenant routing, billing webhooks
*/
@Module({
imports: [SettingsModule],
imports: [SettingsModule, LicenseModule],
controllers: [OnboardingCronController, KgCronController],
providers: [OnboardingCronService, KgCronService],
})
Expand Down
36 changes: 34 additions & 2 deletions packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { OnboardingCronService } from './onboarding-cron.service';

/**
* The repair pass talks to the licence API, so every test here stubs it. Its
* own behaviour is covered in license-trial.service.spec.ts.
*/
function makeLicense(repaired = 0) {
return {
repairMissingTrials: jest
.fn()
.mockResolvedValue({ examined: repaired, repaired, failed: 0 }),
} as any;
}

describe('OnboardingCronService — activation pass', () => {
function makeService(overrides: {
onboardingCandidates?: any[];
Expand Down Expand Up @@ -29,8 +41,9 @@ describe('OnboardingCronService — activation pass', () => {
.fn()
.mockResolvedValue(overrides.sendOk ?? true),
} as any;
const license = makeLicense();
return {
service: new OnboardingCronService(prisma, email),
service: new OnboardingCronService(prisma, email, license),
findMany,
update,
email,
Expand Down Expand Up @@ -147,7 +160,7 @@ describe('OnboardingCronService — trial status transition', () => {
} as any;
const email = {} as any;
const { OnboardingCronService } = await import('./onboarding-cron.service');
const svc = new OnboardingCronService(prisma, email);
const svc = new OnboardingCronService(prisma, email, makeLicense());

const out = await svc.run();

Expand All @@ -163,3 +176,22 @@ describe('OnboardingCronService — trial status transition', () => {
});
});

describe('OnboardingCronService — trial repair', () => {
it('repairs workspaces left without a licence, and reports how many', async () => {
const prisma = {
user: { findMany: jest.fn().mockResolvedValue([]), update: jest.fn() },
license: {
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
} as any;
const license = makeLicense(4);
const svc = new OnboardingCronService(prisma, {} as any, license);

const out = await svc.run();

// Without this the drip happily emails people about a trial they never got.
expect(license.repairMissingTrials).toHaveBeenCalledTimes(1);
expect(out.trialsRepaired).toBe(4);
});
});
13 changes: 12 additions & 1 deletion packages/backend/src/ee/cloud/onboarding-cron.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { EmailService } from '../../settings/email.service';
import { LicenseService } from '../../license/license.service';

const HOURS = (n: number) => n * 60 * 60 * 1000;
const DAYS = (n: number) => n * 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -33,6 +34,7 @@ export class OnboardingCronService {
constructor(
private readonly prisma: PrismaService,
private readonly email: EmailService,
private readonly license: LicenseService,
) {}

async run(): Promise<{
Expand All @@ -44,6 +46,7 @@ export class OnboardingCronService {
trialWarn1: number;
trialExpired: number;
trialsMarkedExpired: number;
trialsRepaired: number;
skipped: number;
}> {
const now = Date.now();
Expand All @@ -56,6 +59,7 @@ export class OnboardingCronService {
trialWarn1: 0,
trialExpired: 0,
trialsMarkedExpired: 0,
trialsRepaired: 0,
skipped: 0,
};

Expand Down Expand Up @@ -162,11 +166,18 @@ export class OnboardingCronService {
await this.runTrialLifecyclePass(now, out);
out.trialsMarkedExpired = await this.markExpiredTrials(now);

// Before nudging anyone about their trial, make sure they actually got one.
// A verified user with no licence at all sees the licence wall instead of
// onboarding, and every drip email we send them is about something they
// cannot use.
out.trialsRepaired = (await this.license.repairMissingTrials()).repaired;

this.logger.log(
`Onboarding drip: examined=${out.examined} first=${out.firstReminders} ` +
`second=${out.secondReminders} activation=${out.activationReminders} ` +
`trialWarn3=${out.trialWarn3} trialWarn1=${out.trialWarn1} trialExpired=${out.trialExpired} ` +
`trialsMarkedExpired=${out.trialsMarkedExpired} skipped=${out.skipped}`,
`trialsMarkedExpired=${out.trialsMarkedExpired} trialsRepaired=${out.trialsRepaired} ` +
`skipped=${out.skipped}`,
);
return out;
}
Expand Down
192 changes: 192 additions & 0 deletions packages/backend/src/license/license-trial.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* The trial path, which had been losing customers.
*
* Trial activation is best-effort by design: email verification must succeed
* even when the licence API does not answer. That made every transient failure
* invisible, and on 2026-09-16 the cloud had 131 verified users with no licence
* at all — the licence API rate-limits per IP and every cloud trial request
* leaves from the same droplet, so the whole tenancy shared three trials an
* hour. These tests pin the three things that now stop that from happening:
* the service token, the retry, and the repair pass.
*/
import axios from 'axios';
import { LicenseService } from './license.service';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

const TRIAL_RESPONSE = {
licenseKey: 'AMCP-TRIA-L000-0000-0001',
plan: 'trial',
features: { maxUsers: 3 },
expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
trialDaysLeft: 7,
};

function httpError(status?: number) {
return { response: status === undefined ? undefined : { status }, message: `HTTP ${status}` };
}

function makeService(opts: { isCloud?: boolean; users?: any[] } = {}) {
const isCloud = opts.isCloud ?? true;
const prisma = {
license: { upsert: jest.fn(async () => ({})) },
user: { findMany: jest.fn(async () => opts.users ?? []) },
};
const siteSettings = {
get: jest.fn(async (k: string) => (k === 'instance_id' ? 'instance-1' : null)),
set: jest.fn(async () => undefined),
};
const deployment = { isCloud: () => isCloud, isSelfHosted: () => !isCloud };
const svc = new LicenseService(prisma as any, siteSettings as any, deployment as any);
return { svc, prisma, siteSettings };
}

describe('LicenseService — trial acquisition', () => {
const originalToken = process.env.LICENSE_SERVICE_TOKEN;

beforeEach(() => {
jest.clearAllMocks();
process.env.TRIAL_RETRY_BASE_MS = '1';
delete process.env.LICENSE_SERVICE_TOKEN;
});

afterAll(() => {
delete process.env.TRIAL_RETRY_BASE_MS;
if (originalToken === undefined) delete process.env.LICENSE_SERVICE_TOKEN;
else process.env.LICENSE_SERVICE_TOKEN = originalToken;
});

it('retries a 429 instead of dropping the trial, and stores the licence it finally gets', async () => {
mockedAxios.post
.mockRejectedValueOnce(httpError(429))
.mockResolvedValueOnce({ data: TRIAL_RESPONSE } as any);
const { svc, prisma } = makeService();

const result = await svc.requestTrialLicense('user@example.com', 'User', 'org-1');

expect(mockedAxios.post).toHaveBeenCalledTimes(2);
expect(result.licenseKey).toBe(TRIAL_RESPONSE.licenseKey);
expect(prisma.license.upsert).toHaveBeenCalledTimes(1);
});

it('retries when the licence API does not answer at all', async () => {
mockedAxios.post
.mockRejectedValueOnce(httpError(undefined))
.mockRejectedValueOnce(httpError(503))
.mockResolvedValueOnce({ data: TRIAL_RESPONSE } as any);
const { svc } = makeService();

await expect(svc.requestTrialLicense('user@example.com', 'User')).resolves.toMatchObject({
plan: 'trial',
});
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
});

it('gives up after the third attempt rather than hammering', async () => {
mockedAxios.post.mockRejectedValue(httpError(429));
const { svc } = makeService();

await expect(svc.requestTrialLicense('user@example.com', 'User')).rejects.toThrow(
/Too many requests/,
);
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
});

it('does not retry a request the licence API rejected on its merits', async () => {
mockedAxios.post.mockRejectedValue(httpError(400));
const { svc } = makeService();

await expect(svc.requestTrialLicense('nope', 'User')).rejects.toThrow();
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
});

it('presents the service token so the cloud is not throttled as one anonymous visitor', async () => {
process.env.LICENSE_SERVICE_TOKEN = 'a-token-long-enough-to-count';
mockedAxios.post.mockResolvedValue({ data: TRIAL_RESPONSE } as any);
const { svc } = makeService();

await svc.requestTrialLicense('user@example.com', 'User');

const [, , config] = mockedAxios.post.mock.calls[0] as any[];
expect(config.headers['x-amcp-service-token']).toBe('a-token-long-enough-to-count');
});

it('sends no token header when none is configured, so self-hosted keeps the public limit', async () => {
mockedAxios.post.mockResolvedValue({ data: TRIAL_RESPONSE } as any);
const { svc } = makeService();

await svc.requestTrialLicense('user@example.com', 'User');

const [, , config] = mockedAxios.post.mock.calls[0] as any[];
expect(config.headers).toEqual({});
});

it('never sends the organization id, so one email cannot farm a trial per workspace', async () => {
mockedAxios.post.mockResolvedValue({ data: TRIAL_RESPONSE } as any);
const { svc } = makeService();

await svc.requestTrialLicense('user@example.com', 'User', 'org-1');

const [, payload] = mockedAxios.post.mock.calls[0] as any[];
expect(payload).toEqual({ email: 'user@example.com', name: 'User', instanceId: 'instance-1' });
});
});

describe('LicenseService — repairMissingTrials', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.TRIAL_RETRY_BASE_MS = '1';
});

it('gives a trial to every verified user whose workspace has none', async () => {
mockedAxios.post.mockResolvedValue({ data: TRIAL_RESPONSE } as any);
const { svc, prisma } = makeService({
users: [
{ id: 'u1', email: 'a@example.com', name: 'A', organizationId: 'org-a' },
{ id: 'u2', email: 'b@example.com', name: null, organizationId: 'org-b' },
],
});

const out = await svc.repairMissingTrials();

expect(out).toEqual({ examined: 2, repaired: 2, failed: 0 });
expect(prisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
emailVerified: true,
organization: { licenses: { none: {} } },
}),
}),
);
});

it('keeps going when one user cannot be repaired', async () => {
mockedAxios.post
.mockRejectedValueOnce(httpError(400))
.mockResolvedValueOnce({ data: TRIAL_RESPONSE } as any);
const { svc } = makeService({
users: [
{ id: 'u1', email: 'bad', name: 'A', organizationId: 'org-a' },
{ id: 'u2', email: 'b@example.com', name: 'B', organizationId: 'org-b' },
],
});

await expect(svc.repairMissingTrials()).resolves.toEqual({
examined: 2,
repaired: 1,
failed: 1,
});
});

it('does nothing on a self-hosted install, which has no trials to repair', async () => {
const { svc, prisma } = makeService({ isCloud: false });

await expect(svc.repairMissingTrials()).resolves.toEqual({
examined: 0,
repaired: 0,
failed: 0,
});
expect(prisma.user.findMany).not.toHaveBeenCalled();
});
});
Loading
Loading