Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readInstallAttribution } from './signup-attribution';
import { createServer } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import type { Config } from '../config/schema';
Expand Down Expand Up @@ -293,6 +294,8 @@ export function buildBrowserFlowUrls(
// the signup routes, so the install referral survives the browser hop.
const ref = readInstallRef();
if (ref) openUrl.searchParams.set('ref', ref);
const attribution = readInstallAttribution();
if (attribution) openUrl.searchParams.set('attribution', JSON.stringify(attribution));
if (runId) openUrl.searchParams.set(ONBOARDING_RUN_QUERY_PARAM, runId);
// The provider round-trip takes longer than a plain consent hop whether or
// not the account is new, so every /signup entry gets the longer budget.
Expand Down
46 changes: 46 additions & 0 deletions src/auth/signup-attribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { CONFIG_DIR } from '../config/paths';

export interface SignupAttribution {
source?: string;
medium?: string;
campaign?: string;
referrer?: string;
landingPage?: string;
blog?: string;
signupPage?: string;
}

export function parseSignupAttribution(value: unknown): SignupAttribution | null {
if (typeof value === "string") {
if (value.length > 4096) return null;
try {
value = JSON.parse(value);
} catch {
return null;
}
}
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const input = value as Record<string, unknown>;
const output: SignupAttribution = {};
for (const field of ["source", "medium", "campaign", "referrer", "landingPage", "blog", "signupPage"] as const) {
const text = input[field];
if (typeof text !== "string" || !text || text.length > 256) continue;
if (field === "landingPage" || field === "signupPage") {
if (!/^\/[a-zA-Z0-9/._-]*$/.test(text)) continue;
} else if (!/^[a-zA-Z0-9 ._:/-]+$/.test(text)) continue;
output[field] = text;
}
return Object.keys(output).length ? output : null;
}

export function readInstallAttribution(): SignupAttribution | null {
try {
const raw = readFileSync(join(CONFIG_DIR, 'attribution'), 'utf8').trim();
if (raw.length > 12288) return null;
return parseSignupAttribution(decodeURIComponent(raw));
} catch {
return null;
}
}
4 changes: 3 additions & 1 deletion src/commands/auth/signup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readInstallAttribution } from '../../auth/signup-attribution';
import type { Command } from '../../command';
import type { Config } from '../../config/schema';
import { formatOutput } from '../../output/formatter';
Expand Down Expand Up @@ -300,11 +301,12 @@ export async function emailSignup(config: Config, args: Record<string, unknown>)
// the pre-auth onboarding run identifier. The server drops invalid values
// and never rejects on them.
const ref = readInstallRef();
const attribution = readInstallAttribution();
const run = resolveOnboardingRunId();
const res = await request(config, {
method: 'POST',
url: '/v1/auth/signup',
body: { email, password, ...(ref ? { ref } : {}), ...(run ? { run } : {}) },
body: { email, password, ...(attribution ? { attribution: JSON.stringify(attribution) } : {}), ...(ref ? { ref } : {}), ...(run ? { run } : {}) },
noAuth: true,
});
if (isCloudflareChallenge(res)) {
Expand Down
7 changes: 5 additions & 2 deletions src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,10 +802,12 @@ async function connectWithCredentials(
if (!ok) return BACK;
body = { type: 'axiom', workspaceId, apiToken, ...(region !== undefined ? { region: region as AxiomRegion } : {}) };
} else if (type === 'betterstack') {
let teamName = '';
let apiToken = '';
let uptimeApiToken = '';
let telemetryApiToken = '';
const ok = await runSteps([
textStep(config, args, 'teamName', 'Better Stack team name', '--team-name', (v) => { teamName = v; }),
secretStep(
config,
args,
Expand Down Expand Up @@ -856,7 +858,7 @@ async function connectWithCredentials(
),
]);
if (!ok) return BACK;
body = { type: 'betterstack', workspaceId, apiToken, uptimeApiToken, telemetryApiToken };
body = { type: 'betterstack', workspaceId, teamName, apiToken, uptimeApiToken, telemetryApiToken };
} else if (type === 'openstatus') {
let apiKey = '';
const ok = await runSteps([
Expand Down Expand Up @@ -1206,6 +1208,7 @@ export const integrationConnectCommand: Command = {
{ flag: '--api-token <token>', description: 'API token (Axiom / Better Stack global token)', type: 'string' },
{ flag: '--stack-url <url>', description: 'Grafana Cloud stack URL, e.g. https://mystack.grafana.net', type: 'string' },
{ flag: '--service-account-token <token>', description: 'Service account token (Grafana only, glsa_...)', type: 'string' },
{ flag: '--team-name <name>', description: 'Team name (Better Stack only)', type: 'string' },
{ flag: '--uptime-api-token <token>', description: 'Uptime API token (Better Stack only)', type: 'string' },
{ flag: '--telemetry-api-token <token>', description: 'Telemetry API token (Better Stack only)', type: 'string' },
{ flag: '--service-account-username <username>', description: 'Service account username (Mixpanel only)', type: 'string' },
Expand Down Expand Up @@ -1241,7 +1244,7 @@ export const integrationConnectCommand: Command = {
'polylane integration connect --type datadog --site us5.datadoghq.com --api-key ... --app-key ...',
'polylane integration connect --type honeycomb --region us --api-key ... --management-api-key-id ... --management-api-key-secret ...',
'polylane integration connect --type axiom --api-token ...',
'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...',
'polylane integration connect --type betterstack --team-name MyTeam --api-token ... --uptime-api-token ... --telemetry-api-token ...',
'polylane integration connect --type openstatus --api-key ...',
'polylane integration connect --type grafana --stack-url https://mystack.grafana.net --service-account-token glsa_...',
'polylane integration connect --type logfire --api-key pylf_... --organization-api-key pylf_...',
Expand Down
14 changes: 14 additions & 0 deletions test/signup-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseSignupAttribution } from '../src/auth/signup-attribution';

test('installer attribution retains the original source and the assisting article', () => {
const attribution = { source: 'producthunt', campaign: 'launch', landingPage: '/', blog: 'context-graph', signupPage: '/pricing/' };
assert.deepEqual(parseSignupAttribution(JSON.stringify(attribution)), attribution);
});

test('invalid attribution fields are discarded without breaking signup', () => {
assert.equal(parseSignupAttribution('broken'), null);
assert.equal(parseSignupAttribution('x'.repeat(4097)), null);
assert.deepEqual(parseSignupAttribution({ source: 'google', landingPage: '/?secret=private', arbitrary: 'secret' }), { source: 'google' });
});
Loading