@hogsend/cli 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { CALLBACK_PATH, CALLBACK_TIMEOUT_MS } from "./oauth.js";
3
+
4
+ /**
5
+ * RFC 8252 loopback redirect receiver for the OAuth callback. Binds
6
+ * 127.0.0.1 ONLY (never 0.0.0.0), tries the fixed port list in order
7
+ * (production: `LOOPBACK_PORTS`, registered in the CIMD document; tests
8
+ * inject `[0]` for ephemeral binds), and settles exactly once — later
9
+ * requests get a 410.
10
+ */
11
+
12
+ export type LoopbackFailure =
13
+ | "ports_busy"
14
+ | "state_mismatch"
15
+ | "consent_denied"
16
+ | "timeout"
17
+ | "oauth_error";
18
+
19
+ export class LoopbackError extends Error {
20
+ readonly reason: LoopbackFailure;
21
+ readonly detail?: string;
22
+
23
+ constructor(reason: LoopbackFailure, message: string, detail?: string) {
24
+ super(message);
25
+ this.name = "LoopbackError";
26
+ this.reason = reason;
27
+ this.detail = detail;
28
+ }
29
+ }
30
+
31
+ export interface LoopbackServer {
32
+ port: number;
33
+ /** `http://127.0.0.1:${port}${callbackPath}` */
34
+ redirectUri: string;
35
+ waitForCallback(opts?: { timeoutMs?: number }): Promise<{ code: string }>;
36
+ close(): Promise<void>;
37
+ }
38
+
39
+ const page = (title: string, body: string): string =>
40
+ `<!doctype html><html><head><meta charset="utf-8"><title>Hogsend</title>` +
41
+ `<style>body{font-family:system-ui,sans-serif;max-width:32rem;` +
42
+ `margin:6rem auto;padding:0 1rem;color:#1a1a1a}</style></head>` +
43
+ `<body><h1>${title}</h1><p>${body}</p></body></html>`;
44
+
45
+ const SUCCESS_HTML = page(
46
+ "Connected",
47
+ "You can close this tab and return to your terminal.",
48
+ );
49
+ const DENIED_HTML = page(
50
+ "Not connected",
51
+ "Authorization was denied. Close this tab and re-run the command if that " +
52
+ "was a mistake.",
53
+ );
54
+ const MISMATCH_HTML = page(
55
+ "Not connected",
56
+ "State mismatch. Close this tab and re-run the command.",
57
+ );
58
+ const NO_CODE_HTML = page(
59
+ "Not connected",
60
+ "The callback carried no authorization code. Close this tab and re-run " +
61
+ "the command.",
62
+ );
63
+
64
+ /** Bind to 127.0.0.1:port; resolves null on EADDRINUSE, rejects otherwise. */
65
+ function tryListen(
66
+ port: number,
67
+ handler: Parameters<typeof createServer>[1],
68
+ ): Promise<Server | null> {
69
+ return new Promise((resolve, reject) => {
70
+ const server = createServer(handler);
71
+ const onError = (err: NodeJS.ErrnoException) => {
72
+ server.close();
73
+ if (err.code === "EADDRINUSE") resolve(null);
74
+ else reject(err);
75
+ };
76
+ server.once("error", onError);
77
+ server.listen({ host: "127.0.0.1", port }, () => {
78
+ server.removeListener("error", onError);
79
+ resolve(server);
80
+ });
81
+ });
82
+ }
83
+
84
+ export async function startLoopbackServer(opts: {
85
+ /** Production: LOOPBACK_PORTS; tests inject [0] for an ephemeral bind. */
86
+ ports: readonly number[];
87
+ state: string;
88
+ /** Default CALLBACK_PATH. */
89
+ callbackPath?: string;
90
+ }): Promise<LoopbackServer> {
91
+ const callbackPath = opts.callbackPath ?? CALLBACK_PATH;
92
+
93
+ let settled = false;
94
+ let settleResolve!: (value: { code: string }) => void;
95
+ let settleReject!: (error: Error) => void;
96
+ const settlePromise = new Promise<{ code: string }>((resolve, reject) => {
97
+ settleResolve = resolve;
98
+ settleReject = reject;
99
+ });
100
+ // A callback can settle (reject) before/without waitForCallback being
101
+ // awaited — keep the bare promise from surfacing an unhandled rejection.
102
+ settlePromise.catch(() => {});
103
+
104
+ const handler: Parameters<typeof createServer>[1] = (req, res) => {
105
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
106
+
107
+ if (url.pathname !== callbackPath) {
108
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
109
+ res.end("Not found");
110
+ return;
111
+ }
112
+
113
+ // First settled answer wins; later requests are stale.
114
+ if (settled) {
115
+ res.writeHead(410, { "content-type": "text/plain; charset=utf-8" });
116
+ res.end("This callback was already handled — return to your terminal.");
117
+ return;
118
+ }
119
+ settled = true;
120
+
121
+ const respond = (status: number, html: string) => {
122
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
123
+ res.end(html);
124
+ };
125
+
126
+ const error = url.searchParams.get("error");
127
+ if (error !== null) {
128
+ respond(200, DENIED_HTML);
129
+ const detail = url.searchParams.get("error_description") ?? error;
130
+ settleReject(
131
+ error === "access_denied"
132
+ ? new LoopbackError(
133
+ "consent_denied",
134
+ "authorization was denied in PostHog",
135
+ detail,
136
+ )
137
+ : new LoopbackError(
138
+ "oauth_error",
139
+ `the OAuth callback returned an error: ${error}`,
140
+ detail,
141
+ ),
142
+ );
143
+ return;
144
+ }
145
+
146
+ const state = url.searchParams.get("state");
147
+ if (state === null || state !== opts.state) {
148
+ respond(400, MISMATCH_HTML);
149
+ // High-entropy random state ⇒ plain `===` comparison is fine.
150
+ settleReject(
151
+ new LoopbackError(
152
+ "state_mismatch",
153
+ "state mismatch on the OAuth callback — possible CSRF; retry " +
154
+ "the command",
155
+ ),
156
+ );
157
+ return;
158
+ }
159
+
160
+ const code = url.searchParams.get("code");
161
+ if (code === null || code === "") {
162
+ respond(400, NO_CODE_HTML);
163
+ settleReject(
164
+ new LoopbackError(
165
+ "oauth_error",
166
+ "the OAuth callback carried no authorization code",
167
+ ),
168
+ );
169
+ return;
170
+ }
171
+
172
+ respond(200, SUCCESS_HTML);
173
+ settleResolve({ code });
174
+ };
175
+
176
+ let server: Server | null = null;
177
+ for (const port of opts.ports) {
178
+ server = await tryListen(port, handler);
179
+ if (server) break;
180
+ }
181
+ if (!server) {
182
+ throw new LoopbackError(
183
+ "ports_busy",
184
+ `ports ${opts.ports.join(", ")} on 127.0.0.1 are all in use`,
185
+ );
186
+ }
187
+
188
+ const address = server.address();
189
+ const port =
190
+ address !== null && typeof address === "object" ? address.port : 0;
191
+ const bound = server;
192
+
193
+ return {
194
+ port,
195
+ redirectUri: `http://127.0.0.1:${port}${callbackPath}`,
196
+
197
+ waitForCallback(waitOpts?: { timeoutMs?: number }) {
198
+ const timeoutMs = waitOpts?.timeoutMs ?? CALLBACK_TIMEOUT_MS;
199
+ let timer: NodeJS.Timeout | undefined;
200
+ const timeout = new Promise<never>((_, reject) => {
201
+ timer = setTimeout(() => {
202
+ reject(
203
+ new LoopbackError(
204
+ "timeout",
205
+ "timed out waiting for the OAuth callback (5 minutes)",
206
+ ),
207
+ );
208
+ }, timeoutMs);
209
+ });
210
+ return Promise.race([settlePromise, timeout]).finally(() => {
211
+ clearTimeout(timer);
212
+ });
213
+ },
214
+
215
+ close() {
216
+ return new Promise<void>((resolve) => {
217
+ // Keep-alive sockets would hold close() open indefinitely.
218
+ bound.closeAllConnections();
219
+ bound.close(() => resolve());
220
+ });
221
+ },
222
+ };
223
+ }
@@ -0,0 +1,256 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * OAuth 2.0 primitives for `hogsend connect posthog`: PKCE (S256, RFC 7636),
5
+ * RFC 8414 authorization-server discovery, the authorize-URL builder, and the
6
+ * form-encoded public-client code exchange. Pure + injectable (`fetchImpl`)
7
+ * so everything is unit-testable without a live PostHog.
8
+ */
9
+
10
+ /**
11
+ * The CIMD document URL — doubles as the OAuth `client_id` (PostHog public
12
+ * client, `token_endpoint_auth_method: "none"`).
13
+ *
14
+ * LOCKSTEP (M5): this URL is deliberately re-typed in THREE places — here,
15
+ * the engine's `HOGSEND_POSTHOG_CLIENT_ID`
16
+ * (`packages/engine/src/lib/oauth-token-manager.ts`), and the `client_id`
17
+ * field inside the hosted CIMD document
18
+ * (`apps/docs/public/.well-known/hogsend-posthog-client.json`). The CLI has
19
+ * no engine dependency, so there is no single importable source of truth;
20
+ * grep all three before changing any of them.
21
+ */
22
+ export const POSTHOG_CLIENT_ID =
23
+ "https://hogsend.com/.well-known/hogsend-posthog-client.json";
24
+
25
+ /**
26
+ * LOCKSTEP (M5): must match the `scope` field of the hosted CIMD document
27
+ * (`apps/docs/public/.well-known/hogsend-posthog-client.json`).
28
+ */
29
+ export const POSTHOG_SCOPES =
30
+ "person:read person:write project:read hog_function:write";
31
+
32
+ /**
33
+ * LOCKSTEP (M5): the CIMD document's `redirect_uris` list EXACTLY
34
+ * `http://127.0.0.1:{8423,8424,8425}/callback` — these constants and the
35
+ * hosted JSON (`apps/docs/public/.well-known/hogsend-posthog-client.json`)
36
+ * must stay in lockstep, or PostHog rejects the redirect at authorize time.
37
+ */
38
+ export const LOOPBACK_PORTS = [8423, 8424, 8425] as const;
39
+ export const CALLBACK_PATH = "/callback";
40
+ export const CALLBACK_TIMEOUT_MS = 300_000; // 5 min
41
+
42
+ /**
43
+ * Consent-scope refinement (verified: the authorize endpoint accepts a
44
+ * required_access_level param; "team" = least privilege). If the live consent
45
+ * page rejects the param during e2e verification, flip to undefined — the
46
+ * flow must not depend on it.
47
+ */
48
+ export const REQUIRED_ACCESS_LEVEL: string | undefined = "team";
49
+
50
+ const DISCOVERY_TIMEOUT_MS = 10_000;
51
+
52
+ export interface PkcePair {
53
+ verifier: string;
54
+ challenge: string;
55
+ method: "S256";
56
+ }
57
+
58
+ /**
59
+ * base64url(sha256(ascii verifier)) — exported separately for the RFC 7636
60
+ * appendix B vector test.
61
+ */
62
+ export function computeChallenge(verifier: string): string {
63
+ return createHash("sha256").update(verifier, "ascii").digest("base64url");
64
+ }
65
+
66
+ /**
67
+ * verifier = base64url(crypto.randomBytes(32)) → 43 chars, RFC 7636 §4.1
68
+ * charset (no padding — Node's base64url omits `=`).
69
+ */
70
+ export function generatePkce(): PkcePair {
71
+ const verifier = randomBytes(32).toString("base64url");
72
+ return { verifier, challenge: computeChallenge(verifier), method: "S256" };
73
+ }
74
+
75
+ /** base64url(crypto.randomBytes(16)) — high-entropy CSRF state. */
76
+ export function generateState(): string {
77
+ return randomBytes(16).toString("base64url");
78
+ }
79
+
80
+ export interface OAuthServerMetadata {
81
+ issuer: string;
82
+ authorization_endpoint: string;
83
+ token_endpoint: string;
84
+ [k: string]: unknown; // passthrough
85
+ }
86
+
87
+ export type DiscoveryResult =
88
+ | { status: "ok"; metadata: OAuthServerMetadata }
89
+ | { status: "unsupported" } // HTTP 404 or 410
90
+ | { status: "error"; message: string }; // transport / non-JSON / missing endpoints
91
+
92
+ /**
93
+ * RFC 8414 discovery against the instance's own private host —
94
+ * `GET {privateHost}/.well-known/oauth-authorization-server`. Never hardcode
95
+ * `oauth.posthog.com`; per-region servers are discovered from the host.
96
+ * Self-hosted builds without OAuth 404 here → `unsupported` (clean
97
+ * personal-API-key fallback). Never throws.
98
+ */
99
+ export async function discoverOAuthServer(opts: {
100
+ /** No trailing slash. */
101
+ privateHost: string;
102
+ fetchImpl?: typeof fetch;
103
+ }): Promise<DiscoveryResult> {
104
+ const fetchImpl = opts.fetchImpl ?? fetch;
105
+ const url = `${opts.privateHost}/.well-known/oauth-authorization-server`;
106
+
107
+ let res: Response;
108
+ try {
109
+ res = await fetchImpl(url, {
110
+ headers: { Accept: "application/json" },
111
+ signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
112
+ });
113
+ } catch (cause) {
114
+ const msg = cause instanceof Error ? cause.message : String(cause);
115
+ return { status: "error", message: `OAuth discovery failed: ${msg}` };
116
+ }
117
+
118
+ if (res.status === 404 || res.status === 410) {
119
+ return { status: "unsupported" };
120
+ }
121
+ if (!res.ok) {
122
+ return {
123
+ status: "error",
124
+ message: `OAuth discovery failed (HTTP ${res.status})`,
125
+ };
126
+ }
127
+
128
+ let doc: unknown;
129
+ try {
130
+ doc = await res.json();
131
+ } catch {
132
+ return {
133
+ status: "error",
134
+ message: "malformed discovery document (not JSON)",
135
+ };
136
+ }
137
+
138
+ if (
139
+ typeof doc !== "object" ||
140
+ doc === null ||
141
+ typeof (doc as Record<string, unknown>).issuer !== "string" ||
142
+ typeof (doc as Record<string, unknown>).authorization_endpoint !==
143
+ "string" ||
144
+ typeof (doc as Record<string, unknown>).token_endpoint !== "string"
145
+ ) {
146
+ return {
147
+ status: "error",
148
+ message:
149
+ "malformed discovery document (missing issuer / " +
150
+ "authorization_endpoint / token_endpoint)",
151
+ };
152
+ }
153
+
154
+ return { status: "ok", metadata: doc as OAuthServerMetadata };
155
+ }
156
+
157
+ /** Build the user-facing authorize URL (PKCE S256, public client). */
158
+ export function buildAuthorizeUrl(opts: {
159
+ authorizationEndpoint: string;
160
+ clientId: string;
161
+ redirectUri: string;
162
+ scope: string;
163
+ state: string;
164
+ pkce: PkcePair;
165
+ requiredAccessLevel?: string;
166
+ }): string {
167
+ const url = new URL(opts.authorizationEndpoint);
168
+ url.searchParams.set("response_type", "code");
169
+ url.searchParams.set("client_id", opts.clientId);
170
+ url.searchParams.set("redirect_uri", opts.redirectUri);
171
+ url.searchParams.set("scope", opts.scope);
172
+ url.searchParams.set("state", opts.state);
173
+ url.searchParams.set("code_challenge", opts.pkce.challenge);
174
+ url.searchParams.set("code_challenge_method", opts.pkce.method);
175
+ if (opts.requiredAccessLevel !== undefined) {
176
+ url.searchParams.set("required_access_level", opts.requiredAccessLevel);
177
+ }
178
+ return url.toString();
179
+ }
180
+
181
+ export interface TokenResponse {
182
+ access_token: string;
183
+ /** REQUIRED here — `exchangeCode` throws when a 200 omits it. */
184
+ refresh_token: string;
185
+ token_type: string;
186
+ expires_in: number;
187
+ scope?: string;
188
+ id_token?: string;
189
+ scoped_teams?: number[];
190
+ /** PostHog org ids are UUID strings (SYNTHESIS §0). */
191
+ scoped_organizations?: string[];
192
+ }
193
+
194
+ /**
195
+ * Public-client authorization-code exchange: form-encoded POST, NO
196
+ * Authorization header, NO client_secret. The error message NEVER includes
197
+ * the code or verifier.
198
+ */
199
+ export async function exchangeCode(opts: {
200
+ tokenEndpoint: string;
201
+ clientId: string;
202
+ code: string;
203
+ codeVerifier: string;
204
+ redirectUri: string;
205
+ fetchImpl?: typeof fetch;
206
+ }): Promise<TokenResponse> {
207
+ const fetchImpl = opts.fetchImpl ?? fetch;
208
+
209
+ const res = await fetchImpl(opts.tokenEndpoint, {
210
+ method: "POST",
211
+ headers: {
212
+ // URLSearchParams sets this automatically; explicit for clarity.
213
+ "Content-Type": "application/x-www-form-urlencoded",
214
+ Accept: "application/json",
215
+ },
216
+ body: new URLSearchParams({
217
+ grant_type: "authorization_code",
218
+ code: opts.code,
219
+ redirect_uri: opts.redirectUri,
220
+ client_id: opts.clientId,
221
+ code_verifier: opts.codeVerifier,
222
+ }),
223
+ });
224
+
225
+ if (!res.ok) {
226
+ const text = await res.text().catch(() => "");
227
+ let detail = text;
228
+ try {
229
+ const parsed: unknown = JSON.parse(text);
230
+ if (
231
+ typeof parsed === "object" &&
232
+ parsed !== null &&
233
+ typeof (parsed as Record<string, unknown>).error === "string"
234
+ ) {
235
+ detail = (parsed as { error: string }).error;
236
+ }
237
+ } catch {
238
+ // non-JSON body — keep the raw text
239
+ }
240
+ throw new Error(
241
+ `token exchange failed (${res.status})${detail ? `: ${detail}` : ""}`,
242
+ );
243
+ }
244
+
245
+ const json = (await res.json()) as Record<string, unknown>;
246
+ if (typeof json.access_token !== "string" || json.access_token === "") {
247
+ throw new Error("token response missing access_token");
248
+ }
249
+ if (typeof json.refresh_token !== "string" || json.refresh_token === "") {
250
+ throw new Error(
251
+ "token response missing refresh_token — cannot store a long-lived " +
252
+ "credential",
253
+ );
254
+ }
255
+ return json as unknown as TokenResponse;
256
+ }