From 6e4288d7a62fa728813a9369974cedc360a0d844 Mon Sep 17 00:00:00 2001 From: Matanya Date: Fri, 9 Oct 2026 15:37:29 -0600 Subject: [PATCH 1/2] feat(auth): carry installer attribution through signup --- src/auth/oauth.ts | 3 +++ src/auth/signup-attribution.ts | 46 +++++++++++++++++++++++++++++++++ src/commands/auth/signup.ts | 4 ++- test/signup-attribution.test.ts | 14 ++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/auth/signup-attribution.ts create mode 100644 test/signup-attribution.test.ts diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index e004411..6322e1e 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -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'; @@ -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. diff --git a/src/auth/signup-attribution.ts b/src/auth/signup-attribution.ts new file mode 100644 index 0000000..11fa81c --- /dev/null +++ b/src/auth/signup-attribution.ts @@ -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; + 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; + } +} diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 62a7331..5d61329 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -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'; @@ -300,11 +301,12 @@ export async function emailSignup(config: Config, args: Record) // 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)) { diff --git a/test/signup-attribution.test.ts b/test/signup-attribution.test.ts new file mode 100644 index 0000000..d714af4 --- /dev/null +++ b/test/signup-attribution.test.ts @@ -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' }); +}); From 4b8538840ef1d493858b0b1d0361a7221f2b3388 Mon Sep 17 00:00:00 2001 From: Matanya Date: Fri, 9 Oct 2026 15:42:44 -0600 Subject: [PATCH 2/2] fix(integration): supply required Better Stack team name --- src/commands/integration/connect.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index f39fda3..11c407c 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -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, @@ -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([ @@ -1206,6 +1208,7 @@ export const integrationConnectCommand: Command = { { flag: '--api-token ', description: 'API token (Axiom / Better Stack global token)', type: 'string' }, { flag: '--stack-url ', description: 'Grafana Cloud stack URL, e.g. https://mystack.grafana.net', type: 'string' }, { flag: '--service-account-token ', description: 'Service account token (Grafana only, glsa_...)', type: 'string' }, + { flag: '--team-name ', description: 'Team name (Better Stack only)', type: 'string' }, { flag: '--uptime-api-token ', description: 'Uptime API token (Better Stack only)', type: 'string' }, { flag: '--telemetry-api-token ', description: 'Telemetry API token (Better Stack only)', type: 'string' }, { flag: '--service-account-username ', description: 'Service account username (Mixpanel only)', type: 'string' }, @@ -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_...',