@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.
- package/package.json +7 -7
- package/src/container.ts +10 -3
- package/src/destinations/presets/posthog.ts +132 -30
- package/src/index.ts +31 -0
- package/src/lib/analytics-providers-from-env.ts +48 -5
- package/src/lib/oauth-token-manager.ts +353 -0
- package/src/lib/posthog-scopes.ts +29 -0
- package/src/lib/provider-credentials.ts +349 -0
- package/src/lib/provision-posthog-loop.ts +744 -0
- package/src/lib/seed-posthog-destination.ts +38 -7
- package/src/routes/admin/analytics.ts +335 -0
- package/src/routes/admin/index.ts +4 -0
- package/src/routes/admin/provider-credentials.ts +188 -0
- package/src/routes/webhooks/sources.ts +60 -1
|
@@ -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
|
+
}
|