@hogsend/engine 0.19.0 → 0.21.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,349 @@
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
+ /**
20
+ * "oauth" holds the token grant; "derived" holds server-derived config grabbed
21
+ * during connect. Widen the union further when an "api_key" kind lands.
22
+ */
23
+ export type CredentialKind = "oauth" | "derived";
24
+
25
+ /**
26
+ * The decrypted shape stored for kind="oauth". Provider-neutral: nothing in
27
+ * here is PostHog-specific. `expiresAt` is ISO-8601 with offset (access-token
28
+ * expiry); `tokenEndpoint` is captured at connect time so refresh never
29
+ * re-runs discovery; `clientId` is the client identifier used at connect so
30
+ * refresh can re-send it; `scopedTeams`/`scopedOrganizations` mirror what an
31
+ * authorization server reports at consent time (PostHog: numeric team ids,
32
+ * UUID organization ids) — empty when the grant is unscoped.
33
+ */
34
+ export interface OAuthCredentialPayload {
35
+ accessToken: string;
36
+ refreshToken: string;
37
+ expiresAt: string;
38
+ tokenEndpoint: string;
39
+ clientId: string;
40
+ scopes: string[];
41
+ scopedTeams: number[];
42
+ scopedOrganizations: string[];
43
+ }
44
+
45
+ /**
46
+ * The decrypted shape stored for kind="derived": server-derived PostHog config
47
+ * grabbed during connect. `webhookSecret` is minted when env lacks one
48
+ * (load-bearing for the inbound loop — the posthog webhook source resolves it
49
+ * from the store at request time); `projectApiKey` (phc_) is grabbed
50
+ * opportunistically for the OPTIONAL outbound capture path. All fields optional.
51
+ */
52
+ export interface DerivedCredentialPayload {
53
+ webhookSecret?: string;
54
+ projectApiKey?: string;
55
+ projectId?: string;
56
+ privateHost?: string;
57
+ }
58
+
59
+ /** Row metadata — everything EXCEPT token material. Safe to surface. */
60
+ export interface ProviderCredentialMeta {
61
+ providerId: string;
62
+ kind: CredentialKind;
63
+ scopes: string[];
64
+ expiresAt: Date;
65
+ scopedTeams: number[];
66
+ createdAt: Date;
67
+ updatedAt: Date;
68
+ }
69
+
70
+ /** Full decrypted record for engine-internal callers (token lifecycle). */
71
+ export interface DecryptedProviderCredential {
72
+ providerId: string;
73
+ kind: CredentialKind;
74
+ payload: OAuthCredentialPayload;
75
+ createdAt: Date;
76
+ updatedAt: Date;
77
+ }
78
+
79
+ /**
80
+ * Thrown when a stored payload fails to decrypt or parse — in practice this
81
+ * means BETTER_AUTH_SECRET rotated (or the row was tampered with). LOUD by
82
+ * design: callers must not silently fall back, the operator needs to
83
+ * reconnect (`hogsend connect <providerId>`) or DELETE the credential.
84
+ */
85
+ export class ProviderCredentialDecryptError extends Error {
86
+ constructor(providerId: string) {
87
+ super(
88
+ `Stored credential for "${providerId}" cannot be decrypted — ` +
89
+ `BETTER_AUTH_SECRET may have rotated. Re-connect the provider or ` +
90
+ `delete the credential.`,
91
+ );
92
+ this.name = "ProviderCredentialDecryptError";
93
+ }
94
+ }
95
+
96
+ // --- crypto (mirrors lib/identity-token.ts: AES-256-GCM, sha256-derived key,
97
+ // --- base64url(iv || ciphertext || tag)) ----------------------------------
98
+ const IV_LENGTH = 12;
99
+ const TAG_LENGTH = 16;
100
+
101
+ function deriveKey(secret: string): Buffer {
102
+ return createHash("sha256").update(secret).digest();
103
+ }
104
+
105
+ /**
106
+ * Payload-agnostic encrypt: JSON-stringify any value, AES-256-GCM encrypt,
107
+ * return base64url(iv || ciphertext || tag). No shape validation — the kind's
108
+ * own encrypt helper owns that.
109
+ */
110
+ function encryptJson(value: unknown, secret: string): string {
111
+ const iv = randomBytes(IV_LENGTH);
112
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
113
+ const ciphertext = Buffer.concat([
114
+ cipher.update(JSON.stringify(value), "utf-8"),
115
+ cipher.final(),
116
+ ]);
117
+ return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
118
+ "base64url",
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Payload-agnostic decrypt: decode base64url, AES-256-GCM decrypt, JSON.parse.
124
+ * Throws `ProviderCredentialDecryptError` on ANY failure or if the parsed
125
+ * result is not a non-null object. Per-kind validation lives in the caller.
126
+ */
127
+ function decryptJson(
128
+ blob: string,
129
+ secret: string,
130
+ providerId: string,
131
+ ): unknown {
132
+ let raw: Buffer;
133
+ try {
134
+ raw = Buffer.from(blob, "base64url");
135
+ } catch {
136
+ throw new ProviderCredentialDecryptError(providerId);
137
+ }
138
+ if (raw.length <= IV_LENGTH + TAG_LENGTH) {
139
+ throw new ProviderCredentialDecryptError(providerId);
140
+ }
141
+
142
+ const iv = raw.subarray(0, IV_LENGTH);
143
+ const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
144
+ const tag = raw.subarray(raw.length - TAG_LENGTH);
145
+
146
+ let value: unknown;
147
+ try {
148
+ const decipher = createDecipheriv("aes-256-gcm", deriveKey(secret), iv);
149
+ decipher.setAuthTag(tag);
150
+ const plaintext = Buffer.concat([
151
+ decipher.update(ciphertext),
152
+ decipher.final(),
153
+ ]).toString("utf-8");
154
+ value = JSON.parse(plaintext);
155
+ } catch {
156
+ throw new ProviderCredentialDecryptError(providerId);
157
+ }
158
+
159
+ if (typeof value !== "object" || value === null) {
160
+ throw new ProviderCredentialDecryptError(providerId);
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function encryptPayload(
166
+ payload: OAuthCredentialPayload,
167
+ secret: string,
168
+ ): string {
169
+ return encryptJson(payload, secret);
170
+ }
171
+
172
+ function decryptPayload(
173
+ blob: string,
174
+ secret: string,
175
+ providerId: string,
176
+ ): OAuthCredentialPayload {
177
+ const payload = decryptJson(
178
+ blob,
179
+ secret,
180
+ providerId,
181
+ ) as OAuthCredentialPayload;
182
+
183
+ if (
184
+ typeof payload.accessToken !== "string" ||
185
+ typeof payload.tokenEndpoint !== "string"
186
+ ) {
187
+ throw new ProviderCredentialDecryptError(providerId);
188
+ }
189
+ return payload;
190
+ }
191
+
192
+ /** Meta projection — keeps the tokens-never-surfaced shape in ONE place. */
193
+ export function toCredentialMeta(
194
+ record: DecryptedProviderCredential,
195
+ ): ProviderCredentialMeta {
196
+ return {
197
+ providerId: record.providerId,
198
+ kind: record.kind,
199
+ scopes: record.payload.scopes,
200
+ expiresAt: new Date(record.payload.expiresAt),
201
+ scopedTeams: record.payload.scopedTeams,
202
+ createdAt: record.createdAt,
203
+ updatedAt: record.updatedAt,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Read + decrypt one credential. `null` when none stored; throws
209
+ * `ProviderCredentialDecryptError` when a row exists but cannot be decrypted.
210
+ */
211
+ export async function getProviderCredential(
212
+ db: Database,
213
+ providerId: string,
214
+ kind: CredentialKind = "oauth",
215
+ ): Promise<DecryptedProviderCredential | null> {
216
+ const [row] = await db
217
+ .select()
218
+ .from(providerCredentials)
219
+ .where(
220
+ and(
221
+ eq(providerCredentials.providerId, providerId),
222
+ eq(providerCredentials.kind, kind),
223
+ ),
224
+ )
225
+ .limit(1);
226
+
227
+ if (!row) return null;
228
+
229
+ return {
230
+ providerId: row.providerId,
231
+ kind: row.kind as CredentialKind,
232
+ payload: decryptPayload(row.payload, env.BETTER_AUTH_SECRET, providerId),
233
+ createdAt: row.createdAt,
234
+ updatedAt: row.updatedAt,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Encrypt + UPSERT (full-payload overwrite, deliberately: "keep the old
240
+ * refresh token when the token response omits one" is the CALLER's merge job
241
+ * — the store stays dumb). Returns the safe meta projection.
242
+ */
243
+ export async function saveProviderCredential(
244
+ db: Database,
245
+ opts: {
246
+ providerId: string;
247
+ kind?: CredentialKind;
248
+ payload: OAuthCredentialPayload;
249
+ },
250
+ ): Promise<ProviderCredentialMeta> {
251
+ const kind = opts.kind ?? "oauth";
252
+ const encrypted = encryptPayload(opts.payload, env.BETTER_AUTH_SECRET);
253
+
254
+ const [row] = await db
255
+ .insert(providerCredentials)
256
+ .values({ providerId: opts.providerId, kind, payload: encrypted })
257
+ .onConflictDoUpdate({
258
+ target: [providerCredentials.providerId, providerCredentials.kind],
259
+ set: { payload: encrypted, updatedAt: new Date() },
260
+ })
261
+ .returning();
262
+
263
+ if (!row) {
264
+ throw new Error(
265
+ `Failed to save provider credential for "${opts.providerId}"`,
266
+ );
267
+ }
268
+
269
+ return toCredentialMeta({
270
+ providerId: row.providerId,
271
+ kind: row.kind as CredentialKind,
272
+ payload: opts.payload,
273
+ createdAt: row.createdAt,
274
+ updatedAt: row.updatedAt,
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Hard-delete. `true` iff a row was removed. Never decrypts — this is the
280
+ * operator's escape hatch after a secret rotation.
281
+ */
282
+ export async function deleteProviderCredential(
283
+ db: Database,
284
+ providerId: string,
285
+ kind: CredentialKind = "oauth",
286
+ ): Promise<boolean> {
287
+ const deleted = await db
288
+ .delete(providerCredentials)
289
+ .where(
290
+ and(
291
+ eq(providerCredentials.providerId, providerId),
292
+ eq(providerCredentials.kind, kind),
293
+ ),
294
+ )
295
+ .returning({ id: providerCredentials.id });
296
+
297
+ return deleted.length > 0;
298
+ }
299
+
300
+ /**
301
+ * Read + decrypt the kind="derived" config for a provider. `null` when none
302
+ * stored; throws `ProviderCredentialDecryptError` when a row exists but cannot
303
+ * be decrypted. Unlike the oauth path there's no required-field validation —
304
+ * every field on `DerivedCredentialPayload` is optional.
305
+ */
306
+ export async function getDerivedCredential(
307
+ db: Database,
308
+ providerId: string,
309
+ ): Promise<DerivedCredentialPayload | null> {
310
+ const [row] = await db
311
+ .select()
312
+ .from(providerCredentials)
313
+ .where(
314
+ and(
315
+ eq(providerCredentials.providerId, providerId),
316
+ eq(providerCredentials.kind, "derived"),
317
+ ),
318
+ )
319
+ .limit(1);
320
+
321
+ if (!row) return null;
322
+
323
+ return decryptJson(
324
+ row.payload,
325
+ env.BETTER_AUTH_SECRET,
326
+ providerId,
327
+ ) as DerivedCredentialPayload;
328
+ }
329
+
330
+ /**
331
+ * Encrypt + UPSERT the kind="derived" config (full-payload overwrite, same dumb
332
+ * store as `saveProviderCredential`: merging old + new fields is the CALLER's
333
+ * job).
334
+ */
335
+ export async function saveDerivedCredential(
336
+ db: Database,
337
+ providerId: string,
338
+ payload: DerivedCredentialPayload,
339
+ ): Promise<void> {
340
+ const encrypted = encryptJson(payload, env.BETTER_AUTH_SECRET);
341
+
342
+ await db
343
+ .insert(providerCredentials)
344
+ .values({ providerId, kind: "derived", payload: encrypted })
345
+ .onConflictDoUpdate({
346
+ target: [providerCredentials.providerId, providerCredentials.kind],
347
+ set: { payload: encrypted, updatedAt: new Date() },
348
+ });
349
+ }