@hogsend/engine 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,353 @@
1
+ import type { Database } from "@hogsend/db";
2
+ import { z } from "zod";
3
+ import type { Logger } from "./logger.js";
4
+ import {
5
+ getProviderCredential,
6
+ type OAuthCredentialPayload,
7
+ saveProviderCredential,
8
+ } from "./provider-credentials.js";
9
+
10
+ /**
11
+ * The CIMD document URL — doubles as the OAuth `client_id` (PostHog public
12
+ * client, `token_endpoint_auth_method: "none"`). Sent on every refresh.
13
+ *
14
+ * LOCKSTEP (M5): this URL is deliberately re-typed in THREE places — here,
15
+ * the CLI's `POSTHOG_CLIENT_ID` (`packages/cli/src/lib/oauth.ts`), and the
16
+ * `client_id` field inside the hosted CIMD document
17
+ * (`apps/docs/public/.well-known/hogsend-posthog-client.json`). The CLI has
18
+ * no engine dependency, so there is no single importable source of truth;
19
+ * grep all three before changing any of them.
20
+ */
21
+ export const HOGSEND_POSTHOG_CLIENT_ID =
22
+ "https://hogsend.com/.well-known/hogsend-posthog-client.json";
23
+
24
+ /** Refresh when fewer than 60s of access-token life remain. */
25
+ export const EXPIRY_SKEW_MS = 60_000;
26
+ /** Negative-cache window for "no credential row" — runtime-connect pickup. */
27
+ export const ABSENT_RECHECK_MS = 30_000;
28
+ /** Minimum gap between failed refresh attempts. */
29
+ export const FAILURE_BACKOFF_MS = 60_000;
30
+ const REFRESH_TIMEOUT_MS = 10_000;
31
+
32
+ // The canonical OAuth credential payload (SYNTHESIS §0) — keep textually
33
+ // identical to the admin route's PUT body schema
34
+ // (`routes/admin/provider-credentials.ts`). Output type matches the store's
35
+ // `OAuthCredentialPayload` interface exactly.
36
+ export const oauthCredentialPayloadSchema = z.object({
37
+ accessToken: z.string().min(1),
38
+ refreshToken: z.string().min(1),
39
+ expiresAt: z.string().datetime({ offset: true }),
40
+ tokenEndpoint: z.string().url(),
41
+ clientId: z.string().url(),
42
+ scopes: z.array(z.string()).default([]),
43
+ scopedTeams: z.array(z.number().int()).default([]),
44
+ scopedOrganizations: z.array(z.string()).default([]),
45
+ });
46
+
47
+ /** Test/cross-lane seam over the credential store. */
48
+ export interface CredentialStore {
49
+ /** Decrypted payload JSON of the (providerId, "oauth") row, or null. */
50
+ load(): Promise<Record<string, unknown> | null>;
51
+ save(payload: OAuthCredentialPayload): Promise<void>;
52
+ }
53
+
54
+ export type CredentialState = "unknown" | "present" | "absent";
55
+
56
+ export interface TokenManager {
57
+ /**
58
+ * Resolve a live access token, refreshing if necessary. NEVER throws.
59
+ * Returns null when no usable credential exists (absent, malformed, or
60
+ * refresh failed and the old token is hard-expired) — callers degrade.
61
+ */
62
+ getAccessToken(): Promise<string | null>;
63
+ /** Synchronous best-known state — drives `capabilities.personReads`. */
64
+ credentialState(): CredentialState;
65
+ /** Load-only warm-up (no refresh). Fire-and-forget at boot. */
66
+ prime(): Promise<void>;
67
+ /**
68
+ * Drop the in-memory payload and force a refresh attempt on the next
69
+ * getAccessToken (still subject to failure backoff).
70
+ */
71
+ invalidate(): void;
72
+ }
73
+
74
+ interface RefreshResponseBody {
75
+ access_token?: unknown;
76
+ refresh_token?: unknown;
77
+ expires_in?: unknown;
78
+ scope?: unknown;
79
+ scoped_teams?: unknown;
80
+ scoped_organizations?: unknown;
81
+ error?: unknown;
82
+ }
83
+
84
+ function defaultStore(db: Database, providerId: string): CredentialStore {
85
+ return {
86
+ load: async () =>
87
+ ((await getProviderCredential(db, providerId, "oauth"))?.payload ??
88
+ null) as Record<string, unknown> | null,
89
+ save: async (payload) => {
90
+ await saveProviderCredential(db, { providerId, payload });
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Per-process OAuth access-token manager: in-memory cache + single-flight
97
+ * refresh against the credential payload's stored `tokenEndpoint`, persisting
98
+ * rotations back through the credential store. One instance per process (API
99
+ * and worker each have their own) — the DB row is the shared truth, so the
100
+ * manager ALWAYS re-loads from the store before refreshing (a sibling process
101
+ * may have refreshed first; adopting its result avoids a cross-process
102
+ * stampede against an endpoint whose rotation semantics are undocumented).
103
+ */
104
+ export function createTokenManager(opts: {
105
+ providerId: string;
106
+ db?: Database;
107
+ store?: CredentialStore;
108
+ logger?: Logger;
109
+ fetchImpl?: typeof fetch;
110
+ now?: () => number;
111
+ }): TokenManager {
112
+ const { providerId, logger } = opts;
113
+ const resolvedStore =
114
+ opts.store ?? (opts.db ? defaultStore(opts.db, providerId) : undefined);
115
+ if (!resolvedStore) {
116
+ throw new Error("createTokenManager requires db or store");
117
+ }
118
+ // Hoisted inner functions can't see the narrowing above — re-bind.
119
+ const store: CredentialStore = resolvedStore;
120
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
121
+ const now = opts.now ?? Date.now;
122
+
123
+ let payload: OAuthCredentialPayload | null = null;
124
+ let absentCheckedAt: number | null = null;
125
+ let lastFailureAt: number | null = null;
126
+ let warnedFailure = false;
127
+ let warnedInvalid = false;
128
+ let forceRefresh = false;
129
+ let inflight: Promise<string | null> | null = null;
130
+
131
+ const expiresAtMs = (p: OAuthCredentialPayload) => Date.parse(p.expiresAt);
132
+
133
+ // Warn-once: first occurrence per failure streak logs at warn (the message
134
+ // carries the reconnect remediation), repeats drop to debug. Latches reset
135
+ // on the next success so a NEW streak warns again.
136
+ const warnOnce = (kind: "failure" | "invalid", message: string) => {
137
+ const latched = kind === "failure" ? warnedFailure : warnedInvalid;
138
+ if (latched) {
139
+ logger?.debug(message);
140
+ return;
141
+ }
142
+ logger?.warn(message);
143
+ if (kind === "failure") warnedFailure = true;
144
+ else warnedInvalid = true;
145
+ };
146
+
147
+ const errMsg = (err: unknown) =>
148
+ err instanceof Error ? err.message : String(err);
149
+
150
+ async function refresh(
151
+ old: OAuthCredentialPayload,
152
+ t: number,
153
+ ): Promise<string | null> {
154
+ let detail: string;
155
+ try {
156
+ const response = await fetchImpl(old.tokenEndpoint, {
157
+ method: "POST",
158
+ headers: {
159
+ // No Authorization header — public client; client_id in the body.
160
+ "Content-Type": "application/x-www-form-urlencoded",
161
+ Accept: "application/json",
162
+ },
163
+ body: new URLSearchParams({
164
+ grant_type: "refresh_token",
165
+ refresh_token: old.refreshToken,
166
+ client_id: old.clientId,
167
+ }).toString(),
168
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
169
+ });
170
+
171
+ if (response.ok) {
172
+ const body = (await response.json()) as RefreshResponseBody;
173
+ if (
174
+ typeof body.access_token === "string" &&
175
+ typeof body.expires_in === "number"
176
+ ) {
177
+ const next: OAuthCredentialPayload = {
178
+ ...old,
179
+ accessToken: body.access_token,
180
+ // ROTATION RULE: PostHog's refresh-token rotation behavior is
181
+ // undocumented — always store a returned refresh token, KEEP the
182
+ // old one when the response omits it.
183
+ refreshToken:
184
+ typeof body.refresh_token === "string"
185
+ ? body.refresh_token
186
+ : old.refreshToken,
187
+ expiresAt: new Date(t + body.expires_in * 1000).toISOString(),
188
+ scopes:
189
+ typeof body.scope === "string"
190
+ ? body.scope.split(" ")
191
+ : old.scopes,
192
+ scopedTeams: Array.isArray(body.scoped_teams)
193
+ ? (body.scoped_teams as number[])
194
+ : old.scopedTeams,
195
+ scopedOrganizations: Array.isArray(body.scoped_organizations)
196
+ ? (body.scoped_organizations as string[])
197
+ : old.scopedOrganizations,
198
+ };
199
+ try {
200
+ await store.save(next);
201
+ } catch (err) {
202
+ // Persistence hiccup must not kill the send path — adopt the
203
+ // refreshed token in memory; a process restart re-refreshes.
204
+ logger?.warn(
205
+ `${providerId} oauth credential save failed after refresh ` +
206
+ `(token kept in memory): ${errMsg(err)}`,
207
+ );
208
+ }
209
+ payload = next;
210
+ lastFailureAt = null;
211
+ warnedFailure = false;
212
+ forceRefresh = false;
213
+ return next.accessToken;
214
+ }
215
+ detail = "unparseable token response";
216
+ } else {
217
+ detail = `HTTP ${response.status}`;
218
+ try {
219
+ const errBody = (await response.json()) as RefreshResponseBody;
220
+ if (typeof errBody.error === "string") detail = errBody.error;
221
+ } catch {
222
+ // keep the HTTP status detail
223
+ }
224
+ }
225
+ } catch (err) {
226
+ detail = errMsg(err);
227
+ }
228
+
229
+ lastFailureAt = t;
230
+ forceRefresh = false;
231
+ warnOnce(
232
+ "failure",
233
+ `${providerId} oauth token refresh failed (${detail}) — analytics ` +
234
+ "reads degrade (personal key fallback or disabled); run " +
235
+ `\`hogsend connect ${providerId}\` to reconnect`,
236
+ );
237
+ // Inside the skew window the old token is technically still live.
238
+ return expiresAtMs(old) > t ? old.accessToken : null;
239
+ }
240
+
241
+ async function run(): Promise<string | null> {
242
+ const t = now();
243
+
244
+ // 1. Fresh in-memory token → fast path, no IO.
245
+ if (!forceRefresh && payload && expiresAtMs(payload) - EXPIRY_SKEW_MS > t) {
246
+ return payload.accessToken;
247
+ }
248
+
249
+ // 2. Known-absent within the negative-cache window → cheap null.
250
+ if (
251
+ !payload &&
252
+ absentCheckedAt !== null &&
253
+ t - absentCheckedAt < ABSENT_RECHECK_MS
254
+ ) {
255
+ return null;
256
+ }
257
+
258
+ // 3. (Re)load from the store. ALWAYS re-load before refreshing — another
259
+ // process (API vs worker) may have already refreshed; adopting its row
260
+ // avoids a cross-process refresh stampede.
261
+ let raw: Record<string, unknown> | null;
262
+ try {
263
+ raw = await store.load();
264
+ } catch (err) {
265
+ // Surfaces ProviderCredentialDecryptError.message verbatim — it
266
+ // carries the reconnect remediation.
267
+ warnOnce(
268
+ "invalid",
269
+ `${providerId} oauth credential load failed: ${errMsg(err)}`,
270
+ );
271
+ payload = null;
272
+ absentCheckedAt = t;
273
+ return null;
274
+ }
275
+ if (raw === null) {
276
+ // Absent is NORMAL (provider not connected) — no warn.
277
+ payload = null;
278
+ absentCheckedAt = t;
279
+ return null;
280
+ }
281
+ const parsed = oauthCredentialPayloadSchema.safeParse(raw);
282
+ if (!parsed.success) {
283
+ warnOnce(
284
+ "invalid",
285
+ `${providerId} oauth credential payload is malformed — re-run ` +
286
+ `\`hogsend connect ${providerId}\``,
287
+ );
288
+ payload = null;
289
+ absentCheckedAt = t;
290
+ return null;
291
+ }
292
+ payload = parsed.data;
293
+ absentCheckedAt = null;
294
+ warnedInvalid = false;
295
+
296
+ // 4. Reloaded token already fresh (e.g. the other process refreshed).
297
+ if (!forceRefresh && expiresAtMs(payload) - EXPIRY_SKEW_MS > t) {
298
+ return payload.accessToken;
299
+ }
300
+
301
+ // 5. Failure backoff: don't hammer the endpoint. Inside the skew window
302
+ // the old token is technically still live — return it.
303
+ if (lastFailureAt !== null && t - lastFailureAt < FAILURE_BACKOFF_MS) {
304
+ return expiresAtMs(payload) > t ? payload.accessToken : null;
305
+ }
306
+
307
+ // 6. Refresh.
308
+ return refresh(payload, t);
309
+ }
310
+
311
+ return {
312
+ getAccessToken() {
313
+ if (inflight) return inflight;
314
+ inflight = run().finally(() => {
315
+ inflight = null;
316
+ });
317
+ return inflight;
318
+ },
319
+
320
+ credentialState() {
321
+ return payload
322
+ ? "present"
323
+ : absentCheckedAt !== null
324
+ ? "absent"
325
+ : "unknown";
326
+ },
327
+
328
+ // Load-only by design: a refreshing prime would make the API and worker
329
+ // race a simultaneous refresh on every deploy. The first real read pays
330
+ // the refresh; step 3's reload-before-refresh heals the residual race.
331
+ async prime() {
332
+ if (payload || absentCheckedAt !== null) return;
333
+ try {
334
+ const raw = await store.load();
335
+ if (raw === null) {
336
+ absentCheckedAt = now();
337
+ return;
338
+ }
339
+ const parsed = oauthCredentialPayloadSchema.safeParse(raw);
340
+ if (parsed.success) payload = parsed.data;
341
+ else absentCheckedAt = now();
342
+ } catch {
343
+ absentCheckedAt = now();
344
+ }
345
+ },
346
+
347
+ invalidate() {
348
+ payload = null;
349
+ absentCheckedAt = null;
350
+ forceRefresh = true;
351
+ },
352
+ };
353
+ }
@@ -0,0 +1,250 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHash,
5
+ randomBytes,
6
+ } from "node:crypto";
7
+ import { type Database, providerCredentials } from "@hogsend/db";
8
+ import { and, eq } from "drizzle-orm";
9
+ import { env } from "../env.js";
10
+
11
+ /**
12
+ * Provider-neutral credential storage: encrypted-at-rest OAuth tokens (and,
13
+ * later, API keys) in the `provider_credentials` table. The crypto is a
14
+ * PRIVATE mirror of `identity-token.ts` (same AES-256-GCM construction, no
15
+ * shared module — identity tokens bake an `exp` into the payload; credentials
16
+ * don't, so the two stay independent).
17
+ */
18
+
19
+ /** Only "oauth" today; widen the union when an "api_key" kind lands. */
20
+ export type CredentialKind = "oauth";
21
+
22
+ /**
23
+ * The decrypted shape stored for kind="oauth". Provider-neutral: nothing in
24
+ * here is PostHog-specific. `expiresAt` is ISO-8601 with offset (access-token
25
+ * expiry); `tokenEndpoint` is captured at connect time so refresh never
26
+ * re-runs discovery; `clientId` is the client identifier used at connect so
27
+ * refresh can re-send it; `scopedTeams`/`scopedOrganizations` mirror what an
28
+ * authorization server reports at consent time (PostHog: numeric team ids,
29
+ * UUID organization ids) — empty when the grant is unscoped.
30
+ */
31
+ export interface OAuthCredentialPayload {
32
+ accessToken: string;
33
+ refreshToken: string;
34
+ expiresAt: string;
35
+ tokenEndpoint: string;
36
+ clientId: string;
37
+ scopes: string[];
38
+ scopedTeams: number[];
39
+ scopedOrganizations: string[];
40
+ }
41
+
42
+ /** Row metadata — everything EXCEPT token material. Safe to surface. */
43
+ export interface ProviderCredentialMeta {
44
+ providerId: string;
45
+ kind: CredentialKind;
46
+ scopes: string[];
47
+ expiresAt: Date;
48
+ scopedTeams: number[];
49
+ createdAt: Date;
50
+ updatedAt: Date;
51
+ }
52
+
53
+ /** Full decrypted record for engine-internal callers (token lifecycle). */
54
+ export interface DecryptedProviderCredential {
55
+ providerId: string;
56
+ kind: CredentialKind;
57
+ payload: OAuthCredentialPayload;
58
+ createdAt: Date;
59
+ updatedAt: Date;
60
+ }
61
+
62
+ /**
63
+ * Thrown when a stored payload fails to decrypt or parse — in practice this
64
+ * means BETTER_AUTH_SECRET rotated (or the row was tampered with). LOUD by
65
+ * design: callers must not silently fall back, the operator needs to
66
+ * reconnect (`hogsend connect <providerId>`) or DELETE the credential.
67
+ */
68
+ export class ProviderCredentialDecryptError extends Error {
69
+ constructor(providerId: string) {
70
+ super(
71
+ `Stored credential for "${providerId}" cannot be decrypted — ` +
72
+ `BETTER_AUTH_SECRET may have rotated. Re-connect the provider or ` +
73
+ `delete the credential.`,
74
+ );
75
+ this.name = "ProviderCredentialDecryptError";
76
+ }
77
+ }
78
+
79
+ // --- crypto (mirrors lib/identity-token.ts: AES-256-GCM, sha256-derived key,
80
+ // --- base64url(iv || ciphertext || tag)) ----------------------------------
81
+ const IV_LENGTH = 12;
82
+ const TAG_LENGTH = 16;
83
+
84
+ function deriveKey(secret: string): Buffer {
85
+ return createHash("sha256").update(secret).digest();
86
+ }
87
+
88
+ function encryptPayload(
89
+ payload: OAuthCredentialPayload,
90
+ secret: string,
91
+ ): string {
92
+ const iv = randomBytes(IV_LENGTH);
93
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
94
+ const ciphertext = Buffer.concat([
95
+ cipher.update(JSON.stringify(payload), "utf-8"),
96
+ cipher.final(),
97
+ ]);
98
+ return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
99
+ "base64url",
100
+ );
101
+ }
102
+
103
+ function decryptPayload(
104
+ blob: string,
105
+ secret: string,
106
+ providerId: string,
107
+ ): OAuthCredentialPayload {
108
+ let raw: Buffer;
109
+ try {
110
+ raw = Buffer.from(blob, "base64url");
111
+ } catch {
112
+ throw new ProviderCredentialDecryptError(providerId);
113
+ }
114
+ if (raw.length <= IV_LENGTH + TAG_LENGTH) {
115
+ throw new ProviderCredentialDecryptError(providerId);
116
+ }
117
+
118
+ const iv = raw.subarray(0, IV_LENGTH);
119
+ const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
120
+ const tag = raw.subarray(raw.length - TAG_LENGTH);
121
+
122
+ let payload: OAuthCredentialPayload;
123
+ try {
124
+ const decipher = createDecipheriv("aes-256-gcm", deriveKey(secret), iv);
125
+ decipher.setAuthTag(tag);
126
+ const plaintext = Buffer.concat([
127
+ decipher.update(ciphertext),
128
+ decipher.final(),
129
+ ]).toString("utf-8");
130
+ payload = JSON.parse(plaintext);
131
+ } catch {
132
+ throw new ProviderCredentialDecryptError(providerId);
133
+ }
134
+
135
+ if (
136
+ typeof payload.accessToken !== "string" ||
137
+ typeof payload.tokenEndpoint !== "string"
138
+ ) {
139
+ throw new ProviderCredentialDecryptError(providerId);
140
+ }
141
+ return payload;
142
+ }
143
+
144
+ /** Meta projection — keeps the tokens-never-surfaced shape in ONE place. */
145
+ export function toCredentialMeta(
146
+ record: DecryptedProviderCredential,
147
+ ): ProviderCredentialMeta {
148
+ return {
149
+ providerId: record.providerId,
150
+ kind: record.kind,
151
+ scopes: record.payload.scopes,
152
+ expiresAt: new Date(record.payload.expiresAt),
153
+ scopedTeams: record.payload.scopedTeams,
154
+ createdAt: record.createdAt,
155
+ updatedAt: record.updatedAt,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Read + decrypt one credential. `null` when none stored; throws
161
+ * `ProviderCredentialDecryptError` when a row exists but cannot be decrypted.
162
+ */
163
+ export async function getProviderCredential(
164
+ db: Database,
165
+ providerId: string,
166
+ kind: CredentialKind = "oauth",
167
+ ): Promise<DecryptedProviderCredential | null> {
168
+ const [row] = await db
169
+ .select()
170
+ .from(providerCredentials)
171
+ .where(
172
+ and(
173
+ eq(providerCredentials.providerId, providerId),
174
+ eq(providerCredentials.kind, kind),
175
+ ),
176
+ )
177
+ .limit(1);
178
+
179
+ if (!row) return null;
180
+
181
+ return {
182
+ providerId: row.providerId,
183
+ kind: row.kind as CredentialKind,
184
+ payload: decryptPayload(row.payload, env.BETTER_AUTH_SECRET, providerId),
185
+ createdAt: row.createdAt,
186
+ updatedAt: row.updatedAt,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Encrypt + UPSERT (full-payload overwrite, deliberately: "keep the old
192
+ * refresh token when the token response omits one" is the CALLER's merge job
193
+ * — the store stays dumb). Returns the safe meta projection.
194
+ */
195
+ export async function saveProviderCredential(
196
+ db: Database,
197
+ opts: {
198
+ providerId: string;
199
+ kind?: CredentialKind;
200
+ payload: OAuthCredentialPayload;
201
+ },
202
+ ): Promise<ProviderCredentialMeta> {
203
+ const kind = opts.kind ?? "oauth";
204
+ const encrypted = encryptPayload(opts.payload, env.BETTER_AUTH_SECRET);
205
+
206
+ const [row] = await db
207
+ .insert(providerCredentials)
208
+ .values({ providerId: opts.providerId, kind, payload: encrypted })
209
+ .onConflictDoUpdate({
210
+ target: [providerCredentials.providerId, providerCredentials.kind],
211
+ set: { payload: encrypted, updatedAt: new Date() },
212
+ })
213
+ .returning();
214
+
215
+ if (!row) {
216
+ throw new Error(
217
+ `Failed to save provider credential for "${opts.providerId}"`,
218
+ );
219
+ }
220
+
221
+ return toCredentialMeta({
222
+ providerId: row.providerId,
223
+ kind: row.kind as CredentialKind,
224
+ payload: opts.payload,
225
+ createdAt: row.createdAt,
226
+ updatedAt: row.updatedAt,
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Hard-delete. `true` iff a row was removed. Never decrypts — this is the
232
+ * operator's escape hatch after a secret rotation.
233
+ */
234
+ export async function deleteProviderCredential(
235
+ db: Database,
236
+ providerId: string,
237
+ kind: CredentialKind = "oauth",
238
+ ): Promise<boolean> {
239
+ const deleted = await db
240
+ .delete(providerCredentials)
241
+ .where(
242
+ and(
243
+ eq(providerCredentials.providerId, providerId),
244
+ eq(providerCredentials.kind, kind),
245
+ ),
246
+ )
247
+ .returning({ id: providerCredentials.id });
248
+
249
+ return deleted.length > 0;
250
+ }