@hogsend/cli 0.20.0 → 0.21.1

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/cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "tsup": "^8.5.1",
35
35
  "tsx": "^4.22.4",
36
36
  "vitest": "^4.1.7",
37
- "@hogsend/studio": "^0.20.0",
37
+ "@hogsend/studio": "^0.21.1",
38
38
  "@repo/typescript-config": "0.0.0"
39
39
  },
40
40
  "engines": {
@@ -44,8 +44,8 @@
44
44
  "@clack/prompts": "^1.5.0",
45
45
  "better-auth": "^1.6.11",
46
46
  "picocolors": "^1.1.1",
47
- "@hogsend/db": "^0.20.0",
48
- "@hogsend/engine": "^0.20.0"
47
+ "@hogsend/db": "^0.21.1",
48
+ "@hogsend/engine": "^0.21.1"
49
49
  },
50
50
  "scripts": {
51
51
  "prebuild": "node scripts/bundle-studio.mjs",
@@ -103,6 +103,8 @@ function makeHarness(opts: {
103
103
  postResult?: unknown | Error;
104
104
  interactive?: boolean;
105
105
  confirmAnswer?: boolean;
106
+ /** Injected region resolver for the keyless (privateHost null) path. */
107
+ selectRegion?: () => Promise<string>;
106
108
  }): Harness {
107
109
  const sink: string[] = [];
108
110
  const calls: Harness["calls"] = {
@@ -209,6 +211,7 @@ function makeHarness(opts: {
209
211
  return true;
210
212
  },
211
213
  confirm: async () => opts.confirmAnswer ?? true,
214
+ ...(opts.selectRegion ? { selectRegion: opts.selectRegion } : {}),
212
215
  now: () => NOW,
213
216
  };
214
217
 
@@ -253,12 +256,9 @@ describe("runConnectPosthog — happy path", () => {
253
256
  expiresAt: "2026-06-13T04:00:00.000Z",
254
257
  tokenEndpoint: "https://eu.posthog.com/oauth/token/",
255
258
  clientId: POSTHOG_CLIENT_ID,
256
- scopes: [
257
- "person:read",
258
- "person:write",
259
- "project:read",
260
- "hog_function:write",
261
- ],
259
+ // The granted scopes are the front-loaded set the token response
260
+ // carries (TOKENS.scope === POSTHOG_SCOPES), split on whitespace.
261
+ scopes: POSTHOG_SCOPES.split(" "),
262
262
  scopedTeams: [123],
263
263
  scopedOrganizations: [],
264
264
  },
@@ -359,23 +359,21 @@ describe("runConnectPosthog — failure verdicts", () => {
359
359
  });
360
360
 
361
361
  describe("runConnectPosthog — provisioning outcomes", () => {
362
- it("soft-skips when the webhook secret is unconfigured (note with recovery)", async () => {
362
+ it("proceeds to provision even when the webhook secret is unconfigured (server mints it)", async () => {
363
+ // webhookSecretConfigured:false no longer short-circuits — the server mints
364
+ // + persists the secret during provisioning, so the flow stores the
365
+ // credential AND POSTs provision-loop, landing on `connected`.
363
366
  const h = makeHarness({
364
367
  info: connectInfo({ webhookSecretConfigured: false }),
365
368
  });
366
369
  const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
367
370
 
368
- expect(result.verdict).toBe("connected_no_provision");
371
+ expect(result.verdict).toBe("connected");
369
372
  expect(h.calls.put).toHaveLength(1);
370
- expect(h.calls.post).toHaveLength(0);
371
- expect(result.provision).toEqual({
372
- attempted: false,
373
- skipped: "webhook_secret_missing",
374
- });
375
-
376
- const note = h.sink.find((s) => s.includes("POSTHOG_WEBHOOK_SECRET"));
377
- expect(note).toBeDefined();
378
- expect(note).toContain("--provision-only");
373
+ expect(h.calls.post).toEqual([
374
+ { path: "/v1/admin/analytics/provision-loop", body: {} },
375
+ ]);
376
+ expect(result.provision).toMatchObject({ attempted: true, ok: true });
379
377
  });
380
378
 
381
379
  it("soft-skips when API_PUBLIC_URL is loopback (PostHog can't reach it)", async () => {
@@ -466,15 +464,15 @@ describe("runConnectPosthog — --provision-only", () => {
466
464
  expect(h.calls.post).toHaveLength(0);
467
465
  });
468
466
 
469
- it("hard-fails webhook_secret_missing BEFORE calling the route", async () => {
467
+ it("provisions even when the webhook secret is unconfigured (server mints it)", async () => {
468
+ // --provision-only no longer gates on webhookSecretConfigured — the server
469
+ // mints + persists the secret during provisioning, so the POST proceeds.
470
470
  const h = makeHarness({
471
471
  info: connectInfo({ webhookSecretConfigured: false }),
472
472
  });
473
- await expectConnectError(
474
- runConnectPosthog(h.deps, PROVISION_ONLY),
475
- "webhook_secret_missing",
476
- );
477
- expect(h.calls.post).toHaveLength(0);
473
+ const result = await runConnectPosthog(h.deps, PROVISION_ONLY);
474
+ expect(result.verdict).toBe("connected");
475
+ expect(h.calls.post).toHaveLength(1);
478
476
  });
479
477
 
480
478
  it("any other non-2xx is provision_failed", async () => {
@@ -490,3 +488,72 @@ describe("runConnectPosthog — --provision-only", () => {
490
488
  );
491
489
  });
492
490
  });
491
+
492
+ describe("runConnectPosthog — keyless / region resolution", () => {
493
+ it("resolves the region from --posthog-host on a keyless instance and proceeds", async () => {
494
+ // Server reports no PostHog config (privateHost null) — the CLI no longer
495
+ // hard-fails not_configured; it derives the region from the flag and runs
496
+ // the full OAuth handshake against it.
497
+ const h = makeHarness({ info: connectInfo({ privateHost: null }) });
498
+ const result = await runConnectPosthog(h.deps, {
499
+ ...FLOW_DEFAULTS,
500
+ posthogHost: "https://eu.posthog.com/",
501
+ });
502
+
503
+ expect(result.verdict).toBe("connected");
504
+ // Trailing slash stripped; discovery + the OAuth flow ran against it.
505
+ expect(h.calls.discover).toEqual(["https://eu.posthog.com"]);
506
+ expect(result.posthog?.privateHost).toBe("https://eu.posthog.com");
507
+ expect(h.calls.put).toHaveLength(1);
508
+ });
509
+
510
+ it("non-interactive keyless without a flag still fails not_configured", async () => {
511
+ const h = makeHarness({
512
+ info: connectInfo({ privateHost: null }),
513
+ interactive: false,
514
+ });
515
+ await expectConnectError(
516
+ runConnectPosthog(h.deps, FLOW_DEFAULTS),
517
+ "not_configured",
518
+ );
519
+ expect(h.calls.discover).toHaveLength(0);
520
+ });
521
+
522
+ it("interactive keyless uses the injected selectRegion prompt", async () => {
523
+ const selectRegion = async () => "https://us.posthog.com";
524
+ const h = makeHarness({
525
+ info: connectInfo({ privateHost: null }),
526
+ interactive: true,
527
+ selectRegion,
528
+ });
529
+ const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
530
+
531
+ expect(result.verdict).toBe("connected");
532
+ expect(h.calls.discover).toEqual(["https://us.posthog.com"]);
533
+ expect(result.posthog?.privateHost).toBe("https://us.posthog.com");
534
+ });
535
+ });
536
+
537
+ describe("runConnectPosthog — scope downscope advisory", () => {
538
+ it("prints a note when PostHog grants fewer scopes than requested", async () => {
539
+ // Simulate a downscope: PostHog grants everything except two read scopes.
540
+ const granted = POSTHOG_SCOPES.split(" ")
541
+ .filter((s) => s !== "cohort:read" && s !== "query:read")
542
+ .join(" ");
543
+ const h = makeHarness({ tokens: { ...TOKENS, scope: granted } });
544
+ const result = await runConnectPosthog(h.deps, FLOW_DEFAULTS);
545
+
546
+ expect(result.verdict).toBe("connected");
547
+ const note = h.sink.find((s) => s.includes("PostHog granted"));
548
+ expect(note).toBeDefined();
549
+ expect(note).toContain("cohort:read");
550
+ expect(note).toContain("query:read");
551
+ });
552
+
553
+ it("prints no note when PostHog grants the full requested set", async () => {
554
+ // Default TOKENS.scope === POSTHOG_SCOPES — a full grant.
555
+ const h = makeHarness({});
556
+ await runConnectPosthog(h.deps, FLOW_DEFAULTS);
557
+ expect(h.sink.find((s) => s.includes("PostHog granted"))).toBeUndefined();
558
+ });
559
+ });
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { confirm } from "@clack/prompts";
2
+ import { confirm, select, text } from "@clack/prompts";
3
3
  import { openBrowser } from "../lib/browser.js";
4
4
  import {
5
5
  ConnectError,
@@ -12,7 +12,7 @@ import { color } from "../lib/output.js";
12
12
  import { bail } from "../lib/prompt.js";
13
13
  import type { Command, CommandContext } from "./types.js";
14
14
 
15
- const usage = `hogsend connect <provider> [--provision-only] [--no-provision] [--no-browser] [--json]
15
+ const usage = `hogsend connect <provider> [--posthog-host <url>] [--provision-only] [--no-provision] [--no-browser] [--json]
16
16
 
17
17
  Connect this Hogsend instance to an analytics provider via OAuth. Providers:
18
18
 
@@ -26,6 +26,10 @@ The browser consent must happen on THIS machine (the OAuth callback lands on
26
26
  this command from your laptop, not from an SSH session on the server.
27
27
 
28
28
  Options:
29
+ --posthog-host PostHog app/private host to authorize against, e.g.
30
+ https://eu.posthog.com or https://us.posthog.com (NOT the
31
+ i. ingestion host). Required when the instance has no
32
+ PostHog config and you're running non-interactively.
29
33
  --provision-only Skip OAuth; (re-)provision the event loop using the
30
34
  already-stored credential.
31
35
  --no-provision Stop after storing the credential.
@@ -41,6 +45,7 @@ async function run(ctx: CommandContext): Promise<void> {
41
45
  allowPositionals: true,
42
46
  strict: false,
43
47
  options: {
48
+ "posthog-host": { type: "string" },
44
49
  "provision-only": { type: "boolean", default: false },
45
50
  "no-provision": { type: "boolean", default: false },
46
51
  "no-browser": { type: "boolean", default: false },
@@ -96,6 +101,36 @@ async function run(ctx: CommandContext): Promise<void> {
96
101
  exchangeCode,
97
102
  openBrowser,
98
103
  confirm: async (message) => bail(await confirm({ message })),
104
+ selectRegion: async () => {
105
+ const choice = bail(
106
+ await select({
107
+ message: "Which PostHog region should Hogsend authorize against?",
108
+ options: [
109
+ { value: "https://eu.posthog.com", label: "PostHog EU Cloud" },
110
+ { value: "https://us.posthog.com", label: "PostHog US Cloud" },
111
+ { value: "custom", label: "Custom / self-hosted" },
112
+ ],
113
+ }),
114
+ ) as string;
115
+ if (choice !== "custom") return choice;
116
+ return bail(
117
+ await text({
118
+ message:
119
+ "PostHog app/private host URL (e.g. https://posthog.example.com)",
120
+ placeholder: "https://posthog.example.com",
121
+ validate: (value) => {
122
+ try {
123
+ const url = new URL(value ?? "");
124
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
125
+ return "Enter a full URL, e.g. https://posthog.example.com";
126
+ }
127
+ } catch {
128
+ return "Enter a full URL, e.g. https://posthog.example.com";
129
+ }
130
+ },
131
+ }),
132
+ );
133
+ },
99
134
  now: () => new Date(),
100
135
  };
101
136
 
@@ -104,6 +139,10 @@ async function run(ctx: CommandContext): Promise<void> {
104
139
  provisionOnly: Boolean(values["provision-only"]),
105
140
  noProvision: Boolean(values["no-provision"]),
106
141
  noBrowser: Boolean(values["no-browser"]),
142
+ posthogHost:
143
+ typeof values["posthog-host"] === "string"
144
+ ? values["posthog-host"]
145
+ : undefined,
107
146
  });
108
147
 
109
148
  if (ctx.json) {
@@ -34,6 +34,8 @@ export interface ConnectInfoResponse {
34
34
  personalKeyConfigured: boolean;
35
35
  webhookSecretConfigured: boolean;
36
36
  apiPublicUrl: string;
37
+ /** Expected OAuth scopes missing from the stored credential (advisory). */
38
+ scopeGap?: string[];
37
39
  }
38
40
 
39
41
  /** Loose mirror of POST /v1/admin/analytics/provision-loop's 200 (M10: any 2xx is success, no strict parse). */
@@ -51,7 +53,7 @@ export type ConnectVerdict =
51
53
  | "connected_no_provision"; // credential stored; provision skipped or failed
52
54
 
53
55
  export type ConnectFailure =
54
- | "not_configured" // privateHost null (or US-cloud assumption declined)
56
+ | "not_configured" // privateHost null and no --posthog-host (non-interactive)
55
57
  | "oauth_unsupported" // discovery 404
56
58
  | "discovery_failed"
57
59
  | "port_unavailable"
@@ -61,8 +63,7 @@ export type ConnectFailure =
61
63
  | "exchange_failed"
62
64
  | "store_failed"
63
65
  | "no_credential" // --provision-only with nothing stored
64
- | "webhook_secret_missing"
65
- | "api_public_url_unreachable" // --provision-only without POSTHOG_WEBHOOK_SECRET
66
+ | "api_public_url_unreachable" // instance API_PUBLIC_URL is a loopback address
66
67
  | "provision_failed"; // --provision-only and the POST itself failed
67
68
 
68
69
  export class ConnectError extends Error {
@@ -101,10 +102,7 @@ export interface ConnectResult {
101
102
  | { attempted: true; ok: false; error: string }
102
103
  | {
103
104
  attempted: false;
104
- skipped:
105
- | "webhook_secret_missing"
106
- | "no_provision_flag"
107
- | "api_public_url_unreachable";
105
+ skipped: "no_provision_flag" | "api_public_url_unreachable";
108
106
  };
109
107
  }
110
108
 
@@ -128,6 +126,13 @@ export interface ConnectFlowDeps {
128
126
  openBrowser: (url: string) => boolean;
129
127
  /** bail-wrapped clack confirm; injected so tests never prompt. */
130
128
  confirm: (message: string) => Promise<boolean>;
129
+ /**
130
+ * Resolve the PostHog private/app host (e.g. https://eu.posthog.com) when the
131
+ * instance reports no region. Optional — when absent in interactive mode the
132
+ * flow falls back to {@link ConnectFlowDeps.confirm}. Injected (a bail-wrapped
133
+ * clack select + text) so tests never prompt.
134
+ */
135
+ selectRegion?: () => Promise<string>;
131
136
  now: () => Date;
132
137
  }
133
138
 
@@ -136,14 +141,22 @@ export interface ConnectFlowOptions {
136
141
  noProvision: boolean;
137
142
  noBrowser: boolean;
138
143
  timeoutMs?: number;
144
+ /** --posthog-host: the PostHog private/app host to authorize against. */
145
+ posthogHost?: string;
139
146
  }
140
147
 
141
148
  // --- §7 UX text — exact strings for failure modes / notes ------------------
142
149
 
143
150
  const HINT_NOT_CONFIGURED =
144
- "Set POSTHOG_API_KEY (and POSTHOG_HOST for EU/self-hosted) on the " +
145
- "instance, redeploy, then re-run. The server's PostHog config tells the " +
146
- "CLI which region to authorize against.";
151
+ "Pass --posthog-host https://eu.posthog.com (or https://us.posthog.com, " +
152
+ "or your self-hosted app URL) to pick the region to authorize against. " +
153
+ "Alternatively set POSTHOG_HOST on the instance, redeploy, then re-run.";
154
+
155
+ const POSTHOG_EU_HOST = "https://eu.posthog.com";
156
+ const POSTHOG_US_HOST = "https://us.posthog.com";
157
+
158
+ /** Strip a single trailing slash so origin checks stay exact. */
159
+ const normalizeHost = (host: string): string => host.replace(/\/+$/, "");
147
160
 
148
161
  const hintOauthUnsupported = (privateHost: string): string =>
149
162
  `${privateHost} doesn't advertise an OAuth server (discovery returned 404).
@@ -193,13 +206,6 @@ Once deployed, wire the loop against the real instance:
193
206
 
194
207
  hogsend connect posthog --provision-only --url https://your-instance`;
195
208
 
196
- const WEBHOOK_SECRET_NOTE = `Credential stored — but the PostHog -> Hogsend event loop needs a shared
197
- webhook secret, and this instance doesn't have one yet. Finish the loop:
198
-
199
- 1. Generate a secret: openssl rand -hex 32
200
- 2. Set it on the instance: POSTHOG_WEBHOOK_SECRET=<secret> (api AND worker)
201
- 3. Redeploy, then run: hogsend connect posthog --provision-only`;
202
-
203
209
  // ---------------------------------------------------------------------------
204
210
 
205
211
  const errMsg = (err: unknown): string =>
@@ -253,15 +259,8 @@ async function runProvisionOnly(
253
259
  info: ConnectInfoResponse,
254
260
  base: string,
255
261
  ): Promise<ConnectResult> {
256
- if (info.webhookSecretConfigured === false) {
257
- deps.out.note(WEBHOOK_SECRET_NOTE, "Webhook secret missing");
258
- throw new ConnectError(
259
- "webhook_secret_missing",
260
- "POSTHOG_WEBHOOK_SECRET is not set on the instance — nothing to " +
261
- "provision against",
262
- );
263
- }
264
-
262
+ // The server mints the webhook secret during provisioning, so we no longer
263
+ // gate on webhookSecretConfigured — just proceed to the provision route.
265
264
  if (isLoopbackUrl(info.apiPublicUrl)) {
266
265
  deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
267
266
  throw new ConnectError(
@@ -325,6 +324,47 @@ function printProvisioned(out: Output, result: ProvisionLoopResponse): void {
325
324
  );
326
325
  }
327
326
 
327
+ /**
328
+ * Resolve the PostHog private/app host to authorize against. The server's
329
+ * `connect-info` reports `privateHost` when it has a PostHog config; when it's
330
+ * null (keyless start) the CLI resolves the region client-side:
331
+ *
332
+ * 1. `--posthog-host` flag wins (trailing slash stripped).
333
+ * 2. interactive + a `selectRegion` dep → prompt (EU / US / custom).
334
+ * 3. interactive without `selectRegion` → US confirm, declined → EU.
335
+ * 4. non-interactive without the flag → throw not_configured.
336
+ */
337
+ async function resolvePrivateHost(
338
+ deps: ConnectFlowDeps,
339
+ info: ConnectInfoResponse,
340
+ opts: ConnectFlowOptions,
341
+ ): Promise<string> {
342
+ if (info.privateHost !== null) {
343
+ return info.privateHost;
344
+ }
345
+
346
+ if (opts.posthogHost) {
347
+ return normalizeHost(opts.posthogHost);
348
+ }
349
+
350
+ if (deps.interactive) {
351
+ if (deps.selectRegion) {
352
+ return normalizeHost(await deps.selectRegion());
353
+ }
354
+ const useUs = await deps.confirm(
355
+ `Use PostHog US Cloud (${POSTHOG_US_HOST})? (No selects PostHog EU ` +
356
+ `Cloud, ${POSTHOG_EU_HOST})`,
357
+ );
358
+ return useUs ? POSTHOG_US_HOST : POSTHOG_EU_HOST;
359
+ }
360
+
361
+ throw new ConnectError(
362
+ "not_configured",
363
+ "this instance has no PostHog configuration",
364
+ HINT_NOT_CONFIGURED,
365
+ );
366
+ }
367
+
328
368
  /**
329
369
  * Run the full connect flow (or the `--provision-only` shortcut). Resolves
330
370
  * with a {@link ConnectResult} whenever a credential is stored (even if
@@ -343,20 +383,19 @@ export async function runConnectPosthog(
343
383
  deps.http.get<ConnectInfoResponse>("/v1/admin/analytics/connect-info"),
344
384
  );
345
385
 
346
- const privateHost = info.privateHost;
347
- if (privateHost === null) {
348
- throw new ConnectError(
349
- "not_configured",
350
- "this instance has no PostHog configuration",
351
- HINT_NOT_CONFIGURED,
352
- );
353
- }
354
-
355
386
  if (opts.provisionOnly) {
356
387
  return runProvisionOnly(deps, info, base);
357
388
  }
358
389
 
359
- if (info.hostExplicit === false) {
390
+ // a'. Resolve the region. The server tells us when it has no PostHog config
391
+ // (privateHost null); the CLI then resolves it client-side from the flag
392
+ // or an interactive prompt, so a fresh instance needs no PostHog env vars.
393
+ const privateHost = await resolvePrivateHost(deps, info, opts);
394
+
395
+ // When the server DID report a host but it wasn't explicit (US default),
396
+ // confirm the region. When privateHost was null we resolved it ourselves
397
+ // above, so this US-default reconciliation doesn't apply.
398
+ if (info.privateHost !== null && info.hostExplicit === false) {
360
399
  if (deps.interactive) {
361
400
  const proceed = await deps.confirm(
362
401
  "No POSTHOG_HOST set on the instance — assume PostHog US Cloud " +
@@ -533,6 +572,19 @@ export async function runConnectPosthog(
533
572
  credential: { stored: true, expiresAt },
534
573
  };
535
574
 
575
+ // Advise only when PostHog granted FEWER scopes than we requested THIS run
576
+ // (a downscope) — derived from the grant we just received, not the stale
577
+ // pre-run `info.scopeGap`, so a successful full re-auth prints nothing.
578
+ const requestedScopes = POSTHOG_SCOPES.split(" ");
579
+ const missingScopes = requestedScopes.filter((s) => !scopes.includes(s));
580
+ if (missingScopes.length > 0) {
581
+ deps.out.log(
582
+ `note: PostHog granted ${scopes.length}/${requestedScopes.length} ` +
583
+ `requested scope(s); missing: ${missingScopes.join(", ")}. Re-run ` +
584
+ "`hogsend connect posthog` to grant the full set.",
585
+ );
586
+ }
587
+
536
588
  // g. Provision the PostHog → Hogsend loop (soft: the credential is stored,
537
589
  // so a provisioning failure never fails the command).
538
590
  if (opts.noProvision) {
@@ -543,15 +595,7 @@ export async function runConnectPosthog(
543
595
  };
544
596
  }
545
597
 
546
- if (info.webhookSecretConfigured === false) {
547
- deps.out.note(WEBHOOK_SECRET_NOTE, "Webhook secret missing");
548
- return {
549
- verdict: "connected_no_provision",
550
- ...stored,
551
- provision: { attempted: false, skipped: "webhook_secret_missing" },
552
- };
553
- }
554
-
598
+ // The server mints the webhook secret during provisioning — no skip here.
555
599
  if (isLoopbackUrl(info.apiPublicUrl)) {
556
600
  deps.out.note(LOOPBACK_URL_NOTE, "Instance not publicly reachable");
557
601
  return {
package/src/lib/oauth.ts CHANGED
@@ -24,10 +24,19 @@ export const POSTHOG_CLIENT_ID =
24
24
 
25
25
  /**
26
26
  * LOCKSTEP (M5): must match the `scope` field of the hosted CIMD document
27
- * (`apps/docs/public/.well-known/hogsend-posthog-client.json`).
27
+ * (`apps/docs/public/.well-known/hogsend-posthog-client.json`) AND the
28
+ * engine's `EXPECTED_POSTHOG_SCOPES`
29
+ * (`packages/engine/src/lib/posthog-scopes.ts`). Front-loaded beyond the
30
+ * webhook loop's needs (which is only `hog_function:write` + the minted
31
+ * secret) so future read/write features land without forcing a reconnect.
32
+ * Every name here is validated against PostHog's published
33
+ * `scopes_supported`. Grep all three places before changing.
28
34
  */
29
35
  export const POSTHOG_SCOPES =
30
- "person:read person:write project:read hog_function:write";
36
+ "person:read person:write project:read organization:read " +
37
+ "hog_function:read hog_function:write feature_flag:read cohort:read " +
38
+ "cohort:write query:read insight:read event_definition:read " +
39
+ "property_definition:read";
31
40
 
32
41
  /**
33
42
  * LOCKSTEP (M5): the CIMD document's `redirect_uris` list EXACTLY