mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-02 09:58:16 +00:00
feat(auth): signup bonus codes
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user