@hogsend/engine 0.18.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.
- package/package.json +7 -7
- package/src/container.ts +121 -21
- package/src/destinations/presets/posthog.ts +132 -30
- package/src/env.ts +15 -0
- package/src/index.ts +34 -1
- package/src/lib/analytics-adapter.ts +68 -0
- package/src/lib/analytics-provider-registry.ts +35 -0
- package/src/lib/analytics-providers-from-env.ts +79 -0
- package/src/lib/analytics-singleton.ts +2 -2
- package/src/lib/bucket-posthog-sync.ts +8 -6
- package/src/lib/oauth-token-manager.ts +353 -0
- package/src/lib/posthog.ts +16 -0
- package/src/lib/provider-credentials.ts +250 -0
- package/src/lib/provision-posthog-loop.ts +722 -0
- package/src/lib/seed-posthog-destination.ts +38 -7
- package/src/routes/admin/analytics.ts +261 -0
- package/src/routes/admin/index.ts +4 -0
- package/src/routes/admin/provider-credentials.ts +184 -0
- package/src/worker.ts +3 -2
|
@@ -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
|
+
}
|