Compare commits

...
Author SHA1 Message Date
Nariman Jelveh e03d4df030 feat(signup): carry utm params from landing page to puter.signup.success
The GUI keeps the utm_* params a visitor landed with for the session and
sends them with form, temp and OIDC signups. The backend shapes them into
a typed utm field on puter.signup.success; OIDC carries them through the
signed state like bonus codes.
2026-11-03 19:44:48 -07:00
15 changed files with 513 additions and 6 deletions

No files matched your search

+3
View File
@@ -22,6 +22,7 @@ import type {
import { Actor } from '../../core';
import type { UsageInput } from '../../services/metering/types';
import { FSEntry } from '../../stores/fs/FSEntry';
import type { SignupUtm } from '../../util/signupUtm';
// GUI write events spread an entry plus per-event metadata into `response`.
// The exact field set varies by emit site (FSController / LegacyFSController /
@@ -250,6 +251,8 @@ export type EventMap = {
is_temp?: boolean;
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
bonus_code?: string;
/** The `utm_*` params the signup carried, if any. Client-supplied. */
utm?: SignupUtm;
[key: string]: unknown;
};
// Signup bonus codes are pure mechanism here: an extension decides what a
@@ -1073,6 +1073,62 @@ describe('AuthController.handleSignup', () => {
).toBe(true);
});
it('announces the utm params a form signup carried', async () => {
const baseline = heardSignupSuccess.length;
const username = `utm_${uniq()}`;
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
utm_source: 'newsletter',
utm_campaign: 'launch',
utm_id: 'not-tracked',
}),
makeRes(),
);
const evt = heardSignupSuccess
.slice(baseline)
.find((e) => e.username === username);
expect(evt?.utm).toEqual({ source: 'newsletter', campaign: 'launch' });
expect(evt).not.toHaveProperty('password');
});
it('announces utm on a temp signup without exposing its placeholder email', async () => {
const baseline = heardSignupSuccess.length;
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, utm_source: 'ads' }),
res,
);
const evt = heardSignupSuccess
.slice(baseline)
.find((e) => e.is_temp === true);
expect(evt?.utm).toEqual({ source: 'ads' });
expect(evt?.email).toBeNull();
});
it('leaves utm off the event when the signup carried none', async () => {
const baseline = heardSignupSuccess.length;
const username = `noutm_${uniq()}`;
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const evt = heardSignupSuccess
.slice(baseline)
.find((e) => e.username === username);
expect(evt).toBeDefined();
expect(evt).not.toHaveProperty('utm');
});
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
@@ -77,6 +77,7 @@ import {
isAbsentBonusCode,
normalizeBonusCode,
} from '../../util/signupBonus.js';
import { readSignupUtm } from '../../util/signupUtm.js';
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import { buildUserDetails, scrubSensitive } from '../../util/userDetails.js';
import {
@@ -888,6 +889,7 @@ export class AuthController extends PuterController {
isTemp: is_temp,
fingerprint,
bonusCode,
utm: readSignupUtm(body),
});
await this.#completeLogin(req, res, user);
}
@@ -2386,4 +2386,46 @@ describe('OIDC signup bonus codes', () => {
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
});
it('carries shaped utm params in the signed state', async () => {
const decoded = await startState({
flow: 'signup',
utm_source: 'ads',
utm_campaign: 'launch',
utm_id: 'not-tracked',
utm_medium: '',
});
expect(decoded.utm).toEqual({ source: 'ads', campaign: 'launch' });
});
it('leaves utm out of the state when none were sent', async () => {
const decoded = await startState({ flow: 'signup' });
expect(decoded).not.toHaveProperty('utm');
});
it('announces the state utm on the new account, re-shaped', async () => {
const email = `utm-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
utm: { source: 'ads', medium: 7, extra: 'x' },
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.cookies).toHaveLength(1);
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?.utm).toEqual({ source: 'ads' });
});
});
+12 -3
View File
@@ -33,6 +33,11 @@ import {
import type { UserRow } from '../../stores/user/UserStore.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
import {
normalizeSignupUtm,
readSignupUtm,
type SignupUtm,
} from '../../util/signupUtm.js';
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
@@ -88,6 +93,7 @@ function resolutionErrorCode(code: string | undefined): string {
interface OIDCSignupOptions {
referrer?: string | null;
bonusCode?: string | null;
utm?: SignupUtm | null;
}
function signupOptionsFromState(
@@ -98,6 +104,7 @@ function signupOptionsFromState(
// Normalized when the state was signed; re-checked since this is the
// value signup acts on.
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
utm: normalizeSignupUtm(stateDecoded.utm),
};
}
@@ -495,8 +502,10 @@ export class OIDCController extends PuterController {
? req.query.bonusCode[0]
: req.query.bonusCode;
const bonusCode = normalizeBonusCode(rawBonusCode);
if (bonusCode && (flow === 'login' || flow === 'signup')) {
statePayload.bonus_code = bonusCode;
const utm = readSignupUtm(req.query);
if (flow === 'login' || flow === 'signup') {
if (bonusCode) statePayload.bonus_code = bonusCode;
if (utm) statePayload.utm = utm;
}
if (embeddedInPopup && msgId) {
statePayload.embedded_in_popup = true;
@@ -887,7 +896,7 @@ if (window.opener) {
provider,
userinfo as { sub: string; email?: string },
signup.referrer ?? null,
{ bonusCode: signup.bonusCode ?? null },
{ bonusCode: signup.bonusCode ?? null, utm: signup.utm ?? null },
);
// A concurrent callback (a second tab, a provider retry) created the
// account between step 2 and the insert. Nothing went wrong for the
@@ -1164,6 +1164,31 @@ describe('OIDCService.createUserFromOIDC', () => {
server.clients.event.off('puter.signup.success', onSignup);
});
it('announces the utm it was given', async () => {
const email = `utm-${crypto.randomBytes(4).toString('hex')}@example.com`;
const signups: Array<Record<string, unknown>> = [];
const onSignup = (_k: string, data: unknown) =>
signups.push(data as Record<string, unknown>);
server.clients.event.on('puter.signup.success', onSignup);
try {
const result = await runWithContext({ req }, () =>
oidc().createUserFromOIDC(
'google',
{ sub: `utm-sub-${email}`, email, email_verified: true },
null,
{ utm: { source: 'ads', campaign: 'launch' } },
),
);
expect(result.success).toBe(true);
expect(signups[0]?.utm).toEqual({
source: 'ads',
campaign: 'launch',
});
} finally {
server.clients.event.off('puter.signup.success', onSignup);
}
});
it('retries the generated username when its home path is already occupied', async () => {
// The RNG is input to the candidate generator, not the code under
// test: compute the same first candidate the service will try.
+8 -2
View File
@@ -30,6 +30,7 @@ import { Context } from '../../core';
import { HttpError } from '../../core/http/HttpError.js';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import type { SignupUtm } from '../../util/signupUtm.js';
import { verifyOidcIdToken, type JwksCacheEntry } from './oidcIdToken';
const GOOGLE_DISCOVERY_URL =
@@ -492,13 +493,17 @@ export class OIDCService extends PuterService {
* re-resolve rather than surface an error — see
* `#resolveOrCreateOIDCUser`.
*
* `bonusCode` must already be canonical (`normalizeBonusCode`).
* `bonusCode` must already be canonical (`normalizeBonusCode`) and `utm`
* already shaped (`normalizeSignupUtm`).
*/
async createUserFromOIDC(
providerId: string,
claims: OIDCUserInfo,
referrer?: string | null,
{ bonusCode = null }: { bonusCode?: string | null } = {},
{
bonusCode = null,
utm = null,
}: { bonusCode?: string | null; utm?: SignupUtm | null } = {},
): Promise<{
success: boolean;
user?: UserRow;
@@ -686,6 +691,7 @@ export class OIDCService extends PuterService {
bonusCode,
emailConfirmed: true,
saveAccount: true,
utm,
});
return { success: true, user };
+6 -1
View File
@@ -32,6 +32,7 @@ import {
checkSignupBonus,
validateSignupBonus,
} from '../../util/signupBonus.js';
import type { SignupUtm } from '../../util/signupUtm.js';
import { isUsernameTaken } from '../../util/username.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import {
@@ -83,6 +84,7 @@ export interface SignupInput {
isTemp: boolean;
fingerprint: string | null;
bonusCode: string | null;
utm?: SignupUtm | null;
}
/** The trusted client address; `req.ip` honors `trust proxy`. */
@@ -278,7 +280,7 @@ export class SignupService extends PuterService {
* refused signup answers with.
*/
async signup(input: SignupInput): Promise<UserRow> {
const { req, body, isTemp, fingerprint, bonusCode } = input;
const { req, body, isTemp, fingerprint, bonusCode, utm } = input;
// Runs before the duplicate checks so a disabled endpoint doesn't
// reveal which usernames or emails exist. Claiming a pre-existing
@@ -547,6 +549,7 @@ export class SignupService extends PuterService {
// The created/claimed row: a claimed placeholder has credentials.
isTemp: user.password === null && user.email === null,
saveAccount: !isTemp,
utm,
});
return user;
}
@@ -566,6 +569,7 @@ export class SignupService extends PuterService {
/** The provider verified the address, so this is its confirmation. */
emailConfirmed?: boolean;
saveAccount: boolean;
utm?: SignupUtm | null;
},
): void {
if (opts.emailConfirmed) this.#emitEmailConfirmed(user);
@@ -587,6 +591,7 @@ export class SignupService extends PuterService {
// counters are written under one key and read under another.
ip: opts.ip,
...(opts.bonusCode ? { bonus_code: opts.bonusCode } : {}),
...(opts.utm ? { utm: opts.utm } : {}),
},
{},
);
+85
View File
@@ -0,0 +1,85 @@
/*
* 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 {
normalizeSignupUtm,
readSignupUtm,
SIGNUP_UTM_MAX_LENGTH,
} from './signupUtm.js';
describe('readSignupUtm', () => {
it('reads the utm_* params, keyed without the prefix', () => {
expect(
readSignupUtm({
utm_source: 'newsletter',
utm_medium: 'email',
utm_campaign: 'launch',
utm_term: 'cloud os',
utm_content: 'hero-cta',
}),
).toEqual({
source: 'newsletter',
medium: 'email',
campaign: 'launch',
term: 'cloud os',
content: 'hero-cta',
});
});
it('drops unknown keys, non-strings, blanks and oversized values', () => {
expect(
readSignupUtm({
utm_source: ' ads ',
utm_medium: 42,
utm_campaign: ' ',
utm_term: 'x'.repeat(SIGNUP_UTM_MAX_LENGTH + 1),
utm_content: { nested: true },
utm_id: 'not-tracked',
password: 'secret',
}),
).toEqual({ source: 'ads' });
});
it('takes the first value of a repeated query param', () => {
expect(readSignupUtm({ utm_source: ['first', 'second'] })).toEqual({
source: 'first',
});
});
it('returns null when nothing usable is present', () => {
expect(readSignupUtm({})).toBeNull();
expect(readSignupUtm({ utm_source: '' })).toBeNull();
expect(readSignupUtm(undefined)).toBeNull();
});
});
describe('normalizeSignupUtm', () => {
it('keeps a well-formed value and re-checks each field', () => {
expect(
normalizeSignupUtm({ source: 'ads', medium: 7, extra: 'x' }),
).toEqual({ source: 'ads' });
});
it('returns null for anything that is not a plain object', () => {
expect(normalizeSignupUtm(null)).toBeNull();
expect(normalizeSignupUtm('ads')).toBeNull();
expect(normalizeSignupUtm(['ads'])).toBeNull();
});
});
+71
View File
@@ -0,0 +1,71 @@
/*
* 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/>.
*/
/**
* Campaign attribution a signup arrives with, as `utm_*` params. Core only
* shapes and forwards the values; what is done with them is up to listeners on
* `puter.signup.success`.
*/
export const SIGNUP_UTM_KEYS = [
'source',
'medium',
'campaign',
'term',
'content',
] as const;
export type SignupUtmKey = (typeof SIGNUP_UTM_KEYS)[number];
/** The `utm_*` values a signup carried, keyed without the prefix. */
export type SignupUtm = Partial<Record<SignupUtmKey, string>>;
/** Caps each value, which also bounds the OIDC state token that carries them. */
export const SIGNUP_UTM_MAX_LENGTH = 255;
const shapeValue = (raw: unknown): string | null => {
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed && trimmed.length <= SIGNUP_UTM_MAX_LENGTH ? trimmed : null;
};
/**
* A `SignupUtm` holding only the known keys with usable string values, or null
* when none are left.
*/
export function normalizeSignupUtm(raw: unknown): SignupUtm | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const src = raw as Record<string, unknown>;
const utm: SignupUtm = {};
for (const key of SIGNUP_UTM_KEYS) {
const value = shapeValue(src[key]);
if (value) utm[key] = value;
}
return Object.keys(utm).length > 0 ? utm : null;
}
/** The `utm_*` params in a request body or query, or null when there are none. */
export function readSignupUtm(params: unknown): SignupUtm | null {
if (!params || typeof params !== 'object') return null;
const src = params as Record<string, unknown>;
return normalizeSignupUtm(
Object.fromEntries(SIGNUP_UTM_KEYS.map((k) => [k, src[`utm_${k}`]])),
);
}
+2
View File
@@ -26,6 +26,7 @@ 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 { isAttestedOrigin } from '../helpers/attestedOrigin.js';
import { signupUtmQuery } from '../helpers/signupUtm.js';
async function UIWindowLogin (options) {
options = options ?? {};
@@ -222,6 +223,7 @@ async function UIWindowLogin (options) {
if ( window.signup_bonus_code ) {
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
}
url += signupUtmQuery(window.signup_utm);
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 ) {
+3
View File
@@ -29,6 +29,7 @@ import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedire
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js';
import { signupUtmQuery } from '../helpers/signupUtm.js';
// What a checked bonus code grants, above the form (and the provider buttons).
function renderBonusNotice(el_window, result) {
@@ -324,6 +325,7 @@ function UIWindowSignup(options) {
if (window.signup_bonus_code) {
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
}
url += signupUtmQuery(window.signup_utm);
if (
window.embedded_in_popup &&
window.url_query_params?.get('msg_id')
@@ -560,6 +562,7 @@ function UIWindowSignup(options) {
// Include captcha in request only if required
const requestData = {
...window.signup_utm,
username: username,
email: email,
password: password,
+79
View File
@@ -0,0 +1,79 @@
/*
* 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/>.
*/
/**
* Campaign `utm_*` params from the page a visitor landed on, sent with
* whichever signup they end up making. Kept for the tab's session so a reload
* or in-app navigation that drops the query doesn't lose them. The backend
* re-checks every value.
*/
export const SIGNUP_UTM_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
const STORAGE_KEY = 'signup_utm';
const MAX_LENGTH = 255;
/**
* The `utm_*` values in `params` (URLSearchParams or a plain object) as
* `{ utm_source: ... }`, or null when there are none.
*/
export function readSignupUtm(params) {
if (!params || typeof params !== 'object') return null;
const get =
typeof params.get === 'function'
? (key) => params.get(key)
: (key) => params[key];
const utm = {};
for (const key of SIGNUP_UTM_PARAMS) {
const value = get(key);
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (trimmed && trimmed.length <= MAX_LENGTH) utm[key] = trimmed;
}
return Object.keys(utm).length > 0 ? utm : null;
}
/**
* The page's utm params, saved to `storage` for later loads; with none on the
* page, the ones saved earlier this session.
*/
export function rememberSignupUtm(params, storage) {
const fromPage = readSignupUtm(params);
try {
if (fromPage) {
storage?.setItem(STORAGE_KEY, JSON.stringify(fromPage));
return fromPage;
}
const saved = storage?.getItem(STORAGE_KEY);
return saved ? readSignupUtm(JSON.parse(saved)) : null;
} catch {
return fromPage;
}
}
/** `utm` as `&key=value` pairs to append to a query string, or ''. */
export function signupUtmQuery(utm) {
return utm ? `&${new URLSearchParams(utm).toString()}` : '';
}
+110
View File
@@ -0,0 +1,110 @@
/*
* 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 {
readSignupUtm,
rememberSignupUtm,
signupUtmQuery,
} from './signupUtm.js';
const query = (search) => new URLSearchParams(search);
const memoryStorage = () => {
const items = new Map();
return {
getItem: (k) => (items.has(k) ? items.get(k) : null),
setItem: (k, v) => items.set(k, String(v)),
};
};
describe('readSignupUtm', () => {
it('reads the known utm params from a query', () => {
expect(
readSignupUtm(
query('?utm_source=ads&utm_campaign=launch&utm_id=x&ref=y'),
),
).toEqual({ utm_source: 'ads', utm_campaign: 'launch' });
});
it('drops blank and oversized values', () => {
expect(
readSignupUtm(
query(`?utm_source=%20&utm_term=${'x'.repeat(256)}`),
),
).toBeNull();
});
it('reads a plain object and ignores non-strings', () => {
expect(readSignupUtm({ utm_source: 'ads', utm_medium: 3 })).toEqual({
utm_source: 'ads',
});
expect(readSignupUtm(undefined)).toBeNull();
});
});
describe('rememberSignupUtm', () => {
it('saves the page params and returns them on a later load without any', () => {
const storage = memoryStorage();
expect(rememberSignupUtm(query('?utm_source=ads'), storage)).toEqual({
utm_source: 'ads',
});
expect(rememberSignupUtm(query(''), storage)).toEqual({
utm_source: 'ads',
});
});
it('lets new page params replace the saved ones', () => {
const storage = memoryStorage();
rememberSignupUtm(query('?utm_source=ads'), storage);
expect(
rememberSignupUtm(query('?utm_source=newsletter'), storage),
).toEqual({ utm_source: 'newsletter' });
});
it('still returns the page params when storage throws', () => {
const storage = {
getItem: () => {
throw new Error('blocked');
},
setItem: () => {
throw new Error('blocked');
},
};
expect(rememberSignupUtm(query('?utm_source=ads'), storage)).toEqual({
utm_source: 'ads',
});
expect(rememberSignupUtm(query(''), storage)).toBeNull();
});
it('ignores a corrupted saved value', () => {
const storage = memoryStorage();
storage.setItem('signup_utm', '{not json');
expect(rememberSignupUtm(query(''), storage)).toBeNull();
});
});
describe('signupUtmQuery', () => {
it('encodes utm as extra query pairs', () => {
expect(signupUtmQuery({ utm_source: 'a b', utm_campaign: 'x&y' })).toBe(
'&utm_source=a+b&utm_campaign=x%26y',
);
expect(signupUtmQuery(null)).toBe('');
});
});
+9
View File
@@ -67,6 +67,7 @@ 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 { rememberSignupUtm } from './helpers/signupUtm.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';
@@ -1413,6 +1414,13 @@ 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);
let sessionStore = null;
try {
sessionStore = window.sessionStorage;
} catch {
// storage blocked; utm from this page load still applies
}
window.signup_utm = rememberSignupUtm(window.url_query_params, sessionStore);
// Install device signal helpers; collection is lazy. The fingerprint is
// on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude
@@ -2575,6 +2583,7 @@ window.initgui = async function (options) {
// if this is a popup, show a spinner
let spinner_init_ts = Date.now();
const requestData = {
...window.signup_utm,
referrer: referrer,
is_temp: true,
};