Lay the HubSpot and customer-lifecycle foundation
Work in progress from the Codex session, committed so nothing sits undeployed. Verified before committing: typecheck clean across all packages, 139 unit tests and the e2e suite green, migrations apply to an empty Postgres. Adds the HubSpot integration boundary (OAuth, client, contracts, webhook signature verification, sync), a growth route, customer-lifecycle service, Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types. Two things are deliberately incomplete and should not be mistaken for finished: `packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it is inert — no tables, no migration. That is the correct order (the shape can settle before it becomes a migration), but it does mean the HubSpot routes have no persistence behind them yet. `pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The workspace file contains a literal unanswered placeholder — "esbuild: set this to true or false" — and this repository installs with npm, which is also what CI runs. Committing a second package manager's lockfile would make the install ambiguous. If the move to pnpm is intended it should be a deliberate change that updates CI and the Dockerfile together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { decryptSecret, encryptSecret } from '../../lib/secrets';
|
||||
import { HUBSPOT_REQUIRED_SCOPES } from './contracts';
|
||||
|
||||
const HUBSPOT_AUTHORIZE_URL = 'https://app.hubspot.com/oauth/authorize';
|
||||
const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v3/token';
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
const tokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expires_in: z.number().int().positive(),
|
||||
hub_id: z.union([z.string().min(1), z.number().int().nonnegative()]).transform(String),
|
||||
scopes: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export interface HubSpotOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface HubSpotTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresInSeconds: number;
|
||||
portalId: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export class HubSpotOAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HubSpotOAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHubSpotAuthorizationUrl(
|
||||
config: Pick<HubSpotOAuthConfig, 'clientId' | 'redirectUri'>,
|
||||
state: string,
|
||||
): string {
|
||||
const url = new URL(HUBSPOT_AUTHORIZE_URL);
|
||||
url.searchParams.set('client_id', config.clientId);
|
||||
url.searchParams.set('redirect_uri', config.redirectUri);
|
||||
url.searchParams.set('scope', HUBSPOT_REQUIRED_SCOPES.join(' '));
|
||||
url.searchParams.set('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export class HubSpotOAuthClient {
|
||||
constructor(
|
||||
private readonly config: HubSpotOAuthConfig,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
authorizationUrl(state: string): string {
|
||||
return buildHubSpotAuthorizationUrl(this.config, state);
|
||||
}
|
||||
|
||||
exchangeAuthorizationCode(code: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
|
||||
return this.tokenRequest({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
code,
|
||||
}, signal);
|
||||
}
|
||||
|
||||
refreshAccessToken(refreshToken: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
|
||||
return this.tokenRequest({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
refresh_token: refreshToken,
|
||||
}, signal);
|
||||
}
|
||||
|
||||
private async tokenRequest(
|
||||
form: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotTokenResponse> {
|
||||
const response = await this.fetchImpl(HUBSPOT_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(form),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HubSpotOAuthError('HubSpot rejected the OAuth token request.', response.status);
|
||||
}
|
||||
const parsed = tokenResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotOAuthError('HubSpot returned an invalid OAuth token response.');
|
||||
return {
|
||||
accessToken: parsed.data.access_token,
|
||||
refreshToken: parsed.data.refresh_token,
|
||||
expiresInSeconds: parsed.data.expires_in,
|
||||
portalId: parsed.data.hub_id,
|
||||
scopes: parsed.data.scopes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type TokenKind = 'access' | 'refresh';
|
||||
|
||||
function tokenPurpose(connectionId: string, kind: TokenKind): string {
|
||||
return `hubspot:${connectionId}:${kind}-token`;
|
||||
}
|
||||
|
||||
export class HubSpotTokenVault {
|
||||
constructor(private readonly encryptionKey: string | undefined) {}
|
||||
|
||||
encrypt(connectionId: string, kind: TokenKind, token: string): string {
|
||||
return encryptSecret(token, this.encryptionKey, tokenPurpose(connectionId, kind));
|
||||
}
|
||||
|
||||
decrypt(connectionId: string, kind: TokenKind, envelope: string): string {
|
||||
return decryptSecret(envelope, this.encryptionKey, tokenPurpose(connectionId, kind));
|
||||
}
|
||||
}
|
||||
|
||||
export interface LockedHubSpotCredential {
|
||||
id: string;
|
||||
status: 'active' | 'reauthorization_required' | 'disconnected' | 'error';
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
updateTokens(input: {
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
grantedScopes: readonly string[];
|
||||
refreshedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotCredentialLockStore {
|
||||
/** The adapter must hold one row/advisory lock through the callback and update. */
|
||||
withConnectionLock<T>(
|
||||
connectionId: string,
|
||||
operation: (credential: LockedHubSpotCredential) => Promise<T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export class HubSpotTokenManager {
|
||||
constructor(
|
||||
private readonly store: HubSpotCredentialLockStore,
|
||||
private readonly oauth: Pick<HubSpotOAuthClient, 'refreshAccessToken'>,
|
||||
private readonly vault: HubSpotTokenVault,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
getAccessToken(connectionId: string, signal?: AbortSignal): Promise<string> {
|
||||
return this.store.withConnectionLock(connectionId, async (credential) => {
|
||||
if (credential.status !== 'active') {
|
||||
throw new HubSpotOAuthError('The HubSpot connection is not active.');
|
||||
}
|
||||
const now = this.now();
|
||||
if (credential.accessTokenExpiresAt.getTime() > now.getTime() + TOKEN_REFRESH_SKEW_MS) {
|
||||
return this.vault.decrypt(connectionId, 'access', credential.encryptedAccessToken);
|
||||
}
|
||||
const refreshToken = this.vault.decrypt(
|
||||
connectionId,
|
||||
'refresh',
|
||||
credential.encryptedRefreshToken,
|
||||
);
|
||||
const refreshed = await this.oauth.refreshAccessToken(refreshToken, signal);
|
||||
const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !refreshed.scopes.includes(scope));
|
||||
if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
|
||||
const expiresAt = new Date(now.getTime() + refreshed.expiresInSeconds * 1_000);
|
||||
await credential.updateTokens({
|
||||
encryptedAccessToken: this.vault.encrypt(connectionId, 'access', refreshed.accessToken),
|
||||
encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', refreshed.refreshToken),
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
grantedScopes: refreshed.scopes,
|
||||
refreshedAt: now,
|
||||
});
|
||||
return refreshed.accessToken;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredHubSpotOAuthState {
|
||||
requestedByUserId: string;
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export interface HubSpotConnectionInstallStore {
|
||||
createOAuthState(input: {
|
||||
nonceHash: string;
|
||||
requestedByUserId: string;
|
||||
returnPath: string;
|
||||
expiresAt: Date;
|
||||
}): Promise<void>;
|
||||
consumeOAuthState(nonceHash: string, now: Date): Promise<StoredHubSpotOAuthState | null>;
|
||||
reserveConnectionId(portalId: string, proposedId: string): Promise<string>;
|
||||
saveConnection(input: {
|
||||
id: string;
|
||||
portalId: string;
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
grantedScopes: readonly string[];
|
||||
connectedByUserId: string;
|
||||
installedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
const OAUTH_STATE_TTL_MS = 10 * 60 * 1_000;
|
||||
const DEFAULT_RETURN_PATH = '/settings/integrations/hubspot';
|
||||
|
||||
export class HubSpotConnectionService {
|
||||
constructor(
|
||||
private readonly store: HubSpotConnectionInstallStore,
|
||||
private readonly oauth: Pick<HubSpotOAuthClient, 'authorizationUrl' | 'exchangeAuthorizationCode'>,
|
||||
private readonly vault: HubSpotTokenVault,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async begin(requestedByUserId: string): Promise<{ authorizationUrl: string }> {
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const now = this.now();
|
||||
await this.store.createOAuthState({
|
||||
nonceHash: hashOAuthState(state),
|
||||
requestedByUserId,
|
||||
returnPath: DEFAULT_RETURN_PATH,
|
||||
expiresAt: new Date(now.getTime() + OAUTH_STATE_TTL_MS),
|
||||
});
|
||||
return { authorizationUrl: this.oauth.authorizationUrl(state) };
|
||||
}
|
||||
|
||||
async complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }> {
|
||||
const now = this.now();
|
||||
const storedState = await this.store.consumeOAuthState(hashOAuthState(state), now);
|
||||
if (!storedState) throw new HubSpotOAuthError('The HubSpot OAuth state is invalid or expired.');
|
||||
const tokens = await this.oauth.exchangeAuthorizationCode(code, signal);
|
||||
const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !tokens.scopes.includes(scope));
|
||||
if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
|
||||
const connectionId = await this.store.reserveConnectionId(tokens.portalId, randomUUID());
|
||||
await this.store.saveConnection({
|
||||
id: connectionId,
|
||||
portalId: tokens.portalId,
|
||||
encryptedAccessToken: this.vault.encrypt(connectionId, 'access', tokens.accessToken),
|
||||
encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', tokens.refreshToken),
|
||||
accessTokenExpiresAt: new Date(now.getTime() + tokens.expiresInSeconds * 1_000),
|
||||
grantedScopes: tokens.scopes,
|
||||
connectedByUserId: storedState.requestedByUserId,
|
||||
installedAt: now,
|
||||
});
|
||||
return { returnPath: storedState.returnPath };
|
||||
}
|
||||
}
|
||||
|
||||
export function hashOAuthState(state: string): string {
|
||||
return createHash('sha256').update(state, 'utf8').digest('hex');
|
||||
}
|
||||
Reference in New Issue
Block a user