@hogsend/plugin-posthog 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/plugin-posthog",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "posthog-node": "^5.35.1",
26
- "@hogsend/core": "^0.19.0"
26
+ "@hogsend/core": "^0.21.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "ioredis": ">=5.0.0"
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { captureEvent } from "./capture.js";
2
- export { createPostHogClient } from "./client.js";
2
+ export { createPostHogClient, DEFAULT_HOST } from "./client.js";
3
3
  export { derivePrivateHost, getPersonProperties } from "./properties.js";
4
4
  export { createPostHogProvider } from "./provider.js";
5
5
  export { createPostHogService } from "./service.js";
@@ -9,6 +9,7 @@ export type {
9
9
  PersonPropertiesCache,
10
10
  PersonPropertiesConfig,
11
11
  PersonPropertiesWrite,
12
+ PostHogAuthTokenAccessor,
12
13
  PostHogService,
13
14
  PostHogServiceConfig,
14
15
  } from "./types.js";
package/src/properties.ts CHANGED
@@ -25,14 +25,14 @@ export function derivePrivateHost(host: string): string {
25
25
  */
26
26
  async function discoverProjectId(opts: {
27
27
  privateHost: string;
28
- personalApiKey: string;
28
+ token: string;
29
29
  }): Promise<string | undefined> {
30
30
  try {
31
31
  const response = await fetch(
32
32
  new URL("/api/projects/@current/", opts.privateHost).toString(),
33
33
  {
34
34
  headers: {
35
- Authorization: `Bearer ${opts.personalApiKey}`,
35
+ Authorization: `Bearer ${opts.token}`,
36
36
  Accept: "application/json",
37
37
  },
38
38
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -46,16 +46,22 @@ async function discoverProjectId(opts: {
46
46
  }
47
47
  }
48
48
 
49
- /** Per-(host,key) one-shot project-id discovery, shared across calls. */
49
+ /** Per-(host,credential) one-shot project-id discovery, shared across calls. */
50
50
  const projectIdCache = new Map<string, Promise<string | undefined>>();
51
51
 
52
52
  function resolveProjectId(opts: {
53
53
  privateHost: string;
54
- personalApiKey: string;
54
+ token: string;
55
+ /**
56
+ * Cache identity, NOT the bearer token: OAuth access tokens rotate every
57
+ * ~10h, so keying on `token` would re-discover after every refresh. Callers
58
+ * pass the (stable) personal key or a fixed "oauth" marker.
59
+ */
60
+ cacheKey: string;
55
61
  projectId?: string;
56
62
  }): Promise<string | undefined> {
57
63
  if (opts.projectId) return Promise.resolve(opts.projectId);
58
- const cacheKey = `${opts.privateHost}::${opts.personalApiKey}`;
64
+ const cacheKey = `${opts.privateHost}::${opts.cacheKey}`;
59
65
  let pending = projectIdCache.get(cacheKey);
60
66
  if (!pending) {
61
67
  pending = discoverProjectId(opts).then((id) => {
@@ -71,11 +77,12 @@ function resolveProjectId(opts: {
71
77
  /**
72
78
  * Person-property READ via PostHog's private API.
73
79
  *
74
- * Requires a PERSONAL API key (scope `person:read`) — the `phc_` project key
75
- * is write-only by design (it ships in browser bundles) and can never read.
76
- * Without `personalApiKey` this resolves `{}` immediately (reads disabled);
77
- * the engine's timezone fallbacks (contact properties client default) take
78
- * over. All upstream errors also soft-fail to `{}`.
80
+ * Requires a privileged credential (scope `person:read`) — the `phc_` project
81
+ * key is write-only by design (it ships in browser bundles) and can never
82
+ * read. An OAuth token (via `getAuthToken`) is preferred; `personalApiKey` is
83
+ * the fallback. With NEITHER configured this resolves `{}` immediately (reads
84
+ * disabled); the engine's timezone fallbacks (contact properties → client
85
+ * default) take over. All upstream errors also soft-fail to `{}`.
79
86
  *
80
87
  * The private API lives on the APP host (`eu.posthog.com`), not the
81
88
  * ingestion host (`eu.i.posthog.com`) — derived via {@link derivePrivateHost}.
@@ -87,7 +94,7 @@ export async function getPersonProperties(opts: {
87
94
  }): Promise<Record<string, unknown>> {
88
95
  const { config, distinctId, cache } = opts;
89
96
 
90
- if (!config.personalApiKey) return {};
97
+ if (!config.personalApiKey && !config.getAuthToken) return {};
91
98
 
92
99
  if (cache) {
93
100
  try {
@@ -105,9 +112,16 @@ export async function getPersonProperties(opts: {
105
112
  let properties: Record<string, unknown> = {};
106
113
 
107
114
  try {
115
+ // OAuth preferred, personal key fallback — a revoked/failed OAuth
116
+ // credential degrades to the personal key for free.
117
+ const oauthToken = config.getAuthToken ? await config.getAuthToken() : null;
118
+ const token = oauthToken ?? config.personalApiKey;
119
+ if (!token) return {};
120
+
108
121
  const projectId = await resolveProjectId({
109
122
  privateHost,
110
- personalApiKey: config.personalApiKey,
123
+ token,
124
+ cacheKey: config.personalApiKey ?? "oauth",
111
125
  projectId: config.projectId,
112
126
  });
113
127
  if (!projectId) return {};
@@ -120,7 +134,7 @@ export async function getPersonProperties(opts: {
120
134
 
121
135
  const response = await fetch(url.toString(), {
122
136
  headers: {
123
- Authorization: `Bearer ${config.personalApiKey}`,
137
+ Authorization: `Bearer ${token}`,
124
138
  Accept: "application/json",
125
139
  },
126
140
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
package/src/provider.ts CHANGED
@@ -16,19 +16,23 @@ import type {
16
16
  * - **capture + person WRITES** use the public project key (`apiKey`) — person
17
17
  * writes ride the capture pipeline as `$set`/`$set_once`, so propagation
18
18
  * needs NO extra credential.
19
- * - **person READS** need `personalApiKey` (a personal API key scoped
20
- * `person:read`) against the private API host. Without it,
21
- * `capabilities.personReads` is false and reads soft-fail to `{}` the
22
- * engine falls back to contact properties for timezone resolution.
19
+ * - **person READS** need a privileged credential against the private API
20
+ * host: an engine-injected OAuth accessor (`authToken`, preferred) or
21
+ * `personalApiKey` (a personal API key scoped `person:read`, the fallback).
22
+ * With neither, `capabilities.personReads` is false and reads soft-fail to
23
+ * `{}` — the engine falls back to contact properties for timezone
24
+ * resolution.
23
25
  */
24
26
  export function createPostHogProvider(
25
27
  config: PostHogServiceConfig,
26
28
  ): AnalyticsProvider {
27
29
  const host = config.host ?? DEFAULT_HOST;
28
30
  const client = createPostHogClient({ apiKey: config.apiKey, host });
31
+ const authToken = config.authToken;
29
32
 
30
33
  const propsConfig: PersonPropertiesConfig = {
31
34
  personalApiKey: config.personalApiKey,
35
+ getAuthToken: authToken ? () => authToken.getToken() : undefined,
32
36
  host,
33
37
  privateHost: config.privateHost,
34
38
  projectId: config.projectId,
@@ -46,8 +50,17 @@ export function createPostHogProvider(
46
50
  "PostHog capture + person reads/writes (reads need a personal API key).",
47
51
  },
48
52
  capabilities: {
49
- personReads: Boolean(config.personalApiKey),
53
+ // LIVE getter: the container builds providers at BOOT, but the OAuth
54
+ // credential can be stored at RUNTIME via `hogsend connect posthog`.
55
+ // A getter means every reader (boot nudge, doctor, future Studio
56
+ // status) sees current truth without rebuilding the provider.
57
+ get personReads() {
58
+ return (
59
+ Boolean(config.personalApiKey) || (authToken?.isAvailable() ?? false)
60
+ );
61
+ },
50
62
  personWrites: true,
63
+ oauth: true,
51
64
  },
52
65
 
53
66
  async getPersonProperties(distinctId: string) {
package/src/types.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  import type { Redis } from "ioredis";
2
2
 
3
+ /**
4
+ * Engine-injected OAuth auth accessor. `getToken()` resolves a live bearer
5
+ * token (or null — degrade); `isAvailable()` is the synchronous best-known
6
+ * credential state, used for live capability reporting. The plugin stays a
7
+ * dumb wire: it neither refreshes nor stores anything.
8
+ */
9
+ export interface PostHogAuthTokenAccessor {
10
+ getToken(): Promise<string | null>;
11
+ isAvailable(): boolean;
12
+ }
13
+
3
14
  export interface PostHogServiceConfig {
4
15
  /** Project API key (`phc_…`) — capture/flags. Public + WRITE-ONLY by design. */
5
16
  apiKey: string;
@@ -28,6 +39,8 @@ export interface PostHogServiceConfig {
28
39
  projectId?: string;
29
40
  redis?: Redis;
30
41
  cacheTtlSeconds?: number;
42
+ /** OAuth accessor — preferred over `personalApiKey` when it yields a token. */
43
+ authToken?: PostHogAuthTokenAccessor;
31
44
  }
32
45
 
33
46
  // The analytics-provider contract now lives in the neutral @hogsend/core
@@ -41,8 +54,11 @@ export type {
41
54
  } from "@hogsend/core";
42
55
 
43
56
  export interface PersonPropertiesConfig {
44
- /** Personal API key — reads are DISABLED (soft-fail `{}`) without it. */
57
+ /** Personal API key — fallback when OAuth yields no token. Reads are
58
+ * DISABLED (soft-fail `{}`) when BOTH this and `getAuthToken` are absent. */
45
59
  personalApiKey?: string;
60
+ /** Async OAuth token resolver (the accessor's getToken, pre-bound). */
61
+ getAuthToken?: () => Promise<string | null>;
46
62
  /** Capture/ingestion host (private host is derived from it). */
47
63
  host: string;
48
64
  /** Private API host override. */