@theholocron/holocron-plugin-sentry 5.0.0-alpha.436 → 5.0.0-alpha.437

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/dist/index.d.mts CHANGED
@@ -12,7 +12,10 @@ export declare const resolveComlinkToken: import("@theholocron/cli").FeatureReso
12
12
  * fallback only helps if the general token happens to carry the scopes; otherwise Sentry's answer names what is missing.
13
13
  */
14
14
  export declare function resolveRelayToken(input?: ResolveTokenInput): string;
15
- /** Every token this plugin takes, for `holocron doctor secrets --verify`: the general one and the Comlink relay's own. */
15
+ /**
16
+ * Every token this plugin takes, for `holocron doctor secrets --verify`, each with what its layer's token must and must not be
17
+ * able to do: the general one, the Comlink relay's own and the CI release token.
18
+ */
16
19
  export declare const tokens: PluginToken[];
17
20
  /** Options for the Sentry `errors` capability. */
18
21
  interface SentryErrorsOptions {
@@ -97,6 +100,7 @@ export declare function errors(ctx: PluginContext): Errors;
97
100
  export declare function createPlugin(options: SentryPluginOptions): {
98
101
  name: string;
99
102
  probes: {
103
+ token: import("@theholocron/cli").Probe;
100
104
  connection: import("@theholocron/cli").Probe;
101
105
  };
102
106
  capabilities: {
package/dist/index.mjs CHANGED
@@ -1,8 +1,45 @@
1
- import { AuthError, ProviderApiError, connectionProbe, createFeatureResolver, createResolveToken } from "@theholocron/cli";
1
+ import { AuthError, ProviderApiError, connectionProbe, createFeatureResolver, createResolveToken, tokenExpectations } from "@theholocron/cli";
2
2
  import { createEnvLookup, holocronEnv } from "@theholocron/env-utils/lookup";
3
3
  import { randomUUID } from "node:crypto";
4
- import { createSentryClient } from "@theholocron/sentry-client";
5
4
  import { errorMessage } from "@theholocron/misc-utils";
5
+ import { createSentryClient } from "@theholocron/sentry-client";
6
+ // src/probes/token/expectations.ts
7
+ const READS = {
8
+ org: "required",
9
+ projects: "required"
10
+ };
11
+ /**
12
+ * The general token provisions projects and their DSNs and lives on a laptop (`local` inherits `dev`): it needs
13
+ * `org:read` and `project:read`. `project:write` has no read that proves it, so it is not probed; the permission table
14
+ * in `docs/src/content/docs/tokens.mdx` lists it. This is the plugin's `tokens` declaration, read through
15
+ * `tokenExpectations()`; change it and that table together.
16
+ */
17
+ const GENERAL = { permissions: {
18
+ local: READS,
19
+ dev: READS
20
+ } };
21
+ const RELAY_READS = {
22
+ org: "required",
23
+ projects: "required",
24
+ alerts: "required"
25
+ };
26
+ /**
27
+ * The Comlink relay's token makes an integration and an alert rule, so on top of the general token's reads it needs
28
+ * `alerts:read`. Its write scopes (`org:write`, `alerts:write`) and `event:read` are not probed, as no read proves them.
29
+ */
30
+ const COMLINK = { permissions: {
31
+ local: RELAY_READS,
32
+ dev: RELAY_READS
33
+ } };
34
+ /**
35
+ * The release token only creates a release and uploads sourcemaps (an organization token with `org:ci`), so it must not
36
+ * read the organization, its projects or its alert rules: a leak from CI cannot reach them.
37
+ */
38
+ const RELEASE = { permissions: { ci: {
39
+ org: "absent",
40
+ projects: "absent",
41
+ alerts: "absent"
42
+ } } };
6
43
  // src/auth.ts
7
44
  /** Resolves the Sentry token from `--token`, `HOLOCRON_SENTRY_TOKEN`, `SENTRY_AUTH_TOKEN` or the keyring, and throws an `AuthError` when none is set. */
8
45
  const resolveToken = createResolveToken({
@@ -32,8 +69,34 @@ function resolveRelayToken(input = {}) {
32
69
  return resolveToken(input);
33
70
  }
34
71
  }
35
- /** Every token this plugin takes, for `holocron doctor secrets --verify`: the general one and the Comlink relay's own. */
36
- const tokens = [resolveToken.token, resolveComlinkToken.token];
72
+ /**
73
+ * The token only `release.yml` reads (sentry-cli makes the release and uploads sourcemaps): an organization token with
74
+ * `org:ci` held in the `ci` layer. The plugin never resolves it, so it is `optional` and `verifiable: false` (the
75
+ * plugin's `verifyToken` lists organizations, which this token must not be able to do); it is declared so the probe and
76
+ * `holocron probe sentry token --environment ci` can find it and check that it reaches nothing else.
77
+ */
78
+ const releaseToken = {
79
+ envName: "HOLOCRON_SENTRY_RELEASE_TOKEN",
80
+ keyringKey: "sentry.release",
81
+ optional: true,
82
+ verifiable: false,
83
+ expect: RELEASE
84
+ };
85
+ /**
86
+ * Every token this plugin takes, for `holocron doctor secrets --verify`, each with what its layer's token must and must not be
87
+ * able to do: the general one, the Comlink relay's own and the CI release token.
88
+ */
89
+ const tokens = [
90
+ {
91
+ ...resolveToken.token,
92
+ expect: GENERAL
93
+ },
94
+ {
95
+ ...resolveComlinkToken.token,
96
+ expect: COMLINK
97
+ },
98
+ releaseToken
99
+ ];
37
100
  // src/capabilities/relay.ts
38
101
  /** What the integration needs to read: the alert's event. It is also why reading its secret needs the same scope. */
39
102
  const INTEGRATION_SCOPES = ["event:read"];
@@ -575,6 +638,163 @@ function sentryProbes(deps) {
575
638
  "send-error": sendErrorProbe(deps)
576
639
  };
577
640
  }
641
+ // src/probes/token/parse-token-args.ts
642
+ /** Reads `--layer <word>` and `--token-name <VAR>` (either spelling). Anything else, and a flag with no value, is ignored. */
643
+ function parseTokenArgs(args) {
644
+ let layer;
645
+ let tokenName;
646
+ for (let i = 0; i < args.length; i++) {
647
+ const arg = args[i];
648
+ if (arg === "--layer") layer = args[++i];
649
+ else if (arg.startsWith("--layer=")) layer = arg.slice(8);
650
+ else if (arg === "--token-name") tokenName = args[++i];
651
+ else if (arg.startsWith("--token-name=")) tokenName = arg.slice(13);
652
+ }
653
+ return {
654
+ ...layer ? { layer } : {},
655
+ ...tokenName ? { tokenName } : {}
656
+ };
657
+ }
658
+ // src/probes/token/token.ts
659
+ /** How many names to show before "(+N more)". */
660
+ const SHOWN = 5;
661
+ /** `a, b, c (+2 more)`. */
662
+ function summarise(names) {
663
+ return names.length <= SHOWN ? names.join(", ") : `${names.slice(0, SHOWN).join(", ")} (+${names.length - SHOWN} more)`;
664
+ }
665
+ /**
666
+ * Reads one list and says what came back. Sentry answers a token without the scope with a 401 or 403, and an empty
667
+ * list may only mean there are none, so an empty list is `none` too. Any other failure (the network, a 404, a 5xx) is an
668
+ * `error`.
669
+ */
670
+ async function reach(read, noun) {
671
+ try {
672
+ const items = await read();
673
+ if (items.length === 0) return {
674
+ kind: "none",
675
+ detail: `no ${noun} visible (none exist, or the token cannot see them)`
676
+ };
677
+ const names = items.map((i) => i.slug ?? i.name).filter((n) => typeof n === "string");
678
+ return {
679
+ kind: "has",
680
+ detail: names.length > 0 ? `${items.length} ${noun}: ${summarise(names)}` : `${items.length} ${noun}`
681
+ };
682
+ } catch (err) {
683
+ if (err instanceof ProviderApiError && (err.status === 401 || err.status === 403)) return {
684
+ kind: "none",
685
+ detail: `no access (${err.status})`
686
+ };
687
+ return {
688
+ kind: "error",
689
+ detail: errorMessage(err)
690
+ };
691
+ }
692
+ }
693
+ /**
694
+ * One row for what was tried. With no rule it only describes (`ok` for access, `info` for none). Under a layer's rule it
695
+ * judges: a required permission the token lacks fails, and one the layer must not have fails when the token has it; a
696
+ * permission that is rightly absent is a bare `ok`.
697
+ */
698
+ function rowFor({ label, outcome }, rule) {
699
+ if (outcome.kind === "error") return {
700
+ status: "fail",
701
+ label,
702
+ detail: outcome.detail
703
+ };
704
+ const has = outcome.kind === "has";
705
+ if (rule === "required") return has ? {
706
+ status: "ok",
707
+ label,
708
+ detail: outcome.detail
709
+ } : {
710
+ status: "fail",
711
+ label,
712
+ detail: outcome.detail
713
+ };
714
+ if (rule === "absent") return has ? {
715
+ status: "fail",
716
+ label,
717
+ detail: outcome.detail
718
+ } : {
719
+ status: "ok",
720
+ label
721
+ };
722
+ return {
723
+ status: has ? "ok" : "info",
724
+ label,
725
+ detail: outcome.detail
726
+ };
727
+ }
728
+ /**
729
+ * The `token` probe: reads what Holocron reads from Sentry (the organizations, the projects and the alert rules) with a
730
+ * token and reports which of them it reaches. It only reads, so it changes nothing. The token is the one the plugin
731
+ * resolves, so `--token sentry=<token>` or `--environment <layer>` checks another one; the organization is the
732
+ * provider's `org` option. A write scope (`project:write`, `org:write`, `alerts:write`) has no read that proves it, so it
733
+ * is not probed.
734
+ *
735
+ * A token with no layer is only described. When the CLI passes `--layer` (because `--environment` named `ci`, `dev` or
736
+ * `local`), the results are judged against what that layer's token is meant to carry (the plugin's `tokens`
737
+ * declaration, read with `tokenExpectations()` and narrowed to the token `--token-name` names, which the CLI passes for
738
+ * each declared token the Environment holds) and shown in two groups, what it should have and what it should not, where
739
+ * a missing or extra permission fails.
740
+ */
741
+ function tokenProbe(ctx) {
742
+ return {
743
+ summary: "Try each Sentry read Holocron uses (organizations, projects, alert rules) with a token and report which it reaches; for a ci or dev Environment, check it against what that layer should carry. Read-only.",
744
+ writes: false,
745
+ acceptsToken: true,
746
+ async run(probe) {
747
+ const { layer, tokenName } = parseTokenArgs(probe.args ?? []);
748
+ const token = ctx.token();
749
+ probe.secret(token);
750
+ const client = ctx.client();
751
+ const rules = layer ? tokenExpectations(tokens, {
752
+ layer,
753
+ ...tokenName ? { envName: tokenName } : {}
754
+ })[0]?.rules : void 0;
755
+ if (layer && !rules) probe.say({
756
+ status: "info",
757
+ label: layer,
758
+ detail: "no expectations are declared for it, so the token is only described"
759
+ });
760
+ const tried = [{
761
+ label: "Organizations: Read",
762
+ permission: "org",
763
+ outcome: await reach(() => client.auth.organizations(), "organizations")
764
+ }];
765
+ const org = ctx.org;
766
+ if (org) {
767
+ tried.push({
768
+ label: "Projects: Read",
769
+ permission: "projects",
770
+ outcome: await reach(() => client.projects.list(org), "projects")
771
+ });
772
+ tried.push({
773
+ label: "Alert rules: Read",
774
+ permission: "alerts",
775
+ outcome: await reach(() => client.workflows.list(org), "alert rules")
776
+ });
777
+ } else probe.say({
778
+ status: rules ? "fail" : "info",
779
+ label: "projects and alert rules",
780
+ detail: "no org: set `org` in the sentry provider's options to check them"
781
+ });
782
+ if (!rules) {
783
+ for (const t of tried) probe.say(rowFor(t, void 0));
784
+ return;
785
+ }
786
+ for (const [heading, rule] of [["Should have", "required"], ["Should not have", "absent"]]) {
787
+ const inGroup = tried.filter((t) => rules[t.permission] === rule);
788
+ if (inGroup.length === 0) continue;
789
+ probe.say({
790
+ status: "heading",
791
+ label: heading
792
+ });
793
+ for (const t of inGroup) probe.say(rowFor(t, rule));
794
+ }
795
+ }
796
+ };
797
+ }
578
798
  // src/verify-token.ts
579
799
  /** Checks a Sentry token by listing its organizations; the success subject is `org:` and the first organization's slug, the failure carries the error message. */
580
800
  async function verifyToken(token, opts = {}) {
@@ -632,6 +852,11 @@ function createPlugin(options) {
632
852
  client: ctx.relayClient,
633
853
  fetch: options.fetch
634
854
  }),
855
+ token: tokenProbe({
856
+ org: options.org,
857
+ client: ctx.client,
858
+ token: () => resolveToken(options)
859
+ }),
635
860
  connection: connectionProbe({
636
861
  provider: "Sentry",
637
862
  url: options.baseUrl ?? "https://sentry.io",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/holocron-plugin-sentry",
3
- "version": "5.0.0-alpha.436",
3
+ "version": "5.0.0-alpha.437",
4
4
  "description": "Holocron plugin for Sentry. Implements the errors capability against Sentry's management API — project provisioning and DSN retrieval.",
5
5
  "keywords": [
6
6
  "errors",
@@ -45,18 +45,18 @@
45
45
  "typescript": "^5.9.3",
46
46
  "vitest": "^5.1.0",
47
47
  "@theholocron/eslint-config": "^8.9.0-alpha.17",
48
- "@theholocron/http-client": "^1.58.0-alpha.32",
48
+ "@theholocron/cli": "5.0.0-alpha.437",
49
49
  "@theholocron/misc-utils": "^1.17.0-alpha.4",
50
50
  "@theholocron/sentry-client": "^1.58.0-alpha.32",
51
51
  "@theholocron/tsconfig": "^8.9.0-alpha.17",
52
52
  "@theholocron/tsdown-config": "^8.9.0-alpha.17",
53
53
  "@theholocron/vitest-config": "^8.9.0-alpha.17",
54
- "@theholocron/cli": "5.0.0-alpha.436"
54
+ "@theholocron/http-client": "^1.58.0-alpha.32"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@theholocron/misc-utils": "^1.16.0",
58
58
  "@theholocron/sentry-client": "^1.56.0",
59
- "@theholocron/cli": "5.0.0-alpha.436"
59
+ "@theholocron/cli": "5.0.0-alpha.437"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
62
  "@theholocron/sentry-client": {}