feat(auth): signup bonus codes
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

Signup links can carry a bonusCode. Core checks its shape and previews it through `puter.signup-bonus.check`, refuses a dead code before the abuse hook records the attempt, then hands a live one to `puter.signup-bonus.validate` after `puter.signup.validate`, which may raise the phone/card gates; the accepted code rides on `puter.signup.success`, and OIDC carries it in the signed state. `user.card-verified` is now awaited like `user.phone-verified`. The signup form shows the offer and sends the code.
This commit is contained in:
Daniel Salazar
2026-09-23 14:47:34 -07:00
parent 4c3c684cb4
commit 58ed2f485f
15 changed files with 1401 additions and 12 deletions
+32
View File
@@ -227,8 +227,40 @@ export type EventMap = {
fingerprint?: string | null;
/** True when the created account is a temp user (no email/password). */
is_temp?: boolean;
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
bonus_code?: string;
[key: string]: unknown;
};
// Signup bonus codes are pure mechanism here: an extension decides what a
// code is worth and fills these in (both emitted via `emitAndWait`).
'puter.signup-bonus.check': {
code: string;
ip: string | null;
fingerprint: string | null;
valid: boolean;
/** Opaque to core; forwarded to the client when `valid` is false. */
reason: string | null;
display: { title: string; description: string } | null;
/** Verification the code will require after signup. */
requirements: { phone: boolean; card: boolean } | null;
};
/**
* Emitted once a signup has passed `puter.signup.validate`, so listeners
* see the harness's verdict. A listener sets `accepted` to honor the code,
* and may raise (never lower) the verification requirements.
*/
'puter.signup-bonus.validate': {
code: string;
email?: string;
clean_email?: string;
ip?: string | null;
fingerprint?: string | null;
reputation?: number | null;
source?: 'oidc';
requires_phone_verification: boolean;
requires_card_verification: boolean;
accepted: boolean;
};
'email.validate': {
email: string;
allow: boolean;
@@ -0,0 +1,413 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Signup bonus codes through the real controller: the check route, the signup
* hand-off to `puter.signup-bonus.validate`, and the awaited
* `user.card-verified` a grant on card verification depends on. Tests stand in
* for the extension with shared listeners (EventClient has no `off()`).
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { EventMap } from '../../clients/event/types.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
let server: PuterServer;
let controller: any;
type Handler<K extends keyof EventMap> = ((data: EventMap[K]) => void) | null;
let onBonusCheck: Handler<'puter.signup-bonus.check'> = null;
let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null;
let onSignupValidate: ((data: Record<string, unknown>) => void) | null = null;
let onCardConfirm: ((data: Record<string, unknown>) => void) | null = null;
let onCardVerified: (() => Promise<void>) | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.auth;
const events = server.clients.event;
events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data));
events.on('puter.signup-bonus.validate', (_k, data) =>
onBonusValidate?.(data),
);
events.on('puter.signup.validate', (_k, data) =>
onSignupValidate?.(data as Record<string, unknown>),
);
events.on('puter.signup.success', (_k, data) => {
heardSignupSuccess.push(data as Record<string, unknown>);
});
events.on('puter.card-verification.confirm', (_k, data) =>
onCardConfirm?.(data as Record<string, unknown>),
);
events.on('user.card-verified' as never, async () => {
await onCardVerified?.();
});
});
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
onBonusCheck = null;
onBonusValidate = null;
onSignupValidate = null;
onCardConfirm = null;
onCardVerified = null;
});
const uniq = () => Math.random().toString(36).slice(2, 10);
/** A check listener that finds the code open, as an extension would. */
const openCheck: Handler<'puter.signup-bonus.check'> = (data) => {
data.valid = true;
data.display = { title: 'T', description: 'D' };
};
const makeReq = (
body: Record<string, unknown> = {},
extra: { actor?: Actor; ip?: string } = {},
) => ({
body,
headers: {},
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
ip: extra.ip ?? '127.0.0.1',
params: {},
actor: extra.actor,
});
const makeRes = () => {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
json(b: unknown) {
this.body = b;
return this;
},
cookie() {
return this;
},
clearCookie() {
return this;
},
send() {
return this;
},
end() {
return this;
},
};
return res;
};
const signupBody = (extra: Record<string, unknown> = {}) => {
const username = `b_${uniq()}`;
return {
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
...extra,
};
};
const signup = async (body: Record<string, unknown>) => {
const res = makeRes();
await controller.handleSignup(makeReq(body), res);
return res;
};
const findUser = (username: unknown) =>
server.stores.user.getByUsername(username as string, { force: true });
/** `puter.signup.success` is fire-and-forget; wait for it to land. */
const waitForSuccess = async (userUuid: string) => {
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid);
if (hit) return hit;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('puter.signup.success never arrived');
};
// -- POST /signup/bonus-code/check -----------------------------------
describe('AuthController.handleSignupBonusCheck', () => {
const check = async (body: Record<string, unknown>) => {
const res = makeRes();
await controller.handleSignupBonusCheck(makeReq(body), res);
return res.body;
};
it('answers invalid when no extension is listening', async () => {
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
});
});
it('returns the display and requirements a listener fills in', async () => {
let seen: EventMap['puter.signup-bonus.check'] | null = null;
onBonusCheck = (data) => {
seen = { ...data };
data.valid = true;
data.display = { title: '3 months of Basic', description: 'Free' };
data.requirements = { phone: true, card: false };
};
expect(
await check({
bonusCode: 'Startup3M-ABCD1234',
fingerprint: 'fp1',
}),
).toEqual({
valid: true,
display: { title: '3 months of Basic', description: 'Free' },
requirements: { phone: true, card: false },
});
// Listeners only ever see the canonical form.
expect(seen).toMatchObject({
code: 'startup3mabcd1234',
fingerprint: 'fp1',
ip: '127.0.0.1',
});
});
it('forwards an opaque reason for a refused code', async () => {
onBonusCheck = (data) => {
data.reason = 'ended';
};
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
reason: 'ended',
});
});
it('treats valid without display as invalid', async () => {
onBonusCheck = (data) => {
data.valid = true;
};
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
});
});
it('answers a malformed code without consulting listeners', async () => {
let called = false;
onBonusCheck = () => {
called = true;
};
expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false });
expect(called).toBe(false);
});
it('400s a non-string bonusCode', async () => {
await expect(
controller.handleSignupBonusCheck(
makeReq({ bonusCode: 42 }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// -- POST /signup with bonusCode -------------------------------------
describe('AuthController.handleSignup with a bonus code', () => {
it('refuses the signup when nothing accepts the code', async () => {
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await expect(signup(body)).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bonus_code_invalid',
});
expect(await findUser(body.username)).toBeFalsy();
});
it('refuses a dead code before the abuse gate records the attempt', async () => {
let validated = false;
onSignupValidate = () => {
validated = true;
};
await expect(
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
expect(validated).toBe(false);
});
it('still refuses a code that passed the check but is refused after the gate', async () => {
onBonusCheck = openCheck;
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await expect(signup(body)).rejects.toMatchObject({
legacyCode: 'bonus_code_invalid',
});
expect(await findUser(body.username)).toBeFalsy();
});
it('refuses a malformed code before any listener runs', async () => {
let called = false;
onBonusValidate = () => {
called = true;
};
await expect(
signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })),
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
expect(called).toBe(false);
});
it('400s a non-string bonusCode', async () => {
await expect(
signup(signupBody({ bonusCode: ['startup3m'] })),
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
});
it('applies the requirements an accepted code raises and reports the code', async () => {
onBonusCheck = openCheck;
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
onBonusValidate = (data) => {
seen = { ...data };
data.accepted = true;
data.requires_card_verification = true;
};
const body = signupBody({
bonusCode: 'STARTUP6M-abcd1234',
fingerprint: 'fp-bonus',
});
const res = await signup(body);
expect(
(res.body as { user: Record<string, unknown> }).user,
).toMatchObject({ requires_card_verification: true });
expect(seen).toMatchObject({
code: 'startup6mabcd1234',
email: body.email,
fingerprint: 'fp-bonus',
requires_phone_verification: false,
requires_card_verification: false,
});
const user = await findUser(body.username);
expect(Boolean(user!.requires_card_verification)).toBe(true);
expect(Boolean(user!.requires_phone_verification)).toBe(false);
const success = await waitForSuccess(user!.uuid);
expect(success.bonus_code).toBe('startup6mabcd1234');
});
it('keeps a requirement the abuse harness set even if the listener clears it', async () => {
onBonusCheck = openCheck;
onSignupValidate = (data) => {
data.requires_phone_verification = true;
};
onBonusValidate = (data) => {
expect(data.requires_phone_verification).toBe(true);
data.accepted = true;
data.requires_phone_verification = false;
};
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await signup(body);
const user = await findUser(body.username);
expect(Boolean(user!.requires_phone_verification)).toBe(true);
});
it('never offers the code for a signup the abuse harness blocked', async () => {
onBonusCheck = openCheck;
let called = false;
onSignupValidate = (data) => {
data.allow = false;
data.message = 'Signup blocked';
};
onBonusValidate = () => {
called = true;
};
await expect(
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
).rejects.toMatchObject({ statusCode: 403 });
expect(called).toBe(false);
});
it('leaves the success event without a code when none was presented', async () => {
const body = signupBody();
await signup(body);
const user = await findUser(body.username);
const success = await waitForSuccess(user!.uuid);
expect(success).not.toHaveProperty('bonus_code');
});
it('ignores a bonus code on a temp signup', async () => {
let called = false;
onBonusValidate = () => {
called = true;
};
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }),
res,
);
expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe(
true,
);
expect(called).toBe(false);
});
});
// -- /card-verification/confirm awaits user.card-verified ------------
describe('AuthController.handleCardVerificationConfirm', () => {
it('finishes user.card-verified listeners before responding', async () => {
const body = signupBody();
await signup(body);
const created = await findUser(body.username);
await server.stores.user.update(created!.id, {
requires_card_verification: 1,
});
const actor = {
user: {
id: created!.id,
uuid: created!.uuid,
username: created!.username,
email: created!.email ?? null,
email_confirmed: false,
},
} as Actor;
onCardConfirm = (data) => {
data.enabled = true;
data.verified = true;
data.fingerprint = 'fp_card_1';
};
let listenerDone = false;
onCardVerified = async () => {
await new Promise((r) => setTimeout(r, 30));
listenerDone = true;
};
const res = makeRes();
await controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
);
expect(res.body).toMatchObject({ card_verified: true });
expect(listenerDone).toBe(true);
});
});
+118 -4
View File
@@ -76,6 +76,13 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { parsePhone } from '../../util/phone.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
isAbsentBonusCode,
normalizeBonusCode,
validateSignupBonus,
} from '../../util/signupBonus.js';
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
@@ -815,6 +822,18 @@ export class AuthController extends PuterController {
}
}
// Only the shape is judged here; whether the code is honored is up to
// `puter.signup-bonus.validate`, after the abuse checks below.
let bonusCode: string | null = null;
if (!is_temp && !isAbsentBonusCode(body.bonusCode)) {
if (typeof body.bonusCode !== 'string')
throw new HttpError(400, 'bonusCode must be a string.', {
legacyCode: 'bad_request',
});
bonusCode = normalizeBonusCode(body.bonusCode);
if (!bonusCode) throw bonusCodeInvalidError();
}
// Signup-disabled gate. Runs before the duplicate checks so a
// disabled endpoint doesn't reveal which usernames or emails
// exist. Claiming a pre-existing placeholder row is still
@@ -888,6 +907,20 @@ export class AuthController extends PuterController {
? null
: await this.#resolveSignupEmailClaim(body.email);
// A dead code fails here rather than after the gate below, which records
// an allowed attempt against the address as if an account followed.
if (
bonusCode &&
!(
await checkSignupBonus(this.clients.event, bonusCode, {
ip: clientIp,
fingerprint,
})
).valid
) {
throw bonusCodeInvalidError();
}
// Extension-level validation gate. Abuse-prevention extensions
// inspect the incoming signup and can:
// - block it outright via `event.allow = false`
@@ -974,18 +1007,37 @@ export class AuthController extends PuterController {
const force_email_confirmation = Boolean(
validateEvent.requires_email_confirmation,
);
const force_phone_verification =
let force_phone_verification =
Boolean(validateEvent.requires_phone_verification) ||
// Test/QA switch: force the SMS gate on every signup regardless of
// reputation (see config.always_require_phone_verification).
Boolean(this.config.always_require_phone_verification);
const force_card_verification = Boolean(
let force_card_verification = Boolean(
validateEvent.requires_card_verification ||
// Test/QA switch: force the card gate on every signup regardless of
// reputation (see config.always_require_card_verification).
this.config.always_require_card_verification,
);
if (bonusCode) {
const verdict = await validateSignupBonus(
this.clients.event,
bonusCode,
{
email: body.email,
clean_email: cleanEmail(body.email),
ip: clientIp,
fingerprint,
reputation: validateEvent.reputation,
requires_phone_verification: force_phone_verification,
requires_card_verification: force_card_verification,
},
);
if (!verdict.accepted) throw bonusCodeInvalidError();
force_phone_verification = verdict.requiresPhoneVerification;
force_card_verification = verdict.requiresCardVerification;
}
// Prepare shared fields
const user_uuid = uuidv4();
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
@@ -1206,6 +1258,7 @@ export class AuthController extends PuterController {
// have to agree or per-IP counters are written under one
// key and read under another.
ip: clientIp,
...(bonusCode ? { bonus_code: bonusCode } : {}),
} as never,
{},
);
@@ -1227,6 +1280,64 @@ export class AuthController extends PuterController {
await this.#completeLogin(req, res, user!);
}
/**
* Preview a signup bonus code before the account exists, so the signup form
* can say what it grants. Answers only valid/invalid: an extension owns the
* codes, and with none installed every code is invalid.
*/
@Post('/signup/bonus-code/check', {
rateLimit: [
{ scope: 'signup-bonus-check', limit: 20, window: 15 * 60_000 },
{
scope: 'signup-bonus-check-ip',
limit: 60,
window: 15 * 60_000,
key: 'ip',
},
],
})
async handleSignupBonusCheck(req: Request, res: Response): Promise<void> {
const raw = req.body?.bonusCode;
if (typeof raw !== 'string')
throw new HttpError(400, 'bonusCode must be a string.', {
legacyCode: 'bad_request',
});
const code = normalizeBonusCode(raw);
if (!code) {
res.json({ valid: false });
return;
}
const fingerprint =
typeof req.body?.fingerprint === 'string' &&
req.body.fingerprint.length <= FINGERPRINT_MAX_LENGTH
? req.body.fingerprint
: null;
const checkEvent = await checkSignupBonus(this.clients.event, code, {
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
fingerprint,
});
if (checkEvent.valid !== true || !checkEvent.display) {
res.json({
valid: false,
...(checkEvent.reason ? { reason: checkEvent.reason } : {}),
});
return;
}
res.json({
valid: true,
display: {
title: String(checkEvent.display.title),
description: String(checkEvent.display.description),
},
requirements: {
phone: checkEvent.requirements?.phone === true,
card: checkEvent.requirements?.card === true,
},
});
}
// -- Logout ------------------------------------------------------
@Post('/logout', {
@@ -2220,8 +2331,11 @@ export class AuthController extends PuterController {
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
// swallows listener errors, so confirm never fails on one.
try {
this.clients.event?.emit(
await this.clients.event?.emitAndWait(
'user.card-verified' as never,
{
user_id: user.id,
@@ -2234,7 +2348,7 @@ export class AuthController extends PuterController {
{},
);
} catch {
// ignore — event is a side-channel signal, not load-bearing
// ignore — listeners are best-effort
}
// Notify other tabs/devices for this user so they refresh + drop the gate.
try {
@@ -58,6 +58,10 @@ let router: PuterRouter;
// override in and out (same pattern as AuthController.test.ts).
type SignupValidateOverride = (data: Record<string, unknown>) => void;
let signupValidateOverride: SignupValidateOverride | null = null;
// Stand-in for the extension that honors signup bonus codes.
let bonusValidateOverride: SignupValidateOverride | null = null;
let bonusCheckOverride: SignupValidateOverride | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
beforeAll(async () => {
server = await setupTestServer({
@@ -90,6 +94,24 @@ beforeAll(async () => {
}
},
);
server.clients.event.on(
'puter.signup-bonus.check',
(_k: unknown, data: unknown) => {
bonusCheckOverride?.(data as Record<string, unknown>);
},
);
server.clients.event.on(
'puter.signup-bonus.validate',
(_k: unknown, data: unknown) => {
bonusValidateOverride?.(data as Record<string, unknown>);
},
);
server.clients.event.on(
'puter.signup.success',
(_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record<string, unknown>);
},
);
});
afterAll(async () => {
@@ -99,6 +121,8 @@ afterAll(async () => {
afterEach(() => {
vi.restoreAllMocks();
signupValidateOverride = null;
bonusValidateOverride = null;
bonusCheckOverride = null;
});
interface CapturedResponse {
@@ -1965,3 +1989,143 @@ describe('OIDCController rate limits', () => {
expect(rateLimitOf('get', '/auth/oidc/providers').limit).toBe(1_200);
});
});
// -- Signup bonus codes ----------------------------------------------
describe('OIDC signup bonus codes', () => {
const rand = () => Math.random().toString(36).slice(2, 8);
const startState = async (query: Record<string, unknown>) => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({ params: { provider: 'custom' }, query }),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
return oidc().verifyState(state!) as Record<string, unknown>;
};
const stubIdp = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
const openCheck = (data: Record<string, unknown>) => {
data.valid = true;
data.display = { title: 'T', description: 'D' };
};
const signupCallback = async (bonusCode: string) => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
bonus_code: bonusCode,
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
return captured;
};
it('carries a canonical code in the signed state', async () => {
const decoded = await startState({
flow: 'signup',
bonusCode: 'Startup3M-ABCD1234',
});
expect(decoded.bonus_code).toBe('startup3mabcd1234');
});
it('drops a malformed code at start', async () => {
const decoded = await startState({ flow: 'signup', bonusCode: 'a:b' });
expect(decoded).not.toHaveProperty('bonus_code');
});
it('creates the account with the requirements an accepted code raises', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
bonusCheckOverride = openCheck;
let seen: Record<string, unknown> | null = null;
bonusValidateOverride = (data) => {
seen = { ...data };
data.accepted = true;
data.requires_phone_verification = true;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.cookies).toHaveLength(1);
expect(seen).toMatchObject({
code: 'startup3mabcd1234',
source: 'oidc',
email,
});
const user = await server.stores.user.findEmailOwner(email, {
force: true,
});
expect(Boolean(user!.requires_phone_verification)).toBe(true);
const deadline = Date.now() + 2000;
let success: Record<string, unknown> | undefined;
while (!success && Date.now() < deadline) {
success = heardSignupSuccess.find((e) => e.email === email);
if (!success) await new Promise((r) => setTimeout(r, 10));
}
expect(success?.bonus_code).toBe('startup3mabcd1234');
});
it('refuses a dead code before the validate hook runs', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
let validated = false;
signupValidateOverride = (data) => {
if (data.email === email) validated = true;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
expect(validated).toBe(false);
});
it('refuses the account when nothing accepts the code, without echoing it', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
expect(captured.redirectUrl).not.toContain('bonusCode=');
expect(captured.cookies).toHaveLength(0);
expect(
await server.stores.user.findEmailOwner(email, { force: true }),
).toBeFalsy();
});
it('keeps the code on the retry redirect when something else failed', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
bonusCheckOverride = openCheck;
signupValidateOverride = (data) => {
if (data.email === email) data.allow = false;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
});
});
+41 -5
View File
@@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
@@ -50,6 +51,7 @@ const ALLOWED_ERRORS = [
'account_suspended',
'unauthorized',
'signup_blocked',
'bonus_code_invalid',
] as const;
/**
@@ -69,6 +71,23 @@ function resolutionErrorCode(code: string | undefined): string {
: 'signup_blocked';
}
/** What a new account created by an OIDC callback inherits from its flow. */
interface OIDCSignupOptions {
referrer?: string | null;
bonusCode?: string | null;
}
function signupOptionsFromState(
stateDecoded: Record<string, unknown>,
): OIDCSignupOptions {
return {
referrer: (stateDecoded.referrer as string) ?? null,
// Normalized when the state was signed; re-checked since this is the
// value signup acts on.
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
};
}
// GUI pages an OIDC flow may return to: the root (where a share email lands),
// /desktop, /dashboard, and direct app landings (/app/<name> and its
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
@@ -222,6 +241,13 @@ function buildErrorRedirectUrl(
if (requestCode) {
params.set('request_code', requestCode);
}
// A retry from the error page keeps the bonus code, unless it's what failed.
if (
typeof stateDecoded?.bonus_code === 'string' &&
clamped !== 'bonus_code_invalid'
) {
params.set('bonusCode', stateDecoded.bonus_code);
}
for (const path of sharedPaths) {
params.append(SHARE_DEEP_LINK_PARAM, path);
}
@@ -423,6 +449,15 @@ export class OIDCController extends PuterController {
redirect_uri: appRedirectUri,
};
if (referrer) statePayload.referrer = referrer ?? openerOrigin;
// Login can create an account too, so both flows carry it. A
// malformed code is dropped here and never reaches signup.
const rawBonusCode = Array.isArray(req.query.bonusCode)
? req.query.bonusCode[0]
: req.query.bonusCode;
const bonusCode = normalizeBonusCode(rawBonusCode);
if (bonusCode && (flow === 'login' || flow === 'signup')) {
statePayload.bonus_code = bonusCode;
}
if (embeddedInPopup && msgId) {
statePayload.embedded_in_popup = true;
statePayload.msg_id = msgId;
@@ -507,7 +542,7 @@ export class OIDCController extends PuterController {
const resolved = await this.#resolveOrCreateOIDCUser(
provider,
userinfo,
(stateDecoded.referrer as string) ?? null,
signupOptionsFromState(stateDecoded),
);
if ('error' in resolved) {
console.warn(
@@ -574,7 +609,7 @@ export class OIDCController extends PuterController {
const resolved = await this.#resolveOrCreateOIDCUser(
provider,
userinfo,
(stateDecoded.referrer as string) ?? null,
signupOptionsFromState(stateDecoded),
);
if ('error' in resolved) {
console.warn(
@@ -742,7 +777,7 @@ if (window.opener) {
async #resolveOrCreateOIDCUser(
provider: string,
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
referrer?: string | null,
signup: OIDCSignupOptions = {},
attempt = 0,
): Promise<
| { error: string; code?: string; requestCode?: string }
@@ -792,7 +827,8 @@ if (window.opener) {
const outcome = await this.services.oidc.createUserFromOIDC(
provider,
userinfo as { sub: string; email?: string },
referrer,
signup.referrer ?? null,
{ bonusCode: signup.bonusCode ?? null },
);
// A concurrent callback (a second tab, a provider retry) created the
// account between step 2 and the insert. Nothing went wrong for the
@@ -802,7 +838,7 @@ if (window.opener) {
return this.#resolveOrCreateOIDCUser(
provider,
userinfo,
referrer,
signup,
attempt + 1,
);
}
+54 -3
View File
@@ -24,6 +24,10 @@ import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
import { PuterService } from '../types';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import {
checkSignupBonus,
validateSignupBonus,
} from '../../util/signupBonus.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { Context } from '../../core';
import crypto from 'node:crypto';
@@ -456,11 +460,14 @@ export class OIDCService extends PuterService {
* `raced` means a concurrent callback got there first and the caller should
* re-resolve rather than surface an error — see
* `#resolveOrCreateOIDCUser`.
*
* `bonusCode` must already be canonical (`normalizeBonusCode`).
*/
async createUserFromOIDC(
providerId: string,
claims: OIDCUserInfo,
referrer?: string | null,
{ bonusCode = null }: { bonusCode?: string | null } = {},
): Promise<{
success: boolean;
user?: UserRow;
@@ -525,7 +532,7 @@ export class OIDCService extends PuterService {
// IdP already authenticated the user, so captcha listeners
// (e.g. Turnstile) should skip — abuse/IP/email checks still run.
source: 'oidc' as const,
data: { username, email },
data: { username, email, ...(bonusCode ? { bonusCode } : {}) },
// `req.ip` honors `trust proxy`; reading x-forwarded-for directly
// would let a client pick its own per-IP abuse bucket.
ip: clientIp,
@@ -586,6 +593,24 @@ export class OIDCService extends PuterService {
};
}
// Refuse a dead code before the validate hook records the attempt; see
// the same check in AuthController.
if (
bonusCode &&
!(
await checkSignupBonus(this.clients.event, bonusCode, {
ip: clientIp,
fingerprint: null,
})
).valid
) {
return {
success: false,
error: 'This bonus code is invalid or no longer available.',
code: 'bonus_code_invalid',
};
}
try {
await this.clients.event?.emitAndWait(
'puter.signup.validate',
@@ -608,13 +633,38 @@ export class OIDCService extends PuterService {
always_require_phone_verification?: boolean;
always_require_card_verification?: boolean;
};
const force_phone_verification =
let force_phone_verification =
Boolean(validateEvent.requires_phone_verification) ||
Boolean(cfg.always_require_phone_verification);
const force_card_verification =
let force_card_verification =
Boolean(validateEvent.requires_card_verification) ||
Boolean(cfg.always_require_card_verification);
if (bonusCode) {
const verdict = await validateSignupBonus(
this.clients.event,
bonusCode,
{
source: 'oidc',
email,
clean_email: cleanEmail(email),
ip: clientIp,
reputation: validateEvent.reputation,
requires_phone_verification: force_phone_verification,
requires_card_verification: force_card_verification,
},
);
if (!verdict.accepted) {
return {
success: false,
error: 'This bonus code is invalid or no longer available.',
code: 'bonus_code_invalid',
};
}
force_phone_verification = verdict.requiresPhoneVerification;
force_card_verification = verdict.requiresCardVerification;
}
// The caller checked this email was free before we got here, but the
// validate hook and the blocklist checks above sit in between — long
// enough for a second callback (another tab, a provider retry) to have
@@ -765,6 +815,7 @@ export class OIDCService extends PuterService {
// have to agree or per-IP counters are written under one
// key and read under another.
ip: clientIp,
...(bonusCode ? { bonus_code: bonusCode } : {}),
},
{},
);
+163
View File
@@ -0,0 +1,163 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { EventClient } from '../clients/event/EventClient.js';
import type { EventMap } from '../clients/event/types.js';
import {
bonusCodeInvalidError,
isAbsentBonusCode,
normalizeBonusCode,
validateSignupBonus,
} from './signupBonus.js';
const baseContext = {
email: 'a@test.local',
ip: '203.0.113.1',
requires_phone_verification: false,
requires_card_verification: false,
};
const clientWith = (
listener: (data: EventMap['puter.signup-bonus.validate']) => void,
) => {
const client = new EventClient({} as never);
client.on('puter.signup-bonus.validate', (_k, data) => listener(data));
return client;
};
describe('normalizeBonusCode', () => {
it('lowercases and drops separators', () => {
expect(normalizeBonusCode('Startup3M-7KQ2_9XPA')).toBe(
'startup3m7kq29xpa',
);
expect(normalizeBonusCode(' abcd 1234 ')).toBe('abcd1234');
});
it('rejects anything that cannot be a code', () => {
for (const bad of [
undefined,
null,
42,
{},
'',
'abc',
'a'.repeat(65),
'x'.repeat(129),
'abcd.1234',
'abcd:1234',
'ábcd1234',
]) {
expect(normalizeBonusCode(bad)).toBeNull();
}
});
});
describe('isAbsentBonusCode', () => {
it('treats undefined, null and empty string as absent', () => {
expect(isAbsentBonusCode(undefined)).toBe(true);
expect(isAbsentBonusCode(null)).toBe(true);
expect(isAbsentBonusCode('')).toBe(true);
expect(isAbsentBonusCode('abcd')).toBe(false);
expect(isAbsentBonusCode(0)).toBe(false);
});
});
describe('bonusCodeInvalidError', () => {
it('is a 400 with the stable code', () => {
const err = bonusCodeInvalidError();
expect(err.statusCode).toBe(400);
expect(err.legacyCode).toBe('bonus_code_invalid');
});
});
describe('validateSignupBonus', () => {
it('refuses when nothing is listening', async () => {
expect(
await validateSignupBonus(undefined, 'abcd1234', baseContext),
).toEqual({ accepted: false });
});
it('refuses when the listener leaves the code unaccepted', async () => {
const client = clientWith(() => {});
expect(
await validateSignupBonus(client, 'abcd1234', baseContext),
).toEqual({ accepted: false });
});
it('hands the listener the code and the current requirements', async () => {
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
const client = clientWith((data) => {
seen = { ...data };
});
await validateSignupBonus(client, 'abcd1234', {
...baseContext,
requires_card_verification: true,
});
expect(seen).toMatchObject({
code: 'abcd1234',
email: 'a@test.local',
requires_phone_verification: false,
requires_card_verification: true,
accepted: false,
});
});
it('returns the requirements a listener raises', async () => {
const client = clientWith((data) => {
data.accepted = true;
data.requires_phone_verification = true;
});
expect(
await validateSignupBonus(client, 'abcd1234', baseContext),
).toEqual({
accepted: true,
requiresPhoneVerification: true,
requiresCardVerification: false,
});
});
it('never lets a listener lower a requirement', async () => {
const client = clientWith((data) => {
data.accepted = true;
data.requires_phone_verification = false;
data.requires_card_verification = false;
});
expect(
await validateSignupBonus(client, 'abcd1234', {
...baseContext,
requires_phone_verification: true,
requires_card_verification: true,
}),
).toEqual({
accepted: true,
requiresPhoneVerification: true,
requiresCardVerification: true,
});
});
it('treats a throwing listener as not accepting', async () => {
const client = clientWith(() => {
throw new Error('boom');
});
expect(
await validateSignupBonus(client, 'abcd1234', baseContext),
).toEqual({ accepted: false });
});
});
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Signup bonus codes: the shape a code may take, and the hand-off to whatever
* extension decides what a code is worth. Core holds no codes and grants
* nothing.
*
* Codes have one canonical form (lower case, separators dropped) so a code
* typed as `ABC-123` and linked as `abc123` is one code to every counter keyed
* on it.
*/
import type { EventClient } from '../clients/event/EventClient';
import type { EventMap } from '../clients/event/types';
import { HttpError } from '../core/http/HttpError.js';
const CANONICAL_BONUS_CODE = /^[a-z0-9]{4,64}$/;
/** Raw input is capped before normalizing so a huge string is never scanned. */
const RAW_BONUS_CODE_MAX_LENGTH = 128;
/** The canonical form of `raw`, or null when it can't be a bonus code. */
export function normalizeBonusCode(raw: unknown): string | null {
if (typeof raw !== 'string' || raw.length > RAW_BONUS_CODE_MAX_LENGTH) {
return null;
}
const canonical = raw.toLowerCase().replace(/[\s_-]/g, '');
return CANONICAL_BONUS_CODE.test(canonical) ? canonical : null;
}
/** Whether a request body field counts as "no bonus code presented". */
export const isAbsentBonusCode = (raw: unknown): boolean =>
raw === undefined || raw === null || raw === '';
/**
* One answer for every way a code can fail — unknown, expired, used up — so
* signup can't be used to tell them apart.
*/
export const bonusCodeInvalidError = (): HttpError =>
new HttpError(400, 'This bonus code is invalid or no longer available.', {
legacyCode: 'bonus_code_invalid',
});
/**
* Ask listeners what a canonical code grants right now — the preview the signup
* form shows. Signup asks it too, before its abuse checks, so a dead code is
* refused before those checks record the attempt against the address.
*/
export async function checkSignupBonus(
events: EventClient | undefined,
code: string,
{ ip, fingerprint }: { ip: string | null; fingerprint: string | null },
): Promise<EventMap['puter.signup-bonus.check']> {
const event: EventMap['puter.signup-bonus.check'] = {
code,
ip,
fingerprint,
valid: false,
reason: null,
display: null,
requirements: null,
};
try {
await events?.emitAndWait('puter.signup-bonus.check', event, {});
} catch (e) {
console.warn('[signup] bonus check hook failed:', e);
}
return event;
}
export type SignupBonusContext = Omit<
EventMap['puter.signup-bonus.validate'],
'code' | 'accepted'
>;
export type SignupBonusVerdict =
| { accepted: false }
| {
accepted: true;
requiresPhoneVerification: boolean;
requiresCardVerification: boolean;
};
/**
* Offer a canonical bonus code to listeners for a signup that already passed
* `puter.signup.validate`. With no listener installed nothing accepts, so a
* code is refused rather than silently ignored.
*/
export async function validateSignupBonus(
events: EventClient | undefined,
code: string,
context: SignupBonusContext,
): Promise<SignupBonusVerdict> {
const event: EventMap['puter.signup-bonus.validate'] = {
...context,
code,
accepted: false,
};
try {
await events?.emitAndWait('puter.signup-bonus.validate', event, {});
} catch (e) {
console.warn('[signup] bonus validate hook failed:', e);
}
if (event.accepted !== true) return { accepted: false };
return {
accepted: true,
requiresPhoneVerification:
context.requires_phone_verification ||
event.requires_phone_verification === true,
requiresCardVerification:
context.requires_card_verification ||
event.requires_card_verification === true,
};
}
+4
View File
@@ -440,6 +440,10 @@ async function UIWindowLogin (options) {
if ( referrer ) {
url += `&referrer=${encodeURIComponent(referrer)}`;
}
// A provider login can create the account, so a signup link's code rides along.
if ( window.signup_bonus_code ) {
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
}
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
if ( window.openerOrigin ) {
+64
View File
@@ -27,6 +27,30 @@ import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderI
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js';
// What a checked bonus code grants, above the form (and the provider buttons).
function renderBonusNotice(el_window, result) {
const $notice = $(el_window).find('.signup-bonus-notice');
if (!result) {
$notice.hide().empty();
return;
}
if (!result.valid) {
$notice
.addClass('signup-bonus-notice-invalid')
.html(`<p>${i18n('signup_bonus_code_invalid_retry')}</p>`)
.show();
return;
}
const requirementsKey = bonusRequirementsKey(result.requirements);
let h = `<strong>${html_encode(result.display.title)}</strong>`;
h += `<p>${html_encode(result.display.description)}</p>`;
if (requirementsKey) {
h += `<p class="signup-bonus-requirements">${i18n(requirementsKey)}</p>`;
}
$notice.removeClass('signup-bonus-notice-invalid').html(h).show();
}
function UIWindowSignup(options) {
options = options ?? {};
@@ -70,6 +94,7 @@ function UIWindowSignup(options) {
if (window.embedded_in_popup && window.openerOrigin) {
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(window.openerOrigin).hostname])}</p>`;
}
h += '<div class="signup-bonus-notice" style="display:none;"></div>';
// signup form
h += '<form class="signup-form">';
// error msg
@@ -232,6 +257,27 @@ function UIWindowSignup(options) {
initTurnstile();
if (window.signup_bonus_code) {
(async () => {
let fingerprint = null;
try {
fingerprint =
await window.getDeviceFingerprint?.();
} catch (_) {
// the check works without device signals
}
const result = await checkSignupBonusCode(
window.signup_bonus_code,
{ origin: window.gui_origin, fingerprint },
);
// Unknown (null) keeps the code; signup has the final say.
if (result && !result.valid) {
window.signup_bonus_code = null;
}
renderBonusNotice(el_window, result);
})();
}
(async () => {
try {
// A federated hop navigates this popup away and the
@@ -268,6 +314,9 @@ function UIWindowSignup(options) {
if (referrer) {
url += `&referrer=${encodeURIComponent(referrer)}`;
}
if (window.signup_bonus_code) {
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
}
if (
window.embedded_in_popup &&
window.url_query_params?.get('msg_id')
@@ -515,6 +564,9 @@ function UIWindowSignup(options) {
if (fingerprint) {
requestData.fingerprint = fingerprint;
}
if (window.signup_bonus_code) {
requestData.bonusCode = window.signup_bonus_code;
}
$.ajax({
url: `${window.gui_origin}/signup`,
@@ -669,6 +721,18 @@ function UIWindowSignup(options) {
return;
}
// Drop the code so the next attempt signs up without it.
if (errorJson?.code === 'bonus_code_invalid') {
window.signup_bonus_code = null;
// The error below says it; the offer no longer applies.
renderBonusNotice(el_window, null);
$(el_window)
.find('.signup-error-msg')
.html(i18n('signup_bonus_code_invalid_retry'));
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// Handle timeout specifically
if (
errorJson?.code === 'response_timeout' ||
+19
View File
@@ -2241,6 +2241,25 @@ label {
color: #5f6368;
}
.signup-bonus-notice {
text-align: center;
margin: 15px 0;
padding: 12px;
border-radius: 4px;
background-color: #eaf5ee;
color: #1e4620;
font-size: 14px;
}
.signup-bonus-notice p {
margin: 6px 0 0;
}
.signup-bonus-notice-invalid {
background-color: #fdecea;
color: #611a15;
}
/* -- Logo strip above the login / signup forms -- */
.auth-logo-header {
display: flex;
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Signup bonus codes arrive as `?bonusCode=` on a signup link. The GUI only
* carries the code; the backend decides what, if anything, it grants.
*/
export const BONUS_CODE_PARAM = 'bonusCode';
// Loose on purpose: the backend owns the canonical form. This only keeps
// arbitrary text out of requests and the page.
const BONUS_CODE_SHAPE = /^[A-Za-z0-9_-]{4,128}$/;
/** The bonus code a URL's query carries, or null. */
export function readSignupBonusCode(params) {
const raw = params?.get?.(BONUS_CODE_PARAM);
return typeof raw === 'string' && BONUS_CODE_SHAPE.test(raw) ? raw : null;
}
/**
* Ask the backend what a code grants. Resolves `{ valid: true, display,
* requirements }`, `{ valid: false }`, or null when the answer is unknown
* (offline, rate limited) — callers keep the code then and let signup decide.
*/
export async function checkSignupBonusCode(
code,
{ origin, fingerprint, fetchImpl = fetch } = {},
) {
try {
const res = await fetchImpl(`${origin}/signup/bonus-code/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bonusCode: code,
...(fingerprint ? { fingerprint } : {}),
}),
});
if (!res.ok) return null;
const data = await res.json();
if (data?.valid === true && data.display) return data;
return { valid: false };
} catch {
return null;
}
}
/** I18n key describing the verification a code requires, or null. */
export function bonusRequirementsKey(requirements) {
const phone = requirements?.phone === true;
const card = requirements?.card === true;
if (phone && card) return 'signup_bonus_requires_phone_and_card';
if (card) return 'signup_bonus_requires_card';
if (phone) return 'signup_bonus_requires_phone';
return null;
}
+118
View File
@@ -0,0 +1,118 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect } from 'vitest';
import {
bonusRequirementsKey,
checkSignupBonusCode,
readSignupBonusCode,
} from './signupBonusCode.js';
const query = (search) => new URLSearchParams(search);
describe('readSignupBonusCode', () => {
it('reads a well-shaped code from the query', () => {
expect(readSignupBonusCode(query('?bonusCode=startup3m-7KQ2'))).toBe(
'startup3m-7KQ2',
);
});
it('ignores a missing or ill-shaped code', () => {
expect(readSignupBonusCode(query(''))).toBeNull();
expect(readSignupBonusCode(query('?bonusCode=abc'))).toBeNull();
expect(
readSignupBonusCode(query('?bonusCode=<script>1234</script>')),
).toBeNull();
expect(readSignupBonusCode(undefined)).toBeNull();
});
});
describe('checkSignupBonusCode', () => {
const respond = (status, body) => {
const calls = [];
const fetchImpl = async (url, init) => {
calls.push({ url, init });
return {
ok: status >= 200 && status < 300,
json: async () => body,
};
};
return { fetchImpl, calls };
};
it('posts the code (and fingerprint) to the check route', async () => {
const { fetchImpl, calls } = respond(200, { valid: false });
await checkSignupBonusCode('startup3m-7kq2', {
origin: 'https://puter.test',
fingerprint: 'fp1',
fetchImpl,
});
expect(calls[0].url).toBe('https://puter.test/signup/bonus-code/check');
expect(JSON.parse(calls[0].init.body)).toEqual({
bonusCode: 'startup3m-7kq2',
fingerprint: 'fp1',
});
});
it('returns a valid answer as-is', async () => {
const answer = {
valid: true,
display: { title: 'T', description: 'D' },
requirements: { phone: true, card: false },
};
const { fetchImpl } = respond(200, answer);
expect(await checkSignupBonusCode('abcd', { fetchImpl })).toEqual(
answer,
);
});
it('collapses a refusal to invalid', async () => {
const { fetchImpl } = respond(200, { valid: false, reason: 'ended' });
expect(await checkSignupBonusCode('abcd', { fetchImpl })).toEqual({
valid: false,
});
});
it('returns null when the answer is unknown', async () => {
const { fetchImpl } = respond(429, {});
expect(await checkSignupBonusCode('abcd', { fetchImpl })).toBeNull();
const offline = async () => {
throw new Error('offline');
};
expect(
await checkSignupBonusCode('abcd', { fetchImpl: offline }),
).toBeNull();
});
});
describe('bonusRequirementsKey', () => {
it('names the verification a code requires', () => {
expect(bonusRequirementsKey({ phone: true, card: false })).toBe(
'signup_bonus_requires_phone',
);
expect(bonusRequirementsKey({ phone: false, card: true })).toBe(
'signup_bonus_requires_card',
);
expect(bonusRequirementsKey({ phone: true, card: true })).toBe(
'signup_bonus_requires_phone_and_card',
);
expect(bonusRequirementsKey({ phone: false, card: false })).toBeNull();
expect(bonusRequirementsKey(null)).toBeNull();
});
});
+5
View File
@@ -843,6 +843,11 @@ const en = {
'signup_blocked_message': 'Your account could not be created.',
'contact_support': 'Please contact support@puter.com.',
'contact_support_with_code': 'Please contact support@puter.com and include this code: {{id}}.',
'signup_bonus_code_invalid': 'This bonus code is invalid or no longer available.',
'signup_bonus_code_invalid_retry': 'This bonus code is invalid or no longer available. You can still create your account without it.',
'signup_bonus_requires_phone': 'To claim it, you\'ll verify your phone number after signing up.',
'signup_bonus_requires_card': 'To claim it, you\'ll verify a card after signing up.',
'signup_bonus_requires_phone_and_card': 'To claim it, you\'ll verify your phone number and a card after signing up.',
// Welcome Window
'welcome': 'Welcome',
+5
View File
@@ -59,6 +59,7 @@ import item_icon from './helpers/itemIcon.js';
import { installAppIconFallback } from './helpers/appIcon.js';
import launch_app from './helpers/launchApp.js';
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
import { parse_url_paths } from './helpers/urlPaths.js';
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
import update_mouse_position from './helpers/updateMousePosition.js';
@@ -1025,6 +1026,9 @@ function authErrorDisplayMessage() {
if (code === 'account_suspended') {
return i18n('account_suspended_message', [], false);
}
if (code === 'bonus_code_invalid') {
return i18n('signup_bonus_code_invalid_retry', [], false);
}
return i18n('auth_error_generic', [], false);
}
@@ -1200,6 +1204,7 @@ window.initgui = async function (options) {
// GET query params provided
window.url_query_params = new URLSearchParams(window.location.search);
window.signup_bonus_code = readSignupBonusCode(window.url_query_params);
// Install device signal helpers; collection is lazy. The fingerprint is
// on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude