diff --git a/.github/workflows/deploy-cloud.yml b/.github/workflows/deploy-cloud.yml index 21849cfb..5ba7c80e 100644 --- a/.github/workflows/deploy-cloud.yml +++ b/.github/workflows/deploy-cloud.yml @@ -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 @@ -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 diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index 50eefcf1..20b1d5c1 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -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} @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c44870bf..87d95abf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/packages/backend/src/ee/cloud/cloud.module.ts b/packages/backend/src/ee/cloud/cloud.module.ts index b4c4e81e..bbf74520 100644 --- a/packages/backend/src/ee/cloud/cloud.module.ts +++ b/packages/backend/src/ee/cloud/cloud.module.ts @@ -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'; @@ -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], }) diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts index 79820555..4a1cd300 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts @@ -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[]; @@ -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, @@ -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(); @@ -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); + }); +}); diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.ts index 5cc1a839..06f0bc7c 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.ts @@ -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; @@ -33,6 +34,7 @@ export class OnboardingCronService { constructor( private readonly prisma: PrismaService, private readonly email: EmailService, + private readonly license: LicenseService, ) {} async run(): Promise<{ @@ -44,6 +46,7 @@ export class OnboardingCronService { trialWarn1: number; trialExpired: number; trialsMarkedExpired: number; + trialsRepaired: number; skipped: number; }> { const now = Date.now(); @@ -56,6 +59,7 @@ export class OnboardingCronService { trialWarn1: 0, trialExpired: 0, trialsMarkedExpired: 0, + trialsRepaired: 0, skipped: 0, }; @@ -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; } diff --git a/packages/backend/src/license/license-trial.service.spec.ts b/packages/backend/src/license/license-trial.service.spec.ts new file mode 100644 index 00000000..c02808fe --- /dev/null +++ b/packages/backend/src/license/license-trial.service.spec.ts @@ -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; + +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(); + }); +}); diff --git a/packages/backend/src/license/license.controller.spec.ts b/packages/backend/src/license/license.controller.spec.ts new file mode 100644 index 00000000..838d967a --- /dev/null +++ b/packages/backend/src/license/license.controller.spec.ts @@ -0,0 +1,77 @@ +import { LicenseController } from './license.controller'; + +/** + * activate-trial used to answer 400 on every single signup. + * + * The verification flow activates the trial server-side, then the client calls + * this endpoint too; the second call found an existing licence and turned the + * licence API's 409 into a BadRequest. The user's trial was fine, but they were + * shown an error, and the wasted call ate the licence API's rate budget. + */ +describe('LicenseController — activate-trial is idempotent', () => { + const user = { id: 'u1', email: 'user@example.com', name: 'User' }; + const req = { user: { sub: 'u1', organizationId: 'org-1' } }; + + function makeController(existingLicense: any) { + const licenseService = { + getCurrentLicense: jest.fn(async () => existingLicense), + requestTrialLicense: jest.fn(async () => ({ + licenseKey: 'AMCP-NEW', + plan: 'trial', + expiresAt: '2026-10-01T00:00:00.000Z', + trialDaysLeft: 7, + })), + }; + const controller = new LicenseController( + licenseService as any, + {} as any, + {} as any, + { findById: jest.fn(async () => user) } as any, + { isCloud: () => true } as any, + ); + return { controller, licenseService }; + } + + it('returns the existing licence as a success instead of an error', async () => { + const expiresAt = new Date(Date.now() + 3 * 24 * 3600 * 1000); + const { controller, licenseService } = makeController({ + licenseKey: 'AMCP-EXISTING', + plan: 'trial', + expiresAt, + }); + + const result: any = await controller.activateTrial(req); + + expect(result.trialStarted).toBe(false); + expect(result.licenseKey).toBe('AMCP-EXISTING'); + expect(result.trialDaysLeft).toBe(3); + expect(licenseService.requestTrialLicense).not.toHaveBeenCalled(); + }); + + it('still starts a real trial when the workspace has none', async () => { + const { controller, licenseService } = makeController(null); + + const result: any = await controller.activateTrial(req); + + expect(result.trialStarted).toBe(true); + expect(result.licenseKey).toBe('AMCP-NEW'); + expect(licenseService.requestTrialLicense).toHaveBeenCalledWith( + 'user@example.com', + 'User', + 'org-1', + ); + }); + + it('reports a perpetual licence without pretending it expires today', async () => { + const { controller } = makeController({ + licenseKey: 'AMCP-PERPETUAL', + plan: 'enterprise', + expiresAt: null, + }); + + const result: any = await controller.activateTrial(req); + + expect(result.expiresAt).toBeNull(); + expect(result.trialDaysLeft).toBe(0); + }); +}); diff --git a/packages/backend/src/license/license.controller.ts b/packages/backend/src/license/license.controller.ts index fb1bfd4a..60794e2f 100644 --- a/packages/backend/src/license/license.controller.ts +++ b/packages/backend/src/license/license.controller.ts @@ -181,6 +181,28 @@ export class LicenseController { throw new BadRequestException('User not found'); } + // Idempotent on purpose. The login flow already activates a trial when the + // email is verified, and the client then calls this endpoint too, so the + // second call used to answer 400 "a trial already exists" on every single + // signup: an error shown to a user whose trial is perfectly fine. An org + // that already holds a licence gets that licence back, and a success. + const existing = await this.licenseService.getCurrentLicense(req.user.organizationId); + if (existing) { + return { + message: 'Trial already active', + trialStarted: false, + licenseKey: existing.licenseKey, + plan: existing.plan, + expiresAt: existing.expiresAt?.toISOString() ?? null, + trialDaysLeft: existing.expiresAt + ? Math.max( + 0, + Math.ceil((existing.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000)), + ) + : 0, + }; + } + try { const result = await this.licenseService.requestTrialLicense( user.email, diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 8a5b6e0e..923af38d 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -10,6 +10,16 @@ const LICENSE_API_URL = ? 'https://anythingmcp.com' : 'http://localhost:3100'; +/** + * How hard we chase a trial licence before giving up. The licence API is a + * different machine behind its own rate limits, and a trial lost to one bad + * second is a customer who lands on the licence wall instead of onboarding — + * so a transient failure is retried rather than logged. + */ +const TRIAL_RETRY_ATTEMPTS = 3; +/** Read at call time so a test (or an operator) can shrink the wait. */ +const trialRetryBaseMs = () => Number(process.env.TRIAL_RETRY_BASE_MS ?? 600); + export interface LicenseInfo { licenseKey: string; plan: string; @@ -127,6 +137,57 @@ export class LicenseService implements OnModuleInit { // ── Cloud Trial License ────────────────────────────────────────────────── + /** + * Headers that mark a call as coming from this server rather than from a + * browser. The licence API rate-limits anonymous callers per IP, and every + * cloud trial request leaves from the same IP, so without this header the + * whole cloud shares one small bucket and signups silently lose their trial. + * Unset in self-hosted installs, where the public limit is the right one. + */ + private serviceHeaders(): Record { + const token = process.env.LICENSE_SERVICE_TOKEN; + return token ? { 'x-amcp-service-token': token } : {}; + } + + /** Retry only what can succeed on a second try: throttling, upstream faults, no answer at all. */ + private isRetriableLicenseError(err: any): boolean { + const status = err?.response?.status; + if (status === undefined) return true; // timeout, DNS, connection reset + return status === 429 || status >= 500; + } + + private async postTrialWithRetry(payload: { + email: string; + name: string; + instanceId: string; + }): Promise { + let lastErr: any; + for (let attempt = 1; attempt <= TRIAL_RETRY_ATTEMPTS; attempt++) { + try { + const { data } = await axios.post(`${this.apiBase}/api/license/trial`, payload, { + timeout: 10000, + headers: this.serviceHeaders(), + }); + if (attempt > 1) { + this.logger.log(`Trial licence obtained for ${payload.email} on attempt ${attempt}.`); + } + return data; + } catch (err: any) { + lastErr = err; + if (attempt === TRIAL_RETRY_ATTEMPTS || !this.isRetriableLicenseError(err)) break; + // Exponential with jitter: several verifications can land together and + // retrying them in lockstep just rebuilds the burst that failed. + const base = trialRetryBaseMs(); + const delay = base * 2 ** (attempt - 1) + Math.floor(Math.random() * (base / 2)); + this.logger.warn( + `Trial licence attempt ${attempt} for ${payload.email} failed (${err?.response?.status ?? err.code ?? 'no response'}), retrying in ${delay}ms.`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastErr; + } + async requestTrialLicense( email: string, name: string, @@ -135,11 +196,7 @@ export class LicenseService implements OnModuleInit { const instanceId = await this.getInstanceId(); try { - const { data } = await axios.post( - `${this.apiBase}/api/license/trial`, - { email, name, instanceId }, - { timeout: 10000 }, - ); + const data = await this.postTrialWithRetry({ email, name, instanceId }); // Auto-activate the trial key locally await this.prisma.license.upsert({ @@ -190,6 +247,63 @@ export class LicenseService implements OnModuleInit { } } + /** + * Cloud self-heal: hand a trial to every verified user whose workspace ended + * up with no licence at all. + * + * Activation on email verification is best-effort by design (verification + * must succeed even if the licence API is down), and the licence wall's + * "Start trial" button only helps a user who notices it. Between the two, + * 131 verified users had been left with no licence by 2026-09-16, most of + * them because the licence API rate-limited the whole cloud to three trials + * an hour. This pass closes that gap for good: whatever the reason a trial + * went missing, the next cron run picks it up. + * + * Bounded per run and paced, because it talks to a remote API and a + * thundering herd is what created the backlog in the first place. + */ + async repairMissingTrials( + limit = 25, + ): Promise<{ examined: number; repaired: number; failed: number }> { + const out = { examined: 0, repaired: 0, failed: 0 }; + if (!this.deployment.isCloud()) return out; + + const candidates = await this.prisma.user.findMany({ + where: { + emailVerified: true, + organizationId: { not: null }, + organization: { licenses: { none: {} } }, + }, + select: { id: true, email: true, name: true, organizationId: true }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + for (const user of candidates) { + out.examined++; + try { + await this.requestTrialLicense( + user.email, + user.name || user.email, + user.organizationId ?? undefined, + ); + out.repaired++; + this.logger.log(`Repaired missing trial for org ${user.organizationId} (${user.email}).`); + } catch (err: any) { + out.failed++; + this.logger.warn(`Trial repair failed for ${user.email}: ${err.message}`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + if (out.examined > 0) { + this.logger.log( + `Trial repair: examined=${out.examined} repaired=${out.repaired} failed=${out.failed}`, + ); + } + return out; + } + // ── License Activation ───────────────────────────────────────────────────── async activateLicense(licenseKey: string): Promise {