@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
|
@@ -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,261 @@
|
|
|
1
|
+
import { DEFAULT_HOST, derivePrivateHost } from "@hogsend/plugin-posthog";
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import type { AppEnv } from "../../app.js";
|
|
4
|
+
import { createTokenManager } from "../../lib/oauth-token-manager.js";
|
|
5
|
+
import {
|
|
6
|
+
ProvisionPostHogLoopError,
|
|
7
|
+
provisionPostHogLoop,
|
|
8
|
+
} from "../../lib/provision-posthog-loop.js";
|
|
9
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Admin analytics-connection routes — the server half of
|
|
13
|
+
* `hogsend connect posthog`. Mounted at `/v1/admin/analytics`, inheriting
|
|
14
|
+
* `requireAdmin` + `rateLimit` + `auditMiddleware` from the admin router root.
|
|
15
|
+
*
|
|
16
|
+
* - `GET /connect-info` surfaces the instance's PostHog env signal so the CLI
|
|
17
|
+
* needs NO PostHog env vars locally (it learns the region + readiness from
|
|
18
|
+
* the server). Pure env projection — it never discovers anything.
|
|
19
|
+
* - `POST /provision-loop` runs the idempotent PostHog → Hogsend hog-function
|
|
20
|
+
* provisioner server-side with the server's own credential (OAuth credential
|
|
21
|
+
* via a route-local token manager, falling back to the personal API key).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const connectInfoSchema = z.object({
|
|
25
|
+
providerId: z.literal("posthog"),
|
|
26
|
+
analyticsConfigured: z.boolean(),
|
|
27
|
+
privateHost: z.string().nullable(),
|
|
28
|
+
hostExplicit: z.boolean(),
|
|
29
|
+
projectIdHint: z.string().nullable(),
|
|
30
|
+
personalKeyConfigured: z.boolean(),
|
|
31
|
+
webhookSecretConfigured: z.boolean(),
|
|
32
|
+
apiPublicUrl: z.string(),
|
|
33
|
+
});
|
|
34
|
+
export type ConnectInfo = z.infer<typeof connectInfoSchema>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True when a public URL points at a loopback/unspecified address — PostHog
|
|
38
|
+
* Cloud can never deliver to it, so provisioning must refuse rather than
|
|
39
|
+
* create (or repoint!) a destination nobody can reach.
|
|
40
|
+
*/
|
|
41
|
+
export function isLoopbackPublicUrl(publicUrl: string): boolean {
|
|
42
|
+
try {
|
|
43
|
+
const host = new URL(publicUrl).hostname.toLowerCase();
|
|
44
|
+
return (
|
|
45
|
+
host === "localhost" ||
|
|
46
|
+
host === "127.0.0.1" ||
|
|
47
|
+
host === "0.0.0.0" ||
|
|
48
|
+
host === "[::1]" ||
|
|
49
|
+
host === "::1" ||
|
|
50
|
+
host.endsWith(".localhost")
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pure env projection, exported for unit tests — no app boot needed.
|
|
59
|
+
*
|
|
60
|
+
* `privateHost: null` only when the server has NO PostHog signal at all (no
|
|
61
|
+
* API key, no host overrides). When `POSTHOG_API_KEY` is set without a host,
|
|
62
|
+
* the provider defaults to US Cloud, so the derived `https://us.posthog.com`
|
|
63
|
+
* is returned with `hostExplicit: false` — the CLI warns and confirms.
|
|
64
|
+
*
|
|
65
|
+
* Surfaces env ONLY — project-id discovery belongs to the provisioner (M8),
|
|
66
|
+
* and the webhook secret VALUE never leaves the server (only its
|
|
67
|
+
* configured-ness does).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveConnectInfo(env: {
|
|
70
|
+
POSTHOG_API_KEY?: string;
|
|
71
|
+
POSTHOG_HOST?: string;
|
|
72
|
+
POSTHOG_PRIVATE_HOST?: string;
|
|
73
|
+
POSTHOG_PROJECT_ID?: string;
|
|
74
|
+
POSTHOG_PERSONAL_API_KEY?: string;
|
|
75
|
+
POSTHOG_WEBHOOK_SECRET?: string;
|
|
76
|
+
API_PUBLIC_URL: string;
|
|
77
|
+
}): ConnectInfo {
|
|
78
|
+
const hostExplicit = Boolean(env.POSTHOG_PRIVATE_HOST ?? env.POSTHOG_HOST);
|
|
79
|
+
const configuredAtAll = Boolean(env.POSTHOG_API_KEY) || hostExplicit;
|
|
80
|
+
const privateHost = !configuredAtAll
|
|
81
|
+
? null
|
|
82
|
+
: (
|
|
83
|
+
env.POSTHOG_PRIVATE_HOST ??
|
|
84
|
+
derivePrivateHost(env.POSTHOG_HOST ?? DEFAULT_HOST)
|
|
85
|
+
).replace(/\/+$/, "");
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
providerId: "posthog",
|
|
89
|
+
analyticsConfigured: Boolean(env.POSTHOG_API_KEY),
|
|
90
|
+
privateHost,
|
|
91
|
+
hostExplicit,
|
|
92
|
+
projectIdHint: env.POSTHOG_PROJECT_ID ?? null,
|
|
93
|
+
personalKeyConfigured: Boolean(env.POSTHOG_PERSONAL_API_KEY),
|
|
94
|
+
webhookSecretConfigured: Boolean(env.POSTHOG_WEBHOOK_SECRET),
|
|
95
|
+
apiPublicUrl: env.API_PUBLIC_URL,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const provisionLoopResponseSchema = z.object({
|
|
100
|
+
provisioned: z.literal(true),
|
|
101
|
+
/** `action === "created"` — false covers both "updated" and "unchanged". */
|
|
102
|
+
created: z.boolean(),
|
|
103
|
+
action: z.enum(["created", "updated", "unchanged"]),
|
|
104
|
+
hogFunctionId: z.string(),
|
|
105
|
+
webhookUrl: z.string(),
|
|
106
|
+
dashboardUrl: z.string(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const provisionFailureSchema = z.object({
|
|
110
|
+
error: z.string(),
|
|
111
|
+
detail: z.string(),
|
|
112
|
+
remediation: z.string(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const connectInfoRoute = createRoute({
|
|
116
|
+
method: "get",
|
|
117
|
+
path: "/connect-info",
|
|
118
|
+
tags: ["Admin — Analytics"],
|
|
119
|
+
summary: "PostHog connection info for `hogsend connect posthog`",
|
|
120
|
+
responses: {
|
|
121
|
+
200: {
|
|
122
|
+
content: {
|
|
123
|
+
"application/json": { schema: connectInfoSchema },
|
|
124
|
+
},
|
|
125
|
+
description:
|
|
126
|
+
"The instance's PostHog env signal (region, readiness flags) — " +
|
|
127
|
+
"secrets never appear, only their configured-ness",
|
|
128
|
+
},
|
|
129
|
+
401: {
|
|
130
|
+
content: { "application/json": { schema: errorSchema } },
|
|
131
|
+
description: "Missing or invalid admin credentials",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const provisionLoopRoute = createRoute({
|
|
137
|
+
method: "post",
|
|
138
|
+
path: "/provision-loop",
|
|
139
|
+
tags: ["Admin — Analytics"],
|
|
140
|
+
summary: "Provision the PostHog → Hogsend event loop (webhook destination)",
|
|
141
|
+
responses: {
|
|
142
|
+
200: {
|
|
143
|
+
content: {
|
|
144
|
+
"application/json": { schema: provisionLoopResponseSchema },
|
|
145
|
+
},
|
|
146
|
+
description:
|
|
147
|
+
"Loop provisioned (idempotent: created, updated, or unchanged)",
|
|
148
|
+
},
|
|
149
|
+
401: {
|
|
150
|
+
content: { "application/json": { schema: errorSchema } },
|
|
151
|
+
description: "Missing or invalid admin credentials",
|
|
152
|
+
},
|
|
153
|
+
409: {
|
|
154
|
+
content: { "application/json": { schema: errorSchema } },
|
|
155
|
+
description:
|
|
156
|
+
"Refused: `no_posthog_credential` (no OAuth credential and no " +
|
|
157
|
+
"personal API key), `posthog_not_configured` (no PostHog env signal " +
|
|
158
|
+
"at all), `webhook_secret_missing` (POSTHOG_WEBHOOK_SECRET unset), " +
|
|
159
|
+
"or `api_public_url_unreachable` (API_PUBLIC_URL is loopback — " +
|
|
160
|
+
"PostHog cannot deliver to it)",
|
|
161
|
+
},
|
|
162
|
+
502: {
|
|
163
|
+
content: { "application/json": { schema: provisionFailureSchema } },
|
|
164
|
+
description:
|
|
165
|
+
"PostHog rejected the provisioning call — error is the provisioner " +
|
|
166
|
+
"error code, remediation is operator-facing and printed verbatim",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export const analyticsAdminRouter = new OpenAPIHono<AppEnv>()
|
|
172
|
+
.openapi(connectInfoRoute, (c) => {
|
|
173
|
+
return c.json(resolveConnectInfo(c.get("container").env), 200);
|
|
174
|
+
})
|
|
175
|
+
.openapi(provisionLoopRoute, async (c) => {
|
|
176
|
+
const { db, env, logger } = c.get("container");
|
|
177
|
+
const info = resolveConnectInfo(env);
|
|
178
|
+
|
|
179
|
+
// Credential check FIRST (M3), secret refusal second (enforced by the
|
|
180
|
+
// provisioner itself). The container does NOT expose the token manager
|
|
181
|
+
// (it is closed over inside the provider's accessor), so a route-local
|
|
182
|
+
// instance is correct — the DB row is the shared truth and provisioning
|
|
183
|
+
// is a one-shot admin action.
|
|
184
|
+
const tokenManager = createTokenManager({
|
|
185
|
+
db,
|
|
186
|
+
providerId: "posthog",
|
|
187
|
+
logger,
|
|
188
|
+
});
|
|
189
|
+
const accessToken =
|
|
190
|
+
(await tokenManager.getAccessToken()) ??
|
|
191
|
+
env.POSTHOG_PERSONAL_API_KEY ??
|
|
192
|
+
null;
|
|
193
|
+
if (!accessToken) {
|
|
194
|
+
return c.json({ error: "no_posthog_credential" }, 409);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (info.privateHost === null) {
|
|
198
|
+
// A credential exists but the server has no PostHog env signal to tell
|
|
199
|
+
// us which region to provision against.
|
|
200
|
+
return c.json({ error: "posthog_not_configured" }, 409);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// PostHog Cloud cannot deliver webhooks to a loopback address — and a
|
|
204
|
+
// local instance with a misconfigured API_PUBLIC_URL must never repoint
|
|
205
|
+
// a production destination at localhost. Refuse BEFORE any PostHog call.
|
|
206
|
+
if (isLoopbackPublicUrl(info.apiPublicUrl)) {
|
|
207
|
+
return c.json(
|
|
208
|
+
{
|
|
209
|
+
error: "api_public_url_unreachable",
|
|
210
|
+
detail: `API_PUBLIC_URL is ${info.apiPublicUrl} — PostHog cannot reach a loopback address.`,
|
|
211
|
+
remediation:
|
|
212
|
+
"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",
|
|
213
|
+
},
|
|
214
|
+
409,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const result = await provisionPostHogLoop({
|
|
220
|
+
privateHost: info.privateHost,
|
|
221
|
+
accessToken,
|
|
222
|
+
// M8: pass the env project id when set; else the provisioner runs its
|
|
223
|
+
// own one-shot `@current` discovery.
|
|
224
|
+
...(env.POSTHOG_PROJECT_ID !== undefined
|
|
225
|
+
? { projectId: env.POSTHOG_PROJECT_ID }
|
|
226
|
+
: {}),
|
|
227
|
+
apiPublicUrl: info.apiPublicUrl,
|
|
228
|
+
webhookSecret: env.POSTHOG_WEBHOOK_SECRET,
|
|
229
|
+
logger,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// M4 translation: the CLI's documented shape, with `action` riding
|
|
233
|
+
// along for --json consumers.
|
|
234
|
+
return c.json(
|
|
235
|
+
{
|
|
236
|
+
provisioned: true as const,
|
|
237
|
+
created: result.action === "created",
|
|
238
|
+
action: result.action,
|
|
239
|
+
hogFunctionId: result.functionId,
|
|
240
|
+
webhookUrl: result.webhookUrl,
|
|
241
|
+
dashboardUrl: result.dashboardUrl,
|
|
242
|
+
},
|
|
243
|
+
200,
|
|
244
|
+
);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof ProvisionPostHogLoopError) {
|
|
247
|
+
if (error.code === "missing-webhook-secret") {
|
|
248
|
+
return c.json({ error: "webhook_secret_missing" }, 409);
|
|
249
|
+
}
|
|
250
|
+
return c.json(
|
|
251
|
+
{
|
|
252
|
+
error: error.code,
|
|
253
|
+
detail: error.message,
|
|
254
|
+
remediation: error.remediation,
|
|
255
|
+
},
|
|
256
|
+
502,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
@@ -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,184 @@
|
|
|
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
|
+
return {
|
|
131
|
+
providerId: meta.providerId,
|
|
132
|
+
kind: meta.kind,
|
|
133
|
+
scopes: meta.scopes,
|
|
134
|
+
expiresAt: meta.expiresAt.toISOString(),
|
|
135
|
+
scopedTeams: meta.scopedTeams,
|
|
136
|
+
createdAt: meta.createdAt.toISOString(),
|
|
137
|
+
updatedAt: meta.updatedAt.toISOString(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const providerCredentialsRouter = new OpenAPIHono<AppEnv>()
|
|
142
|
+
.openapi(upsertRoute, async (c) => {
|
|
143
|
+
const { db } = c.get("container");
|
|
144
|
+
const { providerId } = c.req.valid("param");
|
|
145
|
+
const body = c.req.valid("json");
|
|
146
|
+
|
|
147
|
+
// PUT is a full idempotent upsert — create and update are the same 200.
|
|
148
|
+
const meta = await saveProviderCredential(db, {
|
|
149
|
+
providerId,
|
|
150
|
+
kind: body.kind,
|
|
151
|
+
payload: body.payload,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return c.json(serializeMeta(meta), 200);
|
|
155
|
+
})
|
|
156
|
+
.openapi(getRoute, async (c) => {
|
|
157
|
+
const { db } = c.get("container");
|
|
158
|
+
const { providerId } = c.req.valid("param");
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const record = await getProviderCredential(db, providerId);
|
|
162
|
+
if (!record) {
|
|
163
|
+
return c.json({ error: "Provider credential not found" }, 404);
|
|
164
|
+
}
|
|
165
|
+
return c.json(serializeMeta(toCredentialMeta(record)), 200);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error instanceof ProviderCredentialDecryptError) {
|
|
168
|
+
return c.json({ error: error.message }, 409);
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
.openapi(deleteRoute, async (c) => {
|
|
174
|
+
const { db } = c.get("container");
|
|
175
|
+
const { providerId } = c.req.valid("param");
|
|
176
|
+
|
|
177
|
+
// Never decrypts — DELETE must succeed even when the payload is
|
|
178
|
+
// undecryptable (the operator's escape hatch after a secret rotation).
|
|
179
|
+
const deleted = await deleteProviderCredential(db, providerId);
|
|
180
|
+
if (!deleted) {
|
|
181
|
+
return c.json({ error: "Provider credential not found" }, 404);
|
|
182
|
+
}
|
|
183
|
+
return c.json({ deleted: true }, 200);
|
|
184
|
+
});
|
package/src/worker.ts
CHANGED
|
@@ -108,8 +108,9 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
108
108
|
_worker?.stop(),
|
|
109
109
|
// Shut down the injected analytics instance (same object the worker's
|
|
110
110
|
// tasks use), not the module singleton. Undefined when no analytics is
|
|
111
|
-
// configured
|
|
112
|
-
|
|
111
|
+
// configured, and `shutdown` is optional on the provider contract —
|
|
112
|
+
// both optional chains make that a no-op.
|
|
113
|
+
container.analytics?.shutdown?.(),
|
|
113
114
|
getRedisIfConnected()?.quit(),
|
|
114
115
|
]);
|
|
115
116
|
}
|