@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.
@@ -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
+ }