@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
|
@@ -23,6 +23,19 @@ const POSTHOG_FUNNEL_EVENTS = [
|
|
|
23
23
|
"email.complained",
|
|
24
24
|
] as const;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Person-property propagation events (the contact → analytics-person rail).
|
|
28
|
+
* The preset's `syncPersons` flag turns these into `$set` captures of the
|
|
29
|
+
* contact's properties under its canonical key; without the flag the
|
|
30
|
+
* transform SKIPS them, so subscribing them is only meaningful together
|
|
31
|
+
* with `config.syncPersons: true`.
|
|
32
|
+
*/
|
|
33
|
+
const POSTHOG_PERSON_SYNC_EVENTS = [
|
|
34
|
+
"contact.created",
|
|
35
|
+
"contact.updated",
|
|
36
|
+
"contact.unsubscribed",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Idempotently seed ONE `kind="posthog"` webhook endpoint subscribed to the
|
|
28
41
|
* email funnel, so the full email lifecycle fans out to PostHog DURABLY on the
|
|
@@ -56,6 +69,7 @@ export async function seedPostHogDestination(opts: {
|
|
|
56
69
|
id: webhookEndpoints.id,
|
|
57
70
|
url: webhookEndpoints.url,
|
|
58
71
|
eventTypes: webhookEndpoints.eventTypes,
|
|
72
|
+
config: webhookEndpoints.config,
|
|
59
73
|
})
|
|
60
74
|
.from(webhookEndpoints)
|
|
61
75
|
.where(
|
|
@@ -78,19 +92,31 @@ export async function seedPostHogDestination(opts: {
|
|
|
78
92
|
const current = Array.isArray(found.eventTypes)
|
|
79
93
|
? (found.eventTypes as string[])
|
|
80
94
|
: [];
|
|
81
|
-
const missing =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
const missing = [
|
|
96
|
+
...POSTHOG_FUNNEL_EVENTS,
|
|
97
|
+
...POSTHOG_PERSON_SYNC_EVENTS,
|
|
98
|
+
].filter((e) => !current.includes(e));
|
|
99
|
+
// Person-sync default-on for the ENGINE-seeded row, but never override
|
|
100
|
+
// an explicit operator choice: only set the flag when it is ABSENT
|
|
101
|
+
// from config (an explicit `false` stays false).
|
|
102
|
+
const existingConfig = (found.config ?? {}) as Record<string, unknown>;
|
|
103
|
+
const needsSyncFlag = existingConfig.syncPersons === undefined;
|
|
104
|
+
if (missing.length > 0 || needsSyncFlag) {
|
|
85
105
|
await tx
|
|
86
106
|
.update(webhookEndpoints)
|
|
87
107
|
.set({
|
|
88
|
-
|
|
108
|
+
...(missing.length > 0
|
|
109
|
+
? { eventTypes: [...current, ...missing] }
|
|
110
|
+
: {}),
|
|
111
|
+
...(needsSyncFlag
|
|
112
|
+
? { config: { ...existingConfig, syncPersons: true } }
|
|
113
|
+
: {}),
|
|
89
114
|
updatedAt: new Date(),
|
|
90
115
|
})
|
|
91
116
|
.where(eq(webhookEndpoints.id, found.id));
|
|
92
|
-
logger.info("Reconciled seeded PostHog destination
|
|
117
|
+
logger.info("Reconciled seeded PostHog destination", {
|
|
93
118
|
added: missing,
|
|
119
|
+
syncPersonsEnabled: needsSyncFlag,
|
|
94
120
|
});
|
|
95
121
|
}
|
|
96
122
|
}
|
|
@@ -110,8 +136,13 @@ export async function seedPostHogDestination(opts: {
|
|
|
110
136
|
// remaps the canonical "email.clicked" back so existing PostHog funnels
|
|
111
137
|
// keep working after the cutover.
|
|
112
138
|
eventNames: { "email.clicked": "email.link_clicked" },
|
|
139
|
+
// Person-property propagation ON for the seeded destination: contact
|
|
140
|
+
// truth ($set of contact.properties) lands on the same PostHog person
|
|
141
|
+
// the identify loop addresses. Disable per-endpoint via the admin API
|
|
142
|
+
// (config.syncPersons: false).
|
|
143
|
+
syncPersons: true,
|
|
113
144
|
},
|
|
114
|
-
eventTypes: [...POSTHOG_FUNNEL_EVENTS],
|
|
145
|
+
eventTypes: [...POSTHOG_FUNNEL_EVENTS, ...POSTHOG_PERSON_SYNC_EVENTS],
|
|
115
146
|
secret: null,
|
|
116
147
|
secretPrefix: null,
|
|
117
148
|
disabled: false,
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { DEFAULT_HOST, derivePrivateHost } from "@hogsend/plugin-posthog";
|
|
3
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { createTokenManager } from "../../lib/oauth-token-manager.js";
|
|
6
|
+
import { EXPECTED_POSTHOG_SCOPES } from "../../lib/posthog-scopes.js";
|
|
7
|
+
import {
|
|
8
|
+
getDerivedCredential,
|
|
9
|
+
getProviderCredential,
|
|
10
|
+
saveDerivedCredential,
|
|
11
|
+
} from "../../lib/provider-credentials.js";
|
|
12
|
+
import {
|
|
13
|
+
ProvisionPostHogLoopError,
|
|
14
|
+
provisionPostHogLoop,
|
|
15
|
+
} from "../../lib/provision-posthog-loop.js";
|
|
16
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Admin analytics-connection routes — the server half of
|
|
20
|
+
* `hogsend connect posthog`. Mounted at `/v1/admin/analytics`, inheriting
|
|
21
|
+
* `requireAdmin` + `rateLimit` + `auditMiddleware` from the admin router root.
|
|
22
|
+
*
|
|
23
|
+
* - `GET /connect-info` surfaces the instance's PostHog env signal so the CLI
|
|
24
|
+
* needs NO PostHog env vars locally (it learns the region + readiness from
|
|
25
|
+
* the server). Pure env projection — it never discovers anything.
|
|
26
|
+
* - `POST /provision-loop` runs the idempotent PostHog → Hogsend hog-function
|
|
27
|
+
* provisioner server-side with the server's own credential (OAuth credential
|
|
28
|
+
* via a route-local token manager, falling back to the personal API key).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const connectInfoSchema = z.object({
|
|
32
|
+
providerId: z.literal("posthog"),
|
|
33
|
+
analyticsConfigured: z.boolean(),
|
|
34
|
+
privateHost: z.string().nullable(),
|
|
35
|
+
hostExplicit: z.boolean(),
|
|
36
|
+
projectIdHint: z.string().nullable(),
|
|
37
|
+
personalKeyConfigured: z.boolean(),
|
|
38
|
+
webhookSecretConfigured: z.boolean(),
|
|
39
|
+
apiPublicUrl: z.string(),
|
|
40
|
+
/**
|
|
41
|
+
* Expected PostHog OAuth scopes the stored credential is MISSING (the
|
|
42
|
+
* CLI surfaces these so the user can reconnect for the broader grant).
|
|
43
|
+
* `[]` when nothing is stored or the stored grant already covers them.
|
|
44
|
+
* Computed in the handler — NOT part of the pure `resolveConnectInfo`
|
|
45
|
+
* env projection (which `ConnectInfo` mirrors), so it omits this key.
|
|
46
|
+
*/
|
|
47
|
+
scopeGap: z.array(z.string()),
|
|
48
|
+
});
|
|
49
|
+
export type ConnectInfo = Omit<z.infer<typeof connectInfoSchema>, "scopeGap">;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when a public URL points at a loopback/unspecified address — PostHog
|
|
53
|
+
* Cloud can never deliver to it, so provisioning must refuse rather than
|
|
54
|
+
* create (or repoint!) a destination nobody can reach.
|
|
55
|
+
*/
|
|
56
|
+
export function isLoopbackPublicUrl(publicUrl: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
59
|
+
return (
|
|
60
|
+
host === "localhost" ||
|
|
61
|
+
host === "127.0.0.1" ||
|
|
62
|
+
host === "0.0.0.0" ||
|
|
63
|
+
host === "[::1]" ||
|
|
64
|
+
host === "::1" ||
|
|
65
|
+
host.endsWith(".localhost")
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pure env projection, exported for unit tests — no app boot needed.
|
|
74
|
+
*
|
|
75
|
+
* `privateHost: null` only when the server has NO PostHog signal at all (no
|
|
76
|
+
* API key, no host overrides). When `POSTHOG_API_KEY` is set without a host,
|
|
77
|
+
* the provider defaults to US Cloud, so the derived `https://us.posthog.com`
|
|
78
|
+
* is returned with `hostExplicit: false` — the CLI warns and confirms.
|
|
79
|
+
*
|
|
80
|
+
* Surfaces env ONLY — project-id discovery belongs to the provisioner (M8),
|
|
81
|
+
* and the webhook secret VALUE never leaves the server (only its
|
|
82
|
+
* configured-ness does).
|
|
83
|
+
*/
|
|
84
|
+
export function resolveConnectInfo(env: {
|
|
85
|
+
POSTHOG_API_KEY?: string;
|
|
86
|
+
POSTHOG_HOST?: string;
|
|
87
|
+
POSTHOG_PRIVATE_HOST?: string;
|
|
88
|
+
POSTHOG_PROJECT_ID?: string;
|
|
89
|
+
POSTHOG_PERSONAL_API_KEY?: string;
|
|
90
|
+
POSTHOG_WEBHOOK_SECRET?: string;
|
|
91
|
+
API_PUBLIC_URL: string;
|
|
92
|
+
}): ConnectInfo {
|
|
93
|
+
const hostExplicit = Boolean(env.POSTHOG_PRIVATE_HOST ?? env.POSTHOG_HOST);
|
|
94
|
+
const configuredAtAll = Boolean(env.POSTHOG_API_KEY) || hostExplicit;
|
|
95
|
+
const privateHost = !configuredAtAll
|
|
96
|
+
? null
|
|
97
|
+
: (
|
|
98
|
+
env.POSTHOG_PRIVATE_HOST ??
|
|
99
|
+
derivePrivateHost(env.POSTHOG_HOST ?? DEFAULT_HOST)
|
|
100
|
+
).replace(/\/+$/, "");
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
providerId: "posthog",
|
|
104
|
+
analyticsConfigured: Boolean(env.POSTHOG_API_KEY),
|
|
105
|
+
privateHost,
|
|
106
|
+
hostExplicit,
|
|
107
|
+
projectIdHint: env.POSTHOG_PROJECT_ID ?? null,
|
|
108
|
+
personalKeyConfigured: Boolean(env.POSTHOG_PERSONAL_API_KEY),
|
|
109
|
+
webhookSecretConfigured: Boolean(env.POSTHOG_WEBHOOK_SECRET),
|
|
110
|
+
apiPublicUrl: env.API_PUBLIC_URL,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const provisionLoopResponseSchema = z.object({
|
|
115
|
+
provisioned: z.literal(true),
|
|
116
|
+
/** `action === "created"` — false covers both "updated" and "unchanged". */
|
|
117
|
+
created: z.boolean(),
|
|
118
|
+
action: z.enum(["created", "updated", "unchanged"]),
|
|
119
|
+
hogFunctionId: z.string(),
|
|
120
|
+
webhookUrl: z.string(),
|
|
121
|
+
dashboardUrl: z.string(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const provisionFailureSchema = z.object({
|
|
125
|
+
error: z.string(),
|
|
126
|
+
detail: z.string(),
|
|
127
|
+
remediation: z.string(),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const connectInfoRoute = createRoute({
|
|
131
|
+
method: "get",
|
|
132
|
+
path: "/connect-info",
|
|
133
|
+
tags: ["Admin — Analytics"],
|
|
134
|
+
summary: "PostHog connection info for `hogsend connect posthog`",
|
|
135
|
+
responses: {
|
|
136
|
+
200: {
|
|
137
|
+
content: {
|
|
138
|
+
"application/json": { schema: connectInfoSchema },
|
|
139
|
+
},
|
|
140
|
+
description:
|
|
141
|
+
"The instance's PostHog env signal (region, readiness flags) — " +
|
|
142
|
+
"secrets never appear, only their configured-ness",
|
|
143
|
+
},
|
|
144
|
+
401: {
|
|
145
|
+
content: { "application/json": { schema: errorSchema } },
|
|
146
|
+
description: "Missing or invalid admin credentials",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const provisionLoopRoute = createRoute({
|
|
152
|
+
method: "post",
|
|
153
|
+
path: "/provision-loop",
|
|
154
|
+
tags: ["Admin — Analytics"],
|
|
155
|
+
summary: "Provision the PostHog → Hogsend event loop (webhook destination)",
|
|
156
|
+
responses: {
|
|
157
|
+
200: {
|
|
158
|
+
content: {
|
|
159
|
+
"application/json": { schema: provisionLoopResponseSchema },
|
|
160
|
+
},
|
|
161
|
+
description:
|
|
162
|
+
"Loop provisioned (idempotent: created, updated, or unchanged)",
|
|
163
|
+
},
|
|
164
|
+
401: {
|
|
165
|
+
content: { "application/json": { schema: errorSchema } },
|
|
166
|
+
description: "Missing or invalid admin credentials",
|
|
167
|
+
},
|
|
168
|
+
409: {
|
|
169
|
+
content: { "application/json": { schema: errorSchema } },
|
|
170
|
+
description:
|
|
171
|
+
"Refused: `no_posthog_credential` (no OAuth credential and no " +
|
|
172
|
+
"personal API key), `posthog_not_configured` (no PostHog env signal " +
|
|
173
|
+
"at all), `webhook_secret_missing` (POSTHOG_WEBHOOK_SECRET unset), " +
|
|
174
|
+
"or `api_public_url_unreachable` (API_PUBLIC_URL is loopback — " +
|
|
175
|
+
"PostHog cannot deliver to it)",
|
|
176
|
+
},
|
|
177
|
+
502: {
|
|
178
|
+
content: { "application/json": { schema: provisionFailureSchema } },
|
|
179
|
+
description:
|
|
180
|
+
"PostHog rejected the provisioning call — error is the provisioner " +
|
|
181
|
+
"error code, remediation is operator-facing and printed verbatim",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
187
|
+
.openapi(connectInfoRoute, async (c) => {
|
|
188
|
+
const { db, env } = c.get("container");
|
|
189
|
+
|
|
190
|
+
// The env projection is the pure base. The store can ALSO hold a minted
|
|
191
|
+
// webhook secret (provision-loop mints + persists one when env lacks it),
|
|
192
|
+
// so the configured-ness flag OR-s the two sources of truth.
|
|
193
|
+
const storedDerived = await getDerivedCredential(db, "posthog");
|
|
194
|
+
const webhookSecretConfigured =
|
|
195
|
+
Boolean(env.POSTHOG_WEBHOOK_SECRET) ||
|
|
196
|
+
Boolean(storedDerived?.webhookSecret);
|
|
197
|
+
|
|
198
|
+
// scopeGap = expected scopes the STORED oauth credential is missing. No
|
|
199
|
+
// credential ⇒ no gap to report (the connect flow will request the full
|
|
200
|
+
// set). A decrypt failure is non-fatal here — fall back to no gap.
|
|
201
|
+
let scopeGap: string[] = [];
|
|
202
|
+
try {
|
|
203
|
+
const oauth = await getProviderCredential(db, "posthog");
|
|
204
|
+
if (oauth) {
|
|
205
|
+
const granted = oauth.payload.scopes;
|
|
206
|
+
scopeGap = EXPECTED_POSTHOG_SCOPES.filter((s) => !granted.includes(s));
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
scopeGap = [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return c.json(
|
|
213
|
+
{ ...resolveConnectInfo(env), webhookSecretConfigured, scopeGap },
|
|
214
|
+
200,
|
|
215
|
+
);
|
|
216
|
+
})
|
|
217
|
+
.openapi(provisionLoopRoute, async (c) => {
|
|
218
|
+
const { db, env, logger } = c.get("container");
|
|
219
|
+
const info = resolveConnectInfo(env);
|
|
220
|
+
|
|
221
|
+
// Credential check FIRST (M3), secret refusal second (enforced by the
|
|
222
|
+
// provisioner itself). The container does NOT expose the token manager
|
|
223
|
+
// (it is closed over inside the provider's accessor), so a route-local
|
|
224
|
+
// instance is correct — the DB row is the shared truth and provisioning
|
|
225
|
+
// is a one-shot admin action.
|
|
226
|
+
const tokenManager = createTokenManager({
|
|
227
|
+
db,
|
|
228
|
+
providerId: "posthog",
|
|
229
|
+
logger,
|
|
230
|
+
});
|
|
231
|
+
const accessToken =
|
|
232
|
+
(await tokenManager.getAccessToken()) ??
|
|
233
|
+
env.POSTHOG_PERSONAL_API_KEY ??
|
|
234
|
+
null;
|
|
235
|
+
if (!accessToken) {
|
|
236
|
+
return c.json({ error: "no_posthog_credential" }, 409);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (info.privateHost === null) {
|
|
240
|
+
// A credential exists but the server has no PostHog env signal to tell
|
|
241
|
+
// us which region to provision against.
|
|
242
|
+
return c.json({ error: "posthog_not_configured" }, 409);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// PostHog Cloud cannot deliver webhooks to a loopback address — and a
|
|
246
|
+
// local instance with a misconfigured API_PUBLIC_URL must never repoint
|
|
247
|
+
// a production destination at localhost. Refuse BEFORE any PostHog call.
|
|
248
|
+
if (isLoopbackPublicUrl(info.apiPublicUrl)) {
|
|
249
|
+
return c.json(
|
|
250
|
+
{
|
|
251
|
+
error: "api_public_url_unreachable",
|
|
252
|
+
detail: `API_PUBLIC_URL is ${info.apiPublicUrl} — PostHog cannot reach a loopback address.`,
|
|
253
|
+
remediation:
|
|
254
|
+
"Run this against your DEPLOYED instance (the credential is already stored there if you connected it): hogsend connect posthog --provision-only --url https://your-instance",
|
|
255
|
+
},
|
|
256
|
+
409,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Resolve the webhook secret instead of requiring it: env wins, else a
|
|
261
|
+
// previously-minted stored secret, else mint a fresh one. When env has no
|
|
262
|
+
// secret, persist the resolved value so it survives AND the inbound
|
|
263
|
+
// posthog webhook source can resolve it from the store at request time
|
|
264
|
+
// (the source falls OPEN otherwise). The stored payload is merged so an
|
|
265
|
+
// existing phc_/projectId is preserved.
|
|
266
|
+
const storedDerived = await getDerivedCredential(db, "posthog");
|
|
267
|
+
const webhookSecret =
|
|
268
|
+
env.POSTHOG_WEBHOOK_SECRET ??
|
|
269
|
+
storedDerived?.webhookSecret ??
|
|
270
|
+
randomBytes(32).toString("hex");
|
|
271
|
+
if (env.POSTHOG_WEBHOOK_SECRET === undefined) {
|
|
272
|
+
await saveDerivedCredential(db, "posthog", {
|
|
273
|
+
...(storedDerived ?? {}),
|
|
274
|
+
webhookSecret,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const result = await provisionPostHogLoop({
|
|
280
|
+
privateHost: info.privateHost,
|
|
281
|
+
accessToken,
|
|
282
|
+
// M8: pass the env project id when set; else the provisioner runs its
|
|
283
|
+
// own one-shot `@current` discovery.
|
|
284
|
+
...(env.POSTHOG_PROJECT_ID !== undefined
|
|
285
|
+
? { projectId: env.POSTHOG_PROJECT_ID }
|
|
286
|
+
: {}),
|
|
287
|
+
apiPublicUrl: info.apiPublicUrl,
|
|
288
|
+
webhookSecret,
|
|
289
|
+
logger,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Opportunistically persist the phc_ (project api_token) the provisioner
|
|
293
|
+
// read on its way through — it powers the OPTIONAL outbound capture path
|
|
294
|
+
// and activates on the next deploy (no lazy boot-time seam). Re-read the
|
|
295
|
+
// stored payload to merge over the just-persisted webhook secret.
|
|
296
|
+
if (result.projectApiKey) {
|
|
297
|
+
const cur = (await getDerivedCredential(db, "posthog")) ?? {};
|
|
298
|
+
await saveDerivedCredential(db, "posthog", {
|
|
299
|
+
...cur,
|
|
300
|
+
projectApiKey: result.projectApiKey,
|
|
301
|
+
projectId: result.projectId,
|
|
302
|
+
privateHost: info.privateHost,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// M4 translation: the CLI's documented shape, with `action` riding
|
|
307
|
+
// along for --json consumers.
|
|
308
|
+
return c.json(
|
|
309
|
+
{
|
|
310
|
+
provisioned: true as const,
|
|
311
|
+
created: result.action === "created",
|
|
312
|
+
action: result.action,
|
|
313
|
+
hogFunctionId: result.functionId,
|
|
314
|
+
webhookUrl: result.webhookUrl,
|
|
315
|
+
dashboardUrl: result.dashboardUrl,
|
|
316
|
+
},
|
|
317
|
+
200,
|
|
318
|
+
);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error instanceof ProvisionPostHogLoopError) {
|
|
321
|
+
if (error.code === "missing-webhook-secret") {
|
|
322
|
+
return c.json({ error: "webhook_secret_missing" }, 409);
|
|
323
|
+
}
|
|
324
|
+
return c.json(
|
|
325
|
+
{
|
|
326
|
+
error: error.code,
|
|
327
|
+
detail: error.message,
|
|
328
|
+
remediation: error.remediation,
|
|
329
|
+
},
|
|
330
|
+
502,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
@@ -4,6 +4,7 @@ import { auditMiddleware } from "../../middleware/audit.js";
|
|
|
4
4
|
import { rateLimit } from "../../middleware/rate-limit.js";
|
|
5
5
|
import { requireAdmin } from "../../middleware/require-admin.js";
|
|
6
6
|
import { alertsRouter } from "./alerts.js";
|
|
7
|
+
import { analyticsAdminRouter } from "./analytics.js";
|
|
7
8
|
import { apiKeysRouter } from "./api-keys.js";
|
|
8
9
|
import { auditLogsRouter } from "./audit-logs.js";
|
|
9
10
|
import { bucketsRouter } from "./buckets.js";
|
|
@@ -17,6 +18,7 @@ import { journeyLogsRouter } from "./journey-logs.js";
|
|
|
17
18
|
import { journeysRouter } from "./journeys.js";
|
|
18
19
|
import { metricsRouter } from "./metrics.js";
|
|
19
20
|
import { preferencesRouter } from "./preferences.js";
|
|
21
|
+
import { providerCredentialsRouter } from "./provider-credentials.js";
|
|
20
22
|
import { reportingRouter } from "./reporting.js";
|
|
21
23
|
import { suppressionsRouter } from "./suppressions.js";
|
|
22
24
|
import { templatesRouter } from "./templates.js";
|
|
@@ -42,6 +44,8 @@ adminRouter.route("/templates", templatesRouter);
|
|
|
42
44
|
adminRouter.route("/suppressions", suppressionsRouter);
|
|
43
45
|
adminRouter.route("/api-keys", apiKeysRouter);
|
|
44
46
|
adminRouter.route("/webhooks", webhooksRouter);
|
|
47
|
+
adminRouter.route("/provider-credentials", providerCredentialsRouter);
|
|
48
|
+
adminRouter.route("/analytics", analyticsAdminRouter);
|
|
45
49
|
adminRouter.route("/audit-logs", auditLogsRouter);
|
|
46
50
|
adminRouter.route("/alerts", alertsRouter);
|
|
47
51
|
adminRouter.route("/dlq", dlqRouter);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import {
|
|
4
|
+
deleteProviderCredential,
|
|
5
|
+
getProviderCredential,
|
|
6
|
+
ProviderCredentialDecryptError,
|
|
7
|
+
type ProviderCredentialMeta,
|
|
8
|
+
saveProviderCredential,
|
|
9
|
+
toCredentialMeta,
|
|
10
|
+
} from "../../lib/provider-credentials.js";
|
|
11
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Admin provider-credential management. Mounted at
|
|
15
|
+
* `/v1/admin/provider-credentials`, it inherits `requireAdmin` + `rateLimit`
|
|
16
|
+
* + `auditMiddleware` from the admin router root — no per-route auth here.
|
|
17
|
+
*
|
|
18
|
+
* INVARIANT: decrypted token material NEVER appears in any HTTP response —
|
|
19
|
+
* this router returns meta only. (The audit middleware is body-blind, so a
|
|
20
|
+
* PUT body carrying tokens is not persisted to `audit_logs` either.)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const providerIdParam = z.object({
|
|
24
|
+
providerId: z.string().min(1).max(100),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// The canonical OAuth credential payload (SYNTHESIS §0) — keep textually
|
|
28
|
+
// identical to the token manager's runtime parse.
|
|
29
|
+
const oauthPayloadSchema = z.object({
|
|
30
|
+
accessToken: z.string().min(1),
|
|
31
|
+
refreshToken: z.string().min(1),
|
|
32
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
33
|
+
tokenEndpoint: z.string().url(),
|
|
34
|
+
clientId: z.string().url(),
|
|
35
|
+
scopes: z.array(z.string()).default([]),
|
|
36
|
+
scopedTeams: z.array(z.number().int()).default([]),
|
|
37
|
+
scopedOrganizations: z.array(z.string()).default([]),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const putBodySchema = z.object({
|
|
41
|
+
kind: z.literal("oauth").default("oauth"),
|
|
42
|
+
payload: oauthPayloadSchema,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Wire meta — Dates serialized to ISO strings. Token material never appears.
|
|
46
|
+
const credentialMetaSchema = z.object({
|
|
47
|
+
providerId: z.string(),
|
|
48
|
+
kind: z.literal("oauth"),
|
|
49
|
+
scopes: z.array(z.string()),
|
|
50
|
+
expiresAt: z.string(),
|
|
51
|
+
scopedTeams: z.array(z.number()),
|
|
52
|
+
createdAt: z.string(),
|
|
53
|
+
updatedAt: z.string(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const upsertRoute = createRoute({
|
|
57
|
+
method: "put",
|
|
58
|
+
path: "/{providerId}",
|
|
59
|
+
tags: ["Admin — Provider Credentials"],
|
|
60
|
+
summary: "Store (upsert) a provider credential",
|
|
61
|
+
request: {
|
|
62
|
+
params: providerIdParam,
|
|
63
|
+
body: {
|
|
64
|
+
content: {
|
|
65
|
+
"application/json": { schema: putBodySchema },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
responses: {
|
|
70
|
+
200: {
|
|
71
|
+
content: {
|
|
72
|
+
"application/json": { schema: credentialMetaSchema },
|
|
73
|
+
},
|
|
74
|
+
description:
|
|
75
|
+
"Credential stored (encrypted at rest) — meta only, tokens are never returned",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const getRoute = createRoute({
|
|
81
|
+
method: "get",
|
|
82
|
+
path: "/{providerId}",
|
|
83
|
+
tags: ["Admin — Provider Credentials"],
|
|
84
|
+
summary: "Get a provider credential's meta",
|
|
85
|
+
request: {
|
|
86
|
+
params: providerIdParam,
|
|
87
|
+
},
|
|
88
|
+
responses: {
|
|
89
|
+
200: {
|
|
90
|
+
content: {
|
|
91
|
+
"application/json": { schema: credentialMetaSchema },
|
|
92
|
+
},
|
|
93
|
+
description: "Credential meta — decrypted tokens are NEVER returned",
|
|
94
|
+
},
|
|
95
|
+
404: {
|
|
96
|
+
content: { "application/json": { schema: errorSchema } },
|
|
97
|
+
description: "No credential stored for this provider",
|
|
98
|
+
},
|
|
99
|
+
409: {
|
|
100
|
+
content: { "application/json": { schema: errorSchema } },
|
|
101
|
+
description:
|
|
102
|
+
"Credential exists but cannot be decrypted — BETTER_AUTH_SECRET rotated; PUT a new credential or DELETE",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const deleteRoute = createRoute({
|
|
108
|
+
method: "delete",
|
|
109
|
+
path: "/{providerId}",
|
|
110
|
+
tags: ["Admin — Provider Credentials"],
|
|
111
|
+
summary: "Delete a provider credential",
|
|
112
|
+
request: {
|
|
113
|
+
params: providerIdParam,
|
|
114
|
+
},
|
|
115
|
+
responses: {
|
|
116
|
+
200: {
|
|
117
|
+
content: {
|
|
118
|
+
"application/json": { schema: z.object({ deleted: z.boolean() }) },
|
|
119
|
+
},
|
|
120
|
+
description: "Credential hard-deleted",
|
|
121
|
+
},
|
|
122
|
+
404: {
|
|
123
|
+
content: { "application/json": { schema: errorSchema } },
|
|
124
|
+
description: "No credential stored for this provider",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function serializeMeta(meta: ProviderCredentialMeta) {
|
|
130
|
+
// This admin surface is OAuth-only: PUT forces `kind: "oauth"` and GET reads
|
|
131
|
+
// the oauth credential, so the meta's kind is always "oauth" at runtime even
|
|
132
|
+
// though `ProviderCredentialMeta.kind` widened to the "oauth" | "derived"
|
|
133
|
+
// union when the derived store landed. Narrow it back to the schema literal.
|
|
134
|
+
return {
|
|
135
|
+
providerId: meta.providerId,
|
|
136
|
+
kind: "oauth" as const,
|
|
137
|
+
scopes: meta.scopes,
|
|
138
|
+
expiresAt: meta.expiresAt.toISOString(),
|
|
139
|
+
scopedTeams: meta.scopedTeams,
|
|
140
|
+
createdAt: meta.createdAt.toISOString(),
|
|
141
|
+
updatedAt: meta.updatedAt.toISOString(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const providerCredentialsRouter = new OpenAPIHono<AppEnv>()
|
|
146
|
+
.openapi(upsertRoute, async (c) => {
|
|
147
|
+
const { db } = c.get("container");
|
|
148
|
+
const { providerId } = c.req.valid("param");
|
|
149
|
+
const body = c.req.valid("json");
|
|
150
|
+
|
|
151
|
+
// PUT is a full idempotent upsert — create and update are the same 200.
|
|
152
|
+
const meta = await saveProviderCredential(db, {
|
|
153
|
+
providerId,
|
|
154
|
+
kind: body.kind,
|
|
155
|
+
payload: body.payload,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return c.json(serializeMeta(meta), 200);
|
|
159
|
+
})
|
|
160
|
+
.openapi(getRoute, async (c) => {
|
|
161
|
+
const { db } = c.get("container");
|
|
162
|
+
const { providerId } = c.req.valid("param");
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const record = await getProviderCredential(db, providerId);
|
|
166
|
+
if (!record) {
|
|
167
|
+
return c.json({ error: "Provider credential not found" }, 404);
|
|
168
|
+
}
|
|
169
|
+
return c.json(serializeMeta(toCredentialMeta(record)), 200);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof ProviderCredentialDecryptError) {
|
|
172
|
+
return c.json({ error: error.message }, 409);
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
.openapi(deleteRoute, async (c) => {
|
|
178
|
+
const { db } = c.get("container");
|
|
179
|
+
const { providerId } = c.req.valid("param");
|
|
180
|
+
|
|
181
|
+
// Never decrypts — DELETE must succeed even when the payload is
|
|
182
|
+
// undecryptable (the operator's escape hatch after a secret rotation).
|
|
183
|
+
const deleted = await deleteProviderCredential(db, providerId);
|
|
184
|
+
if (!deleted) {
|
|
185
|
+
return c.json({ error: "Provider credential not found" }, 404);
|
|
186
|
+
}
|
|
187
|
+
return c.json({ deleted: true }, 200);
|
|
188
|
+
});
|