diff --git a/src/backend/controllers/team/TeamController.ts b/src/backend/controllers/team/TeamController.ts index ce1d586f2..a3b90dfb3 100644 --- a/src/backend/controllers/team/TeamController.ts +++ b/src/backend/controllers/team/TeamController.ts @@ -338,6 +338,29 @@ export class TeamController extends PuterController { res.json({ temporary_password: temporaryPassword }); } + // The gate closes the seat's own route to fix it, so the owner must. + @Post('/:uid/members/:username/email', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: TEAM_LIMIT, + }) + async updateSeatEmail(req: Request, res: Response): Promise { + const userId = this.#requireUserId(req); + const uid = this.#param(req, 'uid'); + // Authority first, or resolving `:username` is an existence oracle. + await this.services.team.requireOwner(uid, userId); + const target = await this.#requireTargetUserId(req); + + await this.services.team.updateSeatEmail( + uid, + userId, + target, + (req.body as { email?: unknown } | undefined)?.email ?? null, + ); + res.json({ success: true }); + } + // Same budget as a password reset: both are a step toward the account. @Post('/:uid/members/:username/2fa-reset', { subdomain: 'api', diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index 5fddf6778..b1f95272b 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -16,6 +16,8 @@ import { bucketTag } from '../../stores/metering/MeteringBufferStore.ts'; import { setupTestServer } from '../../testUtil.ts'; import { DEFAULT_FREE_SUBSCRIPTION, + ORG_SEAT_FREE_SUBSCRIPTION, + UNLIMITED_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, GLOBAL_APP_KEY, METRICS_PREFIX, @@ -45,9 +47,9 @@ const currentMonthString = (): string => { }; /** - * How many `incr` calls one buffered write of `types` costs against the - * actor's own usage item(s): one totals write plus one per distinct detail - * shard the types hash to. + * How many `incr` calls one buffered write of `types` costs against the actor's + * own usage item(s): one totals write plus one per distinct detail shard the + * types hash to. */ const usageWriteCalls = (types: string[]): number => 1 + new Set(types.map((t) => detailShardOf(t))).size; @@ -179,6 +181,124 @@ describe('MeteringService', () => { expect(policy.id).toBe(DEFAULT_TEMP_SUBSCRIPTION); }); + it('runs the default resolvers in the order they asked for, not the order they arrived', async () => { + const seatLike = { + id: 'seat-like', + monthUsageAllowance: toMicroCents(1), + monthlyStorageAllowance: 1024, + }; + const domainLike = { + id: 'domain-like', + monthUsageAllowance: toMicroCents(5), + monthlyStorageAllowance: 2048, + }; + target.registerPolicy(seatLike); + target.registerPolicy(domainLike); + + // Registered first, as the extension layer is: it still loses. + target.registerDefaultSubscriptionResolver( + async () => 'domain-like', + ); + target.registerDefaultSubscriptionResolver( + async () => 'seat-like', + { + priority: 10, + }, + ); + + const policy = await target.getActorSubscription(actor); + expect(policy.id).toBe('seat-like'); + }); + + it('calls a plan nobody bought a default, however generous it is', async () => { + const generous = { + id: 'generous-default', + monthUsageAllowance: toMicroCents(5), + monthlyStorageAllowance: 1024, + }; + target.registerPolicy(generous); + target.registerDefaultSubscriptionResolver( + async () => 'generous-default', + ); + + const defaulted = makeActor(); + expect(await target.getActorSubscription(defaulted)).toMatchObject({ + id: 'generous-default', + }); + expect(await target.actorHasPaidSubscription(defaulted)).toBe( + false, + ); + }); + + it('calls the same plan paid once something subscribed the actor to it', async () => { + const generous = { + id: 'bought-plan', + monthUsageAllowance: toMicroCents(5), + monthlyStorageAllowance: 1024, + }; + target.registerPolicy(generous); + target.registerSubscriptionResolver(async () => 'bought-plan'); + + const subscribed = makeActor(); + expect(await target.getActorSubscription(subscribed)).toMatchObject( + { + id: 'bought-plan', + }, + ); + expect(await target.actorHasPaidSubscription(subscribed)).toBe( + true, + ); + }); + + it('counts unlimited as paid, so a deployment that turned it on keeps its caps', async () => { + const cfg = ( + target as unknown as { config: Record } + ).config; + const saved = cfg.unlimitedMetering; + cfg.unlimitedMetering = true; + try { + const anyone = makeActor(); + expect((await target.getActorSubscription(anyone)).id).toBe( + UNLIMITED_SUBSCRIPTION, + ); + expect(await target.actorHasPaidSubscription(anyone)).toBe( + true, + ); + } finally { + cfg.unlimitedMetering = saved; + } + }); + + it('calls it a default when the explicit id names no registered policy', async () => { + const fallback = { + id: 'registered-default', + monthUsageAllowance: toMicroCents(5), + monthlyStorageAllowance: 1024, + }; + target.registerPolicy(fallback); + target.registerDefaultSubscriptionResolver( + async () => 'registered-default', + ); + // As an extension that failed to load would name. + target.registerSubscriptionResolver(async () => 'ghost-plan'); + + const actorOnGhost = makeActor(); + expect((await target.getActorSubscription(actorOnGhost)).id).toBe( + 'registered-default', + ); + expect(await target.actorHasPaidSubscription(actorOnGhost)).toBe( + false, + ); + }); + + it('calls a free plan unpaid even when it was subscribed to', async () => { + target.registerSubscriptionResolver( + async () => ORG_SEAT_FREE_SUBSCRIPTION, + ); + const seat = makeActor(); + expect(await target.actorHasPaidSubscription(seat)).toBe(false); + }); + it('uses the first non-empty subscription resolver', async () => { const customPolicy = { id: 'custom-paid', @@ -1494,8 +1614,7 @@ describe('MeteringService', () => { expect(result.total).toBe(500); const adj = (result as Record) .manual_adjustment as - | { cost: number; units: number; count: number } - | undefined; + { cost: number; units: number; count: number } | undefined; expect(adj).toMatchObject({ cost: 500, units: 500, count: 1 }); }); @@ -1650,11 +1769,10 @@ describe('MeteringService', () => { expect(typeof value).toBe('object'); } - const detailed = - await target.getActorCurrentMonthAppUsageDetails( - appActor, - 'count-app', - ); + const detailed = await target.getActorCurrentMonthAppUsageDetails( + appActor, + 'count-app', + ); expect(detailed.count).toBeUndefined(); // appTotals reads the same item separately and still has count. @@ -2794,9 +2912,8 @@ describe('MeteringService', () => { ctor.EXACT_READ_MIN_INTERVAL_MS = 60_000; try { const bufActor: Actor = { user: makeUser() }; - const allowance = ( - await target.getActorSubscription(bufActor) - ).monthUsageAllowance; + const allowance = (await target.getActorSubscription(bufActor)) + .monthUsageAllowance; // Already near the allowance — every further call re-triggers // the near-allowance check, and the first one already read @@ -3711,8 +3828,7 @@ describe('MeteringService', () => { const admitted = usages.filter((u) => usage[u.usageType]); expect(admitted.length).toBeLessThanOrEqual(5); const other = usage[OTHER_USAGE_TYPE] as - | { cost: number } - | undefined; + { cost: number } | undefined; expect(other).toBeDefined(); // Nothing was lost, only regrouped: every admitted type's diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index afd8f94d6..a1593a831 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -25,6 +25,7 @@ import { PuterService } from '../types'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, + DEFAULT_RESOLVER_PRIORITY, DETAIL_PATH_COUNTER, GLOBAL_APP_KEY, METRICS_PREFIX, @@ -255,7 +256,10 @@ export class MeteringService extends PuterService { private usageBufferTimer: ReturnType | null = null; private extraPolicies: SubscriptionPolicy[] = []; private subscriptionResolvers: SubscriptionResolver[] = []; - private defaultSubscriptionResolvers: SubscriptionResolver[] = []; + private defaultSubscriptionResolvers: Array<{ + fn: SubscriptionResolver; + priority: number; + }> = []; /** * Fire-and-forget aux writes (`handleAuxPromise`) still in flight. Nothing @@ -267,7 +271,13 @@ export class MeteringService extends PuterService { /** Uuid → resolved policy + expiry; `lookupFailed` marks a fallback. */ private subscriptionCache = new Map< string, - { policy: SubscriptionPolicy; expiresAt: number; lookupFailed: boolean } + { + policy: SubscriptionPolicy; + expiresAt: number; + lookupFailed: boolean; + /** No explicit subscription answered, so this is a default. */ + fromDefault: boolean; + } >(); /** Uuid → the last budget state announced, so a retry loop emits once. */ @@ -469,10 +479,17 @@ export class MeteringService extends PuterService { /** * Register a resolver that maps an actor to a _default_ subscription id, - * used when no explicit subscription is set. First non-empty wins. + * used when none is set. Lowest `priority` is asked first. */ - registerDefaultSubscriptionResolver(fn: SubscriptionResolver): void { - this.defaultSubscriptionResolvers.push(fn); + registerDefaultSubscriptionResolver( + fn: SubscriptionResolver, + { priority = DEFAULT_RESOLVER_PRIORITY }: { priority?: number } = {}, + ): void { + this.defaultSubscriptionResolvers.push({ fn, priority }); + // Lowest first, so the winner is a stated number, not a boot order. + this.defaultSubscriptionResolvers.sort( + (a, b) => a.priority - b.priority, + ); } // -- Public API: increment usage ---------------------------------- @@ -2043,6 +2060,15 @@ export class MeteringService extends PuterService { return (await this.#actorSubscriptionWithStatus(actor)).policy; } + /** On a plan somebody subscribed them to, not one a resolver inferred. */ + async actorHasPaidSubscription(actor: Actor): Promise { + const { policy, fromDefault } = + await this.#actorSubscriptionWithStatus(actor); + // A deployment that turned unlimited on means it. + if (policy.id === UNLIMITED_SUBSCRIPTION) return true; + return !fromDefault && !isFreeSubscription(policy.id); + } + /** * `provisionalUntil` is set when the answer may not be this actor's plan * (failed lookup, or an actor missing its email or numeric id). Clean @@ -2051,6 +2077,7 @@ export class MeteringService extends PuterService { async #actorSubscriptionWithStatus(actor: Actor): Promise<{ policy: SubscriptionPolicy; provisionalUntil?: number; + fromDefault: boolean; }> { if (!actor.user?.uuid) throw new HttpError(403, 'Actor must be a user to get policy', { @@ -2063,6 +2090,7 @@ export class MeteringService extends PuterService { if (cached && cached.expiresAt > now) return { policy: cached.policy, + fromDefault: cached.fromDefault, provisionalUntil: cached.lookupFailed ? cached.expiresAt : undefined, @@ -2078,6 +2106,7 @@ export class MeteringService extends PuterService { ) { return { policy: resolved.policy, + fromDefault: resolved.fromDefault, provisionalUntil: now + MeteringService.SUBSCRIPTION_FALLBACK_CACHE_MS, }; @@ -2091,7 +2120,10 @@ export class MeteringService extends PuterService { stillFresh.expiresAt > now && !stillFresh.lookupFailed ) { - return { policy: stillFresh.policy }; + return { + policy: stillFresh.policy, + fromDefault: stillFresh.fromDefault, + }; } // Map preserves insertion order; FIFO-evict so a flood of one-shot @@ -2111,18 +2143,22 @@ export class MeteringService extends PuterService { this.subscriptionCache.set(uuid, { policy: resolved.policy, lookupFailed: resolved.lookupFailed, + fromDefault: resolved.fromDefault, expiresAt, }); return { policy: resolved.policy, + fromDefault: resolved.fromDefault, provisionalUntil: resolved.lookupFailed ? expiresAt : undefined, }; } /** `lookupFailed`: a resolver or the allowance read threw, so it fell back. */ - async #resolveActorSubscription( - actor: Actor, - ): Promise<{ policy: SubscriptionPolicy; lookupFailed: boolean }> { + async #resolveActorSubscription(actor: Actor): Promise<{ + policy: SubscriptionPolicy; + lookupFailed: boolean; + fromDefault: boolean; + }> { const fallbackDefault = this.config.unlimitedMetering ? UNLIMITED_SUBSCRIPTION : actor.user?.email @@ -2130,7 +2166,7 @@ export class MeteringService extends PuterService { : DEFAULT_TEMP_SUBSCRIPTION; const defaults = await this.firstResolver( - this.defaultSubscriptionResolvers, + this.defaultSubscriptionResolvers.map((entry) => entry.fn), actor, ); const resolvedDefault = defaults.id || fallbackDefault; @@ -2154,10 +2190,13 @@ export class MeteringService extends PuterService { // the built-in free policy keeps callers holding a real policy: the // alternative is `undefined` reaching every reader of `.id` and // `.monthUsageAllowance` as a 500 rather than a downgrade. + const explicit = availablePolicies.find((p) => p.id === resolvedUser); const policy = - availablePolicies.find((p) => p.id === resolvedUser) ?? + explicit ?? availablePolicies.find((p) => p.id === resolvedDefault) ?? availablePolicies.find((p) => p.id === fallbackDefault); + // Of the policy that applied, not the id that was asked for. + const fromDefault = !user.id || !explicit; if (policy) { const withAllowance = await this.#withMonthAllowance( actor.user.uuid!, @@ -2166,6 +2205,7 @@ export class MeteringService extends PuterService { return { policy: withAllowance.policy, lookupFailed: resolverFailed || withAllowance.failed, + fromDefault, }; } console.warn( @@ -2176,6 +2216,7 @@ export class MeteringService extends PuterService { return { policy: REGISTERED_USER_FREE as SubscriptionPolicy, lookupFailed: resolverFailed, + fromDefault, }; } diff --git a/src/backend/services/metering/consts.ts b/src/backend/services/metering/consts.ts index d01993b25..3903266ca 100644 --- a/src/backend/services/metering/consts.ts +++ b/src/backend/services/metering/consts.ts @@ -82,6 +82,10 @@ export const FREE_SUBSCRIPTION_IDS: ReadonlySet = new Set([ export const isFreeSubscription = (id: string): boolean => FREE_SUBSCRIPTION_IDS.has(id); +/** Where a default-subscription resolver sits; lower wins. */ +export const ORG_SEAT_RESOLVER_PRIORITY = 10; +export const DEFAULT_RESOLVER_PRIORITY = 100; + /** * A plan's entry in a per-plan map. An unlisted free plan takes the default * free plan's entry; `undefined` means the caller's paid default applies. diff --git a/src/backend/services/team/TeamCaps.test.ts b/src/backend/services/team/TeamCaps.test.ts index 1f0efe0fc..f89a208dc 100644 --- a/src/backend/services/team/TeamCaps.test.ts +++ b/src/backend/services/team/TeamCaps.test.ts @@ -19,9 +19,18 @@ import { readFile } from 'node:fs/promises'; import { v4 as uuidv4 } from 'uuid'; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; import { PuterServer } from '../../server.ts'; import { setupTestServer } from '../../testUtil.ts'; +import { isFreeSubscription } from '../metering/consts.ts'; describe('team and seat caps', () => { let server: PuterServer; @@ -91,9 +100,7 @@ describe('team and seat caps', () => { Array.from({ length: 5 }, () => makeTeam(owner.id)), ); - expect( - results.filter((r) => r.status === 'fulfilled'), - ).toHaveLength(1); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); }); it('refuses on the cap before complaining about the handle', async () => { @@ -325,10 +332,11 @@ describe('team and seat caps', () => { } }; + // The cap asks whether the owner is on a plan somebody bought. const onPlan = (id: string) => vi - .spyOn(server.services.metering, 'getActorSubscription') - .mockResolvedValue({ id } as never); + .spyOn(server.services.metering, 'actorHasPaidSubscription') + .mockResolvedValue(!isFreeSubscription(id)); const fill = async (teamUid: string, ownerId: number, n: number) => { for (let i = 0; i < n; i++) await provision(teamUid, ownerId); @@ -364,7 +372,7 @@ describe('team and seat caps', () => { provision(team.uid, owner.id), ).rejects.toMatchObject({ legacyCode: 'seat_limit_reached' }); - plan.mockResolvedValue({ id: 'basic' } as never); + plan.mockResolvedValue(true); await expect( provision(team.uid, owner.id), ).resolves.toBeTruthy(); @@ -432,12 +440,12 @@ describe('team and seat caps', () => { const seen: Array> = []; vi.spyOn( server.services.metering, - 'getActorSubscription', + 'actorHasPaidSubscription', ).mockImplementation(async (actor: never) => { seen.push( (actor as { user: Record }).user, ); - return { id: 'user_free' } as never; + return false; }); await provision(team.uid, owner.id); expect(seen[0]).toMatchObject({ diff --git a/src/backend/services/team/TeamService.test.ts b/src/backend/services/team/TeamService.test.ts index c9beded69..f93abb28d 100644 --- a/src/backend/services/team/TeamService.test.ts +++ b/src/backend/services/team/TeamService.test.ts @@ -127,7 +127,9 @@ describe('TeamService', () => { }); // The check exists precisely because the schema cannot refuse this. - await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe(false); + await expect(service.checkOwnerInvariant(team.uid)).resolves.toBe( + false, + ); }); // -- authority ---------------------------------------------------- @@ -171,7 +173,7 @@ describe('TeamService', () => { expect(row.suspended_reason).toBe('disabled_by_team'); }); - it('drops the disabled account\'s sessions', async () => { + it("drops the disabled account's sessions", async () => { const { team, member } = await makeTeam(); await server.clients.db.write( 'INSERT INTO `sessions` (`uuid`, `user_id`) VALUES (?, ?)', @@ -206,7 +208,7 @@ describe('TeamService', () => { expect(row.suspended_reason).toBeNull(); }); - it('leaves the disabled account\'s files alone', async () => { + it("leaves the disabled account's files alone", async () => { const { team, member } = await makeTeam(); await server.clients.db.write( 'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) VALUES (?, ?, ?, ?)', @@ -278,7 +280,6 @@ describe('TeamService', () => { expect(Boolean((await suspensionOf(member.id)).suspended)).toBe(false); }); - // -- provisioning ------------------------------------------------- it('creates an account the team owns, pending a password change', async () => { @@ -609,7 +610,7 @@ describe('TeamService', () => { expect(created.temporaryPassword).toEqual(expect.any(String)); }); - it('never demands confirmation, with or without an address', async () => { + it('demands confirmation of an address, and only when one is given', async () => { // The gate is `requires_email_confirmation && !email_confirmed`. const { team } = await makeTeam(); const bare = `bare_${Math.random().toString(36).slice(2, 9)}`; @@ -623,12 +624,71 @@ describe('TeamService', () => { email: `${withEmail}@test.local`, }); - for (const id of [a.userId, b.userId]) { - const row = await server.stores.user.getByProperty('id', id, { + const rowOf = (id: number) => + server.stores.user.getByProperty('id', id, { force: true }); + + // No address, nothing to confirm: the team vouches for the account. + const bareRow = await rowOf(a.userId); + expect(Boolean(bareRow?.requires_email_confirmation)).toBe(false); + + // An address the team typed in is a claim about someone else's inbox. + const emailRow = await rowOf(b.userId); + expect(Boolean(emailRow?.requires_email_confirmation)).toBe(true); + expect(Boolean(emailRow?.email_confirmed)).toBe(false); + // And a code it can actually be confirmed with. + expect(emailRow?.email_confirm_code).toBeTruthy(); + }); + + it('lets the seat clear the demand with the code it was sent', async () => { + const { team } = await makeTeam(); + const username = `conf_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + + const rowOf = () => + server.stores.user.getByProperty('id', created.userId, { force: true, }); - expect(Boolean(row?.requires_email_confirmation)).toBe(false); - } + const before = await rowOf(); + expect(Boolean(before?.requires_email_confirmation)).toBe(true); + expect(Boolean(before?.email_confirmed)).toBe(false); + + // What `/confirm-email` does once the seat hands back the code. + await server.stores.user.update(created.userId, { + email_confirmed: true, + email_confirm_code: null, + }); + + // The gate is `requires_email_confirmation && !email_confirmed`, so + // confirming clears it without the demand having to be withdrawn. + const after = await rowOf(); + expect(Boolean(after?.requires_email_confirmation)).toBe(true); + expect(Boolean(after?.email_confirmed)).toBe(true); + }); + + it('stops demanding confirmation when the address is released', async () => { + // A demand left behind outlives the address it names. + const { team } = await makeTeam(); + const username = `rel_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + + expect(await service.releaseUnconfirmedSeatEmail(created.userId)).toBe( + true, + ); + + const row = await server.stores.user.getByProperty( + 'id', + created.userId, + { force: true }, + ); + expect(row?.email).toBeNull(); + // The gate is `requires_email_confirmation && !email_confirmed`. + expect(Boolean(row?.requires_email_confirmation)).toBe(false); }); it('keeps an address when one is given', async () => { @@ -754,14 +814,20 @@ describe('TeamService', () => { await server.stores.user.invalidateById(owner.id); await service.updateTeam(team.uid, owner.id, { require2fa: true }); expect( - Number((await server.stores.team.getOrgSeat(member.id))?.require_2fa), + Number( + (await server.stores.team.getOrgSeat(member.id)) + ?.require_2fa, + ), ).toBe(1); // The seat row is cached, so turning it off has to bust it or the // member stays locked out until the TTL lapses. await service.updateTeam(team.uid, owner.id, { require2fa: false }); expect( - Number((await server.stores.team.getOrgSeat(member.id))?.require_2fa), + Number( + (await server.stores.team.getOrgSeat(member.id)) + ?.require_2fa, + ), ).toBe(0); }); }); @@ -1009,9 +1075,9 @@ describe('TeamService', () => { 'disable', 'provision', ]); - expect( - forMember.every((e) => e.actor_username === ownerUsername), - ).toBe(true); + expect(forMember.every((e) => e.actor_username === ownerUsername)).toBe( + true, + ); }); it('shows a member only their own entries', async () => { @@ -1027,7 +1093,10 @@ describe('TeamService', () => { email: `${b}@test.local`, }); - const { items: own } = await service.listOwnAudit(team.uid, first.userId); + const { items: own } = await service.listOwnAudit( + team.uid, + first.userId, + ); expect(own).toHaveLength(1); expect(own[0].username).toBe(a); // Internal ids must not reach a caller, as `toClientTeam` does for `id`. @@ -1148,10 +1217,14 @@ describe('TeamService', () => { const seen: string[] = []; let cursor: string | undefined; for (let page = 0; page < 8; page++) { - const result = await service.listOwnAudit(team.uid, created.userId, { - limit: 1, - cursor, - }); + const result = await service.listOwnAudit( + team.uid, + created.userId, + { + limit: 1, + cursor, + }, + ); seen.push(...result.items.map((e) => e.action)); cursor = result.cursor; if (!cursor) break; @@ -1237,6 +1310,77 @@ describe('TeamService', () => { expect(await server.stores.user.getById(created.userId)).toBeTruthy(); }); + it('leaves the seat usable when no transport could carry a code', async () => { + // A gate nothing can lift: `/send-confirm-email` needs the transport + // that is missing, so the demand would never be satisfiable. + const { team } = await makeTeam(); + const email = server.clients.email; + (server.clients as { email?: unknown }).email = undefined; + try { + const username = `notr_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + const row = await server.stores.user.getByProperty( + 'id', + created.userId, + { force: true }, + ); + expect(row?.email).toBe(`${username}@test.local`); + expect(Boolean(row?.requires_email_confirmation)).toBe(false); + } finally { + (server.clients as { email?: unknown }).email = email; + } + }); + + it('lets the owner retarget an address the seat never confirmed', async () => { + const { team } = await makeTeam(); + const username = `retgt_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}-typo@test.local`, + }); + const fixed = `${username}-right@test.local`; + await service.updateSeatEmail( + team.uid, + owner.id, + created.userId, + fixed, + ); + + const row = await server.stores.user.getByProperty( + 'id', + created.userId, + { force: true }, + ); + expect(row?.email).toBe(fixed); + expect(row?.email_confirm_code).toBeTruthy(); + expect(Boolean(row?.email_confirmed)).toBe(false); + }); + + it('refuses to retarget an address the seat confirmed', async () => { + const { team } = await makeTeam(); + const username = `conf_${Math.random().toString(36).slice(2, 9)}`; + const created = await service.provisionAccount(team.uid, owner.id, { + username, + email: `${username}@test.local`, + }); + await server.stores.user.update(created.userId, { + email_confirmed: true, + requires_email_confirmation: false, + }); + + await expect( + service.updateSeatEmail( + team.uid, + owner.id, + created.userId, + `${username}-other@test.local`, + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }); + // -- notifications ------------------------------------------------- /** Captures what would go out, without standing up a transport. */ @@ -1276,11 +1420,22 @@ describe('TeamService', () => { mail.restore(); } - expect(mail.sent).toHaveLength(1); - expect(mail.sent[0].to).toBe(`${username}@test.local`); + // The account notice, and the code the address is confirmed with. + expect(mail.sent).toHaveLength(2); + for (const sent of mail.sent) { + expect(sent.to).toBe(`${username}@test.local`); + // The owner already has the credential; it never goes to an + // address nobody has confirmed yet. + expect(sent.html).not.toContain(created.temporaryPassword); + } expect(mail.sent[0].html).toContain(username); - // Nobody confirmed this address; the owner has the credential already. - expect(mail.sent[0].html).not.toContain(created.temporaryPassword); + + const row = await server.stores.user.getByProperty( + 'id', + created.userId, + { force: true }, + ); + expect(mail.sent[1].html).toContain(row!.email_confirm_code); }); it('sends nothing when no address is given', async () => { @@ -1402,5 +1557,4 @@ describe('TeamService', () => { expect(second.items).toHaveLength(1); expect(second.items[0]).not.toEqual(first.items[0]); }); - }); diff --git a/src/backend/services/team/TeamService.ts b/src/backend/services/team/TeamService.ts index 0005ddc91..1e79a9921 100644 --- a/src/backend/services/team/TeamService.ts +++ b/src/backend/services/team/TeamService.ts @@ -18,6 +18,7 @@ */ import bcrypt from 'bcrypt'; +import crypto from 'node:crypto'; import { v4 as uuidv4 } from 'uuid'; import { USERNAME_MAX_LENGTH, @@ -25,8 +26,10 @@ import { } from '../../controllers/auth/AuthController.js'; import { isReservedUsername } from '../../util/reservedUsernames.js'; import type { EmailTemplateName } from '../../clients/email/templates.js'; -import { subscriptionSatisfies } from '../metering/enforcement.js'; -import { ORG_SEAT_FREE_SUBSCRIPTION } from '../metering/consts.js'; +import { + ORG_SEAT_FREE_SUBSCRIPTION, + ORG_SEAT_RESOLVER_PRIORITY, +} from '../metering/consts.js'; import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; // A free team is small on purpose; paying widens it. Both overridable in config. @@ -90,6 +93,7 @@ export const AUDIT_ACTIVATE = 'activate'; /** Written before the row it names goes; `_keep` is what preserves it. */ export const AUDIT_DELETE_ACCOUNT = 'delete_account'; +export const AUDIT_UPDATE_SEAT_EMAIL = 'update_seat_email'; /** A disclosure change, so it is recorded like anything else the team does. */ export const AUDIT_DIRECTORY_ON = 'directory_enabled'; @@ -142,6 +146,8 @@ export class TeamService extends PuterService { const seat = await this.stores.team.getOrgSeat(userId); return seat ? ORG_SEAT_FREE_SUBSCRIPTION : null; }, + // Ahead of anything read off the address, which nobody confirmed. + { priority: ORG_SEAT_RESOLVER_PRIORITY }, ); } @@ -317,12 +323,13 @@ export class TeamService extends PuterService { const override = Number(this.config.max_seats_per_team); if (Number.isFinite(override) && override > 0) return override; - const policy = await this.#ownerPolicy(ownerUserId); - const planCap = Number(policy?.teamSeatCap); + const plan = await this.#ownerPlan(ownerUserId); + // A named cap is deliberate; only the fallback turns on paid. + const planCap = Number(plan?.policy?.teamSeatCap); if (Number.isFinite(planCap) && planCap > 0) return planCap; - // A resolved policy outside the free set is a plan someone pays for. - const paid = policy ? subscriptionSatisfies(policy.id, true) : false; + // A plan bought, not one inferred for the owner from their address. + const paid = plan?.paid ?? false; const key = paid ? 'max_seats_per_team_paid' : 'max_seats_per_team_free'; @@ -332,18 +339,21 @@ export class TeamService extends PuterService { } /** Null when unreadable, which caps as free: over-provisioning is worse. */ - async #ownerPolicy( - ownerUserId: number, - ): Promise<{ id: string; teamSeatCap?: number } | null> { + async #ownerPlan(ownerUserId: number): Promise<{ + policy: { id: string; teamSeatCap?: number }; + paid: boolean; + } | null> { try { const owner = await this.stores.user.getById(ownerUserId); if (!owner?.uuid) return null; - // The whole row, not an id/uuid stub: a resolver may key on any - // field, and one that misses makes the cap depend on whether - // something else cached this user's plan first. - return await this.services.metering.getActorSubscription({ - user: owner, - } as never); + // The whole row: a resolver may key on any field. + const actor = { user: owner } as never; + // Sequential: run together both miss the cache and can disagree. + const policy = + await this.services.metering.getActorSubscription(actor); + const paid = + await this.services.metering.actorHasPaidSubscription(actor); + return { policy, paid }; } catch (e) { console.warn('[team] seat cap plan lookup failed:', e); return null; @@ -883,18 +893,16 @@ export class TeamService extends PuterService { id === null ? null : (users.get(id)?.username ?? null); return { - items: page.items.map( - (row): MemberActivityEntry => ({ - action: row.action, - reason: row.reason, - created_at: epochSeconds(row.created_at), - username: name(row.user_id_keep), - actor_username: name(row.actor_user_id), - // Only a sign-in carries these; the shape stays uniform. - ip: null, - user_agent: null, - }), - ), + items: page.items.map((row): MemberActivityEntry => ({ + action: row.action, + reason: row.reason, + created_at: epochSeconds(row.created_at), + username: name(row.user_id_keep), + actor_username: name(row.actor_user_id), + // Only a sign-in carries these; the shape stays uniform. + ip: null, + user_agent: null, + })), ...(page.cursor ? { cursor: page.cursor } : {}), }; } @@ -1012,14 +1020,25 @@ export class TeamService extends PuterService { }); } + // Gated only when a code can reach the address the team claimed. + const confirmCode = + email && this.clients.email + ? String(crypto.randomInt(100000, 1000000)) + : null; const user = await this.stores.user.create({ username: input.username, uuid: uuidv4(), password: null, email: email || null, clean_email: email ? cleanEmail(email) : null, - // Never demanded: the team creating the account is the trust anchor. - requires_email_confirmation: false, + requires_email_confirmation: Boolean(confirmCode), + email_confirmed: false, + ...(confirmCode + ? { + email_confirm_code: confirmCode, + email_confirm_token: uuidv4(), + } + : {}), }); await generateDefaultFsentries(this.clients.db, this.stores.user, user); @@ -1045,6 +1064,12 @@ export class TeamService extends PuterService { // Not mailed: nobody confirmed the address the owner typed in. const temporaryPassword = await this.#issueTemporaryPassword(user.id); await this.#notifyUser(user, 'team_account_created', team); + // The seat cannot confirm a code it was never sent. + if (confirmCode) { + await this.#notifyUser(user, 'email_verification_code', team, { + code: confirmCode, + }); + } // Last: the seat is only chargeable once it exists and can be used. this.#emitBilling('team.account.created', { @@ -1130,6 +1155,61 @@ export class TeamService extends PuterService { return { temporaryPassword }; } + /** Retarget a seat's unconfirmed address; a confirmed one is theirs. */ + async updateSeatEmail( + teamUid: string, + actorUserId: number, + targetUserId: number, + rawEmail: unknown, + ): Promise { + const team = await this.requireOwner(teamUid, actorUserId); + const user = await this.#requireTargetAccount(teamUid, targetUserId); + if (user.email && user.email_confirmed) { + throw new HttpError(409, 'That address is already confirmed', { + legacyCode: 'conflict', + }); + } + + const email = typeof rawEmail === 'string' ? rawEmail.trim() : ''; + if (email && !isStorableEmail(email)) { + throw new HttpError(400, 'Invalid email', { + legacyCode: 'bad_request', + }); + } + if (email && (await this.stores.user.findEmailOwner(email))) { + throw new HttpError(409, 'That email is already in use', { + legacyCode: 'email_already_in_use', + }); + } + + const confirmCode = + email && this.clients.email + ? String(crypto.randomInt(100000, 1000000)) + : null; + await this.stores.user.update(targetUserId, { + email: email || null, + clean_email: email ? cleanEmail(email) : null, + requires_email_confirmation: Boolean(confirmCode), + email_confirmed: false, + email_confirm_code: confirmCode, + email_confirm_token: confirmCode ? uuidv4() : null, + }); + await this.stores.team.appendAudit({ + teamId: team.id, + userId: targetUserId, + actorUserId, + action: AUDIT_UPDATE_SEAT_EMAIL, + }); + if (confirmCode) { + await this.#notifyUser( + { ...user, email } as UserRow, + 'email_verification_code', + team, + { code: confirmCode }, + ); + } + } + /** * Clears a seat's second factor so it can enrol again, for the member who * lost both their authenticator and their recovery codes. diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 692ca8f98..1a3f37ebf 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -667,7 +667,8 @@ export class UserStore extends PuterStore { /** * Remove an address the account never confirmed. Guarded so an address - * confirmed in between stays. Returns false when nothing was removed. + * confirmed in between stays. Returns false when nothing was removed. The + * demand goes too: it names the address, and outlives it otherwise. */ async clearUnconfirmedEmail(userId: number): Promise { const unconfirmed = @@ -675,7 +676,13 @@ export class UserStore extends PuterStore { this.clients.db.booleanLiteral(false); return this.#write( userId, - { email: null, clean_email: null }, + { + email: null, + clean_email: null, + requires_email_confirmation: false, + email_confirm_code: null, + email_confirm_token: null, + }, unconfirmed, ); } diff --git a/src/gui/src/UI/UIWindowEmailConfirmationRequired.js b/src/gui/src/UI/UIWindowEmailConfirmationRequired.js index c5646213b..1d14cb7c1 100644 --- a/src/gui/src/UI/UIWindowEmailConfirmationRequired.js +++ b/src/gui/src/UI/UIWindowEmailConfirmationRequired.js @@ -37,7 +37,12 @@ function UIWindowEmailConfirmationRequired (options) { h += ``; h += `

${i18n('confirm_your_email_address')}

`; h += '
'; - h += `

To continue, please enter the 6-digit confirmation code sent to ${window.user.email}

`; + // Opens at boot before the profile has loaded, and an address we do not + // have yet is no reason to fail to ask for the code. + const sent_to = window.user?.email + ? `${i18n('email_code_sent_to')} ${html_encode(window.user.email)}` + : i18n('email_code_sent_to_your_address'); + h += `

${sent_to}

`; h += '
'; h += `
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 0f11f02d8..438b1b775 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -377,6 +377,8 @@ const en = { repeat: 'Repeat', replace: 'Replace', replace_all: 'Replace All', + email_code_sent_to: 'To continue, please enter the 6-digit confirmation code sent to', + email_code_sent_to_your_address: 'To continue, please enter the 6-digit confirmation code sent to your email address', resend_confirmation_code: 'Re-send Confirmation Code', reset_colors: 'Reset Colors', 'Resources': 'Resources',