diff --git a/packages/docs/src/content/docs/extend/sentry-plugin.md b/packages/docs/src/content/docs/extend/sentry-plugin.md
index b520d4df24..185a36733f 100644
--- a/packages/docs/src/content/docs/extend/sentry-plugin.md
+++ b/packages/docs/src/content/docs/extend/sentry-plugin.md
@@ -83,6 +83,28 @@ OAuth client secret used when a user connects their Sentry account.
+
+SENTRY_APP_CLIENT_ID
+
+Client ID of the Sentry public integration that each org installs.
+
+- **Define:** Set `SENTRY_APP_CLIENT_ID` in the deployment environment
+- **Required:** Yes for org installations; otherwise no
+- **Environment override:** `SENTRY_APP_CLIENT_ID`
+
+
+
+
+SENTRY_APP_CLIENT_SECRET
+
+Client secret of the Sentry public integration. Junior uses it to verify the integration's webhooks and to get installation tokens.
+
+- **Define:** Set `SENTRY_APP_CLIENT_SECRET` in the deployment environment
+- **Required:** Yes for org installations; otherwise no
+- **Environment override:** `SENTRY_APP_CLIENT_SECRET`
+
+
+
SENTRY_READ_TOKEN
@@ -137,6 +159,24 @@ Junior uses the token for `GET`, `HEAD`, and `OPTIONS` requests to the Sentry AP
Anyone who can talk to Junior can read every project the token can read. Sentry audit logs show the integration, not the Slack user.
+## Set up org installations
+
+An internal integration reads only one org. To read more than one org, use a **Sentry public integration** that each org installs. Keep it unpublished: then only the org that owns it, or a Sentry superuser, can install it.
+
+1. Create a public integration in the org that owns Junior.
+2. Grant read-only permissions for **Project**, **Issue & Event**, **Organization**, and **Release**.
+3. Set the webhook URL to `https:///api/webhooks/sentry`. Do not set a redirect URL, and leave **Verify Install** off.
+4. Set `SENTRY_APP_CLIENT_ID` and `SENTRY_APP_CLIENT_SECRET` to the integration's credentials.
+5. Redeploy Junior, then install the integration in each org.
+
+Sentry sends an `installation` webhook when an org installs the integration. Junior saves the org and gets the first token from that webhook. Sentry does not send the webhook again, so if Junior misses it, uninstall and install the integration again.
+
+Junior reads the org from the Sentry API path, such as `/api/0/organizations//`, `/api/0/projects///`, or `/api/0/teams///`, and uses that org's installation token. Junior denies reads of an org that has no installation. A path with no org, such as `/api/0/issues//`, uses the only installation. When more than one org installed the integration, use the org-scoped path instead.
+
+When `SENTRY_READ_TOKEN` is also set, Junior uses it for every read that no installation covers. Use this while you move from the internal integration to the public integration.
+
+An event automation without an identifier runs for every installed org. Its `projects` filter matches the same project slug in each org.
+
## Set up issue webhooks
Create a **Sentry internal integration** in the organization that should send issue webhooks. A public Sentry app is not required.
@@ -262,7 +302,8 @@ Create the watch or event automation before the webhook arrives. Junior does not
- **Sentry reports a missing scope:** Reconnect Sentry to grant the current scopes.
- **Sentry returns `403`:** Connect an account with access to the requested organization and project. With org read access, also check the integration's read permissions.
- **A scheduled task cannot read Sentry:** Set `SENTRY_READ_TOKEN`. Confirm the request targets the organization that installed the integration.
-- **Webhooks are ignored:** Check `SENTRY_WEBHOOK_SECRET`, then confirm a matching watch or event automation exists. For metric alerts, also confirm the integration is a trigger action on the alert.
+- **Junior is not installed in the Sentry org:** Install the public integration in that org. If it is installed but Junior still denies the read, uninstall and install it again.
+- **Webhooks are ignored:** Check `SENTRY_WEBHOOK_SECRET` or `SENTRY_APP_CLIENT_SECRET`, then confirm a matching watch or event automation exists. For metric alerts, also confirm the integration is a trigger action on the alert.
- **Authorization links use the wrong host:** Set `JUNIOR_BASE_URL` to Junior's public URL.
## Next step
diff --git a/packages/junior-sentry/drizzle.config.ts b/packages/junior-sentry/drizzle.config.ts
new file mode 100644
index 0000000000..15a62b834c
--- /dev/null
+++ b/packages/junior-sentry/drizzle.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "drizzle-kit";
+
+export default defineConfig({
+ dialect: "postgresql",
+ out: "./migrations",
+ schema: "./src/db/schema.ts",
+});
diff --git a/packages/junior-sentry/migrations/0000_sentry_installations.sql b/packages/junior-sentry/migrations/0000_sentry_installations.sql
new file mode 100644
index 0000000000..3b2f071ace
--- /dev/null
+++ b/packages/junior-sentry/migrations/0000_sentry_installations.sql
@@ -0,0 +1,6 @@
+CREATE TABLE "junior_sentry_installations" (
+ "installation_uuid" text PRIMARY KEY NOT NULL,
+ "org_slug" text,
+ "updated_at" timestamp with time zone NOT NULL,
+ CONSTRAINT "junior_sentry_installations_org_slug_unique" UNIQUE("org_slug")
+);
diff --git a/packages/junior-sentry/migrations/meta/0000_snapshot.json b/packages/junior-sentry/migrations/meta/0000_snapshot.json
new file mode 100644
index 0000000000..ea80f19c9d
--- /dev/null
+++ b/packages/junior-sentry/migrations/meta/0000_snapshot.json
@@ -0,0 +1,56 @@
+{
+ "id": "defa7480-9855-45ca-8b89-983965cd7b50",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.junior_sentry_installations": {
+ "name": "junior_sentry_installations",
+ "schema": "",
+ "columns": {
+ "installation_uuid": {
+ "name": "installation_uuid",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_slug": {
+ "name": "org_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "junior_sentry_installations_org_slug_unique": {
+ "name": "junior_sentry_installations_org_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": ["org_slug"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/junior-sentry/migrations/meta/_journal.json b/packages/junior-sentry/migrations/meta/_journal.json
new file mode 100644
index 0000000000..b64177c791
--- /dev/null
+++ b/packages/junior-sentry/migrations/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1793654458568,
+ "tag": "0000_sentry_installations",
+ "breakpoints": true
+ }
+ ]
+}
diff --git a/packages/junior-sentry/package.json b/packages/junior-sentry/package.json
index f67a7420d4..20095947fc 100644
--- a/packages/junior-sentry/package.json
+++ b/packages/junior-sentry/package.json
@@ -23,10 +23,12 @@
},
"files": [
"dist",
+ "migrations",
"skills"
],
"scripts": {
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
+ "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts",
"lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts vitest.config.ts",
"prepare": "pnpm run build",
"prepack": "pnpm run build",
@@ -35,12 +37,14 @@
},
"dependencies": {
"@sentry/junior-plugin-api": "workspace:*",
+ "drizzle-orm": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@oxlint/plugins": "1.79.0",
"@sentry/junior-testing": "workspace:*",
"@types/node": "^25.9.1",
+ "drizzle-kit": "catalog:",
"oxlint": "^1.79.0",
"tsup": "^8.5.1",
"typescript": "^7.0.2",
diff --git a/packages/junior-sentry/src/db/database.ts b/packages/junior-sentry/src/db/database.ts
new file mode 100644
index 0000000000..3ca3d1822c
--- /dev/null
+++ b/packages/junior-sentry/src/db/database.ts
@@ -0,0 +1,5 @@
+import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
+import * as sentrySqlSchema from "./schema.js";
+
+/** Database contract for Sentry-owned installation records. */
+export type SentryDb = PgDatabase;
diff --git a/packages/junior-sentry/src/db/schema.ts b/packages/junior-sentry/src/db/schema.ts
new file mode 100644
index 0000000000..5ffa4cfcd3
--- /dev/null
+++ b/packages/junior-sentry/src/db/schema.ts
@@ -0,0 +1,27 @@
+/**
+ * Drizzle source of truth for Sentry plugin SQL migrations.
+ *
+ * Update this schema first, then regenerate packaged migrations with
+ * `pnpm --filter @sentry/junior-sentry db:generate`.
+ */
+import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
+
+/**
+ * Installations of Junior's Sentry public integration. A Sentry org has at
+ * most one installation of an integration, so the org slug finds the
+ * installation for a request. `installation` webhooks write the rows. Sentry
+ * has no API that lists them again, so this table is the only record.
+ */
+export const juniorSentryInstallations = pgTable(
+ "junior_sentry_installations",
+ {
+ installationUuid: text("installation_uuid").primaryKey(),
+ /**
+ * Lowercase slug of the org that installed the integration. It is null
+ * after the integration is uninstalled. The row stays, so a late retry
+ * of the install webhook cannot add the installation again.
+ */
+ orgSlug: text("org_slug").unique(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+ },
+);
diff --git a/packages/junior-sentry/src/index.ts b/packages/junior-sentry/src/index.ts
index d1e32ee6b3..d10c75dc35 100644
--- a/packages/junior-sentry/src/index.ts
+++ b/packages/junior-sentry/src/index.ts
@@ -1,9 +1,10 @@
/**
* Sentry plugin runtime boundary.
*
- * This package owns per-user Sentry OAuth, org read access, CLI setup,
- * internal-integration issue and metric alert webhook normalization, and
- * Sentry resource identities. Junior core owns watches and event automations.
+ * This package owns per-user Sentry OAuth, org read access, installations
+ * of the public integration, CLI setup, issue and metric alert webhook
+ * normalization, and Sentry resource identities. Junior core owns watches
+ * and event automations.
*/
import {
defineJuniorPlugin,
@@ -25,6 +26,7 @@ import {
issueSentryCredential,
sentryGrantForEgress,
} from "./org-read.js";
+import { sentryAppConfig } from "./installations/tokens.js";
import { resolveSentryOAuthAccount } from "./oauth-account.js";
import { createSentryWebhookRoute } from "./webhooks/handler.js";
import { sentryWebhookSecret } from "./webhooks/secret.js";
@@ -64,15 +66,17 @@ export function sentryPlugin(): PluginRegistration {
matchFields: SENTRY_METRIC_ALERT_MATCH_FIELDS,
},
],
- // One install is one Sentry org. Issue and metric alert events carry
- // alert facts, and Junior adds nothing to Sentry, so they can run for
- // every project. Use the `projects` filter to narrow them.
+ // Issue and metric alert events carry alert facts, and Junior adds
+ // nothing to Sentry, so they can run for every project. Use the
+ // `projects` filter to narrow them. They also run for every installed
+ // org, so install the integration in a second org only after events
+ // get an `org` filter.
events: Object.fromEntries(
[...SENTRY_ISSUE_EVENTS, ...SENTRY_METRIC_ALERT_EVENTS].map(
(eventType) => [eventType, { reach: "all" }],
),
),
- isEnabled: () => Boolean(sentryWebhookSecret()),
+ isEnabled: () => Boolean(sentryWebhookSecret() || sentryAppConfig()),
normalizeIdentifier: (identifier) => identifier.toLowerCase(),
},
manifest: {
@@ -90,6 +94,8 @@ export function sentryPlugin(): PluginRegistration {
"Investigate Sentry telemetry, manage projects, teams, and alerting, and receive issue and metric alert webhooks",
displayName: "Sentry",
envVars: {
+ SENTRY_APP_CLIENT_ID: {},
+ SENTRY_APP_CLIENT_SECRET: {},
SENTRY_CLIENT_ID: {},
SENTRY_CLIENT_SECRET: {},
SENTRY_READ_TOKEN: {},
@@ -119,6 +125,8 @@ export function sentryPlugin(): PluginRegistration {
routes(ctx) {
return [
createSentryWebhookRoute({
+ appConfig: sentryAppConfig,
+ ctx,
events: ctx.events,
webhookSecret: sentryWebhookSecret,
}),
diff --git a/packages/junior-sentry/src/installations/store.ts b/packages/junior-sentry/src/installations/store.ts
new file mode 100644
index 0000000000..8e49c3e0fc
--- /dev/null
+++ b/packages/junior-sentry/src/installations/store.ts
@@ -0,0 +1,102 @@
+import type { PluginContext } from "@sentry/junior-plugin-api";
+import { and, eq, isNotNull, ne } from "drizzle-orm";
+import type { SentryDb } from "../db/database.js";
+import { juniorSentryInstallations } from "../db/schema.js";
+
+const table = juniorSentryInstallations;
+const INSTALLATIONS_CACHE_KEY = "installations";
+// Install webhooks refresh the cache. The TTL only repairs a missed refresh.
+const INSTALLATIONS_CACHE_MS = 5 * 60 * 1000;
+
+/** One active installation of the public integration and its org. */
+export interface SentryInstallation {
+ installationUuid: string;
+ /** Org slug. It is stored in lowercase. */
+ orgSlug: string;
+}
+
+async function listSentryInstallations(
+ db: SentryDb,
+): Promise {
+ const rows = await db
+ .select({
+ installationUuid: table.installationUuid,
+ orgSlug: table.orgSlug,
+ })
+ .from(table)
+ .where(isNotNull(table.orgSlug));
+ return rows.flatMap((row) =>
+ row.orgSlug
+ ? [{ installationUuid: row.installationUuid, orgSlug: row.orgSlug }]
+ : [],
+ );
+}
+
+/**
+ * Read every active installation through the plugin cache, because egress
+ * reads them for each Sentry API request. Pass `refresh` after a write.
+ */
+export async function activeSentryInstallations(
+ ctx: Pick,
+ options?: { refresh?: boolean },
+): Promise {
+ return await ctx.cache.getOrLoad(
+ INSTALLATIONS_CACHE_KEY,
+ async () => ({
+ value: await listSentryInstallations(ctx.db as SentryDb),
+ ttlMs: INSTALLATIONS_CACHE_MS,
+ }),
+ options,
+ );
+}
+
+/**
+ * Save one installation from an `installation.created` webhook, and return
+ * true when it is new. A Sentry retry of the same webhook changes nothing,
+ * and a removed installation is never added again. An older row for the
+ * same org is stale, such as after a missed uninstall, so it is removed.
+ */
+export async function saveSentryInstallation(
+ db: SentryDb,
+ installation: SentryInstallation,
+): Promise {
+ const orgSlug = installation.orgSlug.toLowerCase();
+ return await db.transaction(async (tx) => {
+ await tx
+ .delete(table)
+ .where(
+ and(
+ eq(table.orgSlug, orgSlug),
+ ne(table.installationUuid, installation.installationUuid),
+ ),
+ );
+ const inserted = await tx
+ .insert(table)
+ .values({
+ installationUuid: installation.installationUuid,
+ orgSlug,
+ updatedAt: new Date(),
+ })
+ .onConflictDoNothing({ target: table.installationUuid })
+ .returning({ installationUuid: table.installationUuid });
+ return inserted.length > 0;
+ });
+}
+
+/**
+ * Mark an installation as removed after an `installation.deleted` webhook.
+ * The row stays without an org, so a late install retry cannot add it again.
+ */
+export async function removeSentryInstallation(
+ db: SentryDb,
+ installationUuid: string,
+): Promise {
+ const updatedAt = new Date();
+ await db
+ .insert(table)
+ .values({ installationUuid, orgSlug: null, updatedAt })
+ .onConflictDoUpdate({
+ target: table.installationUuid,
+ set: { orgSlug: null, updatedAt },
+ });
+}
diff --git a/packages/junior-sentry/src/installations/tokens.ts b/packages/junior-sentry/src/installations/tokens.ts
new file mode 100644
index 0000000000..bf666bb4b9
--- /dev/null
+++ b/packages/junior-sentry/src/installations/tokens.ts
@@ -0,0 +1,138 @@
+/**
+ * Tokens for installations of Junior's Sentry public integration.
+ *
+ * Sentry gives the first token for a grant code from the install webhook.
+ * After that, Junior mints a new token with a JWT signed with the client
+ * secret, so it never stores a refresh token. Each mint deletes the previous
+ * token of the installation, so the plugin cache shares one token across
+ * instances and its load lock serializes mints.
+ */
+import { createHmac, randomUUID } from "node:crypto";
+import type { PluginCache } from "@sentry/junior-plugin-api";
+import { z } from "zod";
+
+/** Client credentials of the public integration. */
+export interface SentryAppConfig {
+ clientId: string;
+ clientSecret: string;
+}
+
+/** One installation token and when Sentry expires it. */
+export interface SentryInstallationToken {
+ expiresAtMs: number;
+ token: string;
+}
+
+// Tokens last 8 hours. Mint a new one well before that, so a request never
+// sends a token that expires on the way.
+const TOKEN_REFRESH_MARGIN_MS = 30 * 60 * 1000;
+const JWT_LIFETIME_SECONDS = 60;
+const JWT_BEARER_GRANT = "urn:sentry:params:oauth:grant-type:jwt-bearer";
+
+const tokenResponseSchema = z.object({
+ expiresAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
+ token: z.string().min(1),
+});
+
+/** Read the public integration client credentials, when configured. */
+export function sentryAppConfig(): SentryAppConfig | undefined {
+ const clientId = process.env.SENTRY_APP_CLIENT_ID?.trim();
+ const clientSecret = process.env.SENTRY_APP_CLIENT_SECRET?.trim();
+ return clientId && clientSecret ? { clientId, clientSecret } : undefined;
+}
+
+function authorizationsUrl(installationUuid: string): string {
+ return `https://sentry.io/api/0/sentry-app-installations/${encodeURIComponent(installationUuid)}/authorizations/`;
+}
+
+function base64url(value: string): string {
+ return Buffer.from(value).toString("base64url");
+}
+
+/** Sign the short-lived client JWT that Sentry accepts for a token mint. */
+function clientJwt(config: SentryAppConfig): string {
+ const now = Math.floor(Date.now() / 1000);
+ const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
+ const claims = base64url(
+ JSON.stringify({
+ exp: now + JWT_LIFETIME_SECONDS,
+ iat: now,
+ iss: config.clientId,
+ jti: randomUUID(),
+ sub: config.clientId,
+ }),
+ );
+ const signature = createHmac("sha256", config.clientSecret)
+ .update(`${header}.${claims}`)
+ .digest("base64url");
+ return `${header}.${claims}.${signature}`;
+}
+
+async function requestToken(
+ installationUuid: string,
+ init: { body: Record; headers?: Record },
+): Promise {
+ const response = await fetch(authorizationsUrl(installationUuid), {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...init.headers },
+ body: JSON.stringify(init.body),
+ });
+ if (!response.ok) {
+ throw new Error(
+ `Sentry rejected the token request for installation ${installationUuid} with HTTP ${response.status}.`,
+ );
+ }
+ const parsed = tokenResponseSchema.parse(await response.json());
+ return { expiresAtMs: Date.parse(parsed.expiresAt), token: parsed.token };
+}
+
+function cacheEntry(token: SentryInstallationToken) {
+ const ttlMs = token.expiresAtMs - Date.now() - TOKEN_REFRESH_MARGIN_MS;
+ return { value: token, ...(ttlMs > 0 ? { ttlMs } : undefined) };
+}
+
+function tokenCacheKey(installationUuid: string): string {
+ return `installation-token:${installationUuid}`;
+}
+
+/**
+ * Exchange the grant code from an install webhook for the first token, and
+ * keep it in the cache for egress.
+ */
+export async function exchangeSentryGrantCode(
+ cache: PluginCache,
+ config: SentryAppConfig,
+ installation: { code: string; installationUuid: string },
+): Promise {
+ await cache.getOrLoad(
+ tokenCacheKey(installation.installationUuid),
+ async () =>
+ cacheEntry(
+ await requestToken(installation.installationUuid, {
+ body: {
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ code: installation.code,
+ grant_type: "authorization_code",
+ },
+ }),
+ ),
+ { refresh: true },
+ );
+}
+
+/** Return a current token for one installation, and mint one when needed. */
+export async function sentryInstallationToken(
+ cache: PluginCache,
+ config: SentryAppConfig,
+ installationUuid: string,
+): Promise {
+ return await cache.getOrLoad(tokenCacheKey(installationUuid), async () =>
+ cacheEntry(
+ await requestToken(installationUuid, {
+ body: { grant_type: JWT_BEARER_GRANT },
+ headers: { Authorization: `Bearer ${clientJwt(config)}` },
+ }),
+ ),
+ );
+}
diff --git a/packages/junior-sentry/src/installations/webhook.ts b/packages/junior-sentry/src/installations/webhook.ts
new file mode 100644
index 0000000000..13c55f8c83
--- /dev/null
+++ b/packages/junior-sentry/src/installations/webhook.ts
@@ -0,0 +1,64 @@
+import type { PluginContext } from "@sentry/junior-plugin-api";
+import { z } from "zod";
+import type { SentryDb } from "../db/database.js";
+import {
+ activeSentryInstallations,
+ removeSentryInstallation,
+ saveSentryInstallation,
+} from "./store.js";
+import { exchangeSentryGrantCode, type SentryAppConfig } from "./tokens.js";
+
+const installationWebhookSchema = z.discriminatedUnion("action", [
+ z.object({
+ action: z.literal("created"),
+ data: z.object({
+ installation: z.object({
+ code: z.string().min(1),
+ organization: z.object({ slug: z.string().min(1) }),
+ uuid: z.string().min(1),
+ }),
+ }),
+ }),
+ z.object({
+ action: z.literal("deleted"),
+ data: z.object({
+ installation: z.object({ uuid: z.string().min(1) }),
+ }),
+ }),
+]);
+
+/**
+ * Apply one verified `installation` webhook of the public integration.
+ * Return false for a payload that this plugin does not handle.
+ *
+ * Sentry sends the grant code only here, so a new installation saves its org
+ * and exchanges the code before Junior acknowledges the webhook. The code
+ * works once, so a retry of the same webhook does not exchange it again.
+ */
+export async function handleSentryInstallationWebhook(
+ ctx: Pick,
+ config: SentryAppConfig,
+ body: unknown,
+): Promise {
+ const parsed = installationWebhookSchema.safeParse(body);
+ if (!parsed.success) return false;
+ const { installation } = parsed.data.data;
+ const db = ctx.db as SentryDb;
+ if (parsed.data.action === "created") {
+ const { code, organization } = parsed.data.data.installation;
+ const added = await saveSentryInstallation(db, {
+ installationUuid: installation.uuid,
+ orgSlug: organization.slug,
+ });
+ if (added) {
+ await exchangeSentryGrantCode(ctx.cache, config, {
+ code,
+ installationUuid: installation.uuid,
+ });
+ }
+ } else {
+ await removeSentryInstallation(db, installation.uuid);
+ }
+ await activeSentryInstallations(ctx, { refresh: true });
+ return true;
+}
diff --git a/packages/junior-sentry/src/org-read.ts b/packages/junior-sentry/src/org-read.ts
index b8d5fcb392..fc1346c242 100644
--- a/packages/junior-sentry/src/org-read.ts
+++ b/packages/junior-sentry/src/org-read.ts
@@ -1,20 +1,30 @@
/**
* Sentry org read access.
*
- * An internal integration token can read the Sentry organization that
- * installed the integration. Junior uses it for Sentry API reads. Writes use
- * the requesting user's OAuth token.
+ * Junior reads Sentry orgs as itself, not as the requester. Each installation
+ * of the public integration reads its own org, and the org in the API path
+ * selects the installation. An internal integration token
+ * (`SENTRY_READ_TOKEN`) can read only the org that created it. Junior uses it
+ * for reads that no installation covers. Writes use the requesting user's
+ * OAuth token.
*/
-import type {
- EgressHookContext,
- IssueCredentialHookContext,
- PluginCredentialResult,
- PluginGrant,
+import {
+ EgressPolicyDenied,
+ type EgressHookContext,
+ type IssueCredentialHookContext,
+ type PluginCredentialResult,
+ type PluginGrant,
} from "@sentry/junior-plugin-api";
+import { activeSentryInstallations } from "./installations/store.js";
+import {
+ sentryAppConfig,
+ sentryInstallationToken,
+} from "./installations/tokens.js";
export const SENTRY_API_DOMAINS = ["sentry.io", "us.sentry.io", "de.sentry.io"];
const ORG_READ_GRANT = "org-read";
+const INSTALLATION_SCOPE_PREFIX = "installation:";
const LEASE_MS = 60 * 60 * 1000;
const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -22,46 +32,137 @@ function orgReadToken(): string | undefined {
return process.env.SENTRY_READ_TOKEN?.trim() || undefined;
}
-// `/api/0/users/` requests stay on user OAuth because the token has no user.
-function isOrgReadRequest(request: EgressHookContext["request"]): boolean {
- if (!READ_METHODS.has(request.method.toUpperCase())) {
- return false;
- }
+/** Path segments after `/api/0/`, or undefined for other Sentry requests. */
+function apiPathSegments(
+ request: EgressHookContext["request"],
+): string[] | undefined {
const url = new URL(request.url);
if (!SENTRY_API_DOMAINS.includes(url.hostname)) {
- return false;
+ return undefined;
}
- const [api, version, resource] = url.pathname.split("/").filter(Boolean);
- return api === "api" && version === "0" && !!resource && resource !== "users";
+ const [api, version, ...rest] = url.pathname.split("/").filter(Boolean);
+ return api === "api" && version === "0" ? rest : undefined;
+}
+
+// `/api/0/users/` requests stay on user OAuth because Junior has no user.
+function isOrgReadRequest(
+ method: string,
+ segments: string[] | undefined,
+): segments is string[] {
+ return (
+ READ_METHODS.has(method.toUpperCase()) &&
+ !!segments?.[0] &&
+ segments[0] !== "users"
+ );
+}
+
+/**
+ * Read the org slug from an org-scoped API path. Paths such as
+ * `/api/0/issues/{id}/` and the org list name no org.
+ */
+function orgFromApiPath(segments: string[]): string | undefined {
+ const [resource, org] = segments;
+ return (resource === "organizations" ||
+ resource === "projects" ||
+ resource === "teams") &&
+ org
+ ? org.toLowerCase()
+ : undefined;
}
/**
* Select the org read grant for Sentry API reads.
*
- * Return undefined for other requests so the host uses the user's OAuth token.
+ * Return undefined for other requests, and for reads when no installation or
+ * org read token is configured, so the host uses the user's OAuth token.
*/
-export function sentryGrantForEgress(
+export async function sentryGrantForEgress(
ctx: EgressHookContext,
-): PluginGrant | undefined {
- if (!orgReadToken() || !isOrgReadRequest(ctx.request)) {
+): Promise {
+ const segments = apiPathSegments(ctx.request);
+ if (!isOrgReadRequest(ctx.request.method, segments)) {
return undefined;
}
- return {
+ const grant: PluginGrant = {
name: ORG_READ_GRANT,
access: "read",
reason: "sentry.org-read",
};
+ const installations = sentryAppConfig()
+ ? await activeSentryInstallations(ctx)
+ : [];
+ const org = orgFromApiPath(segments);
+ const internalToken = orgReadToken();
+ const installation = org
+ ? installations.find((candidate) => candidate.orgSlug === org)
+ : !internalToken && installations.length === 1
+ ? installations[0]
+ : undefined;
+ if (installation) {
+ return {
+ ...grant,
+ leaseScope: `${INSTALLATION_SCOPE_PREFIX}${installation.installationUuid}`,
+ };
+ }
+ if (internalToken) {
+ return grant;
+ }
+ if (installations.length === 0) {
+ return undefined;
+ }
+ throw new EgressPolicyDenied(
+ org
+ ? `Junior is not installed in the Sentry org "${org}". An owner of that org must install the Junior integration first.`
+ : "Junior is installed in more than one Sentry org, so the Sentry API path must name the org, such as /api/0/organizations/{org}/issues/{id}/.",
+ );
+}
+
+function lease(token: string, expiresAtMs: number): PluginCredentialResult {
+ return {
+ type: "lease",
+ lease: {
+ expiresAt: new Date(expiresAtMs).toISOString(),
+ headerTransforms: SENTRY_API_DOMAINS.map((domain) => ({
+ domain,
+ headers: { Authorization: `Bearer ${token}` },
+ })),
+ },
+ };
}
-/** Issue the org read token for the org read grant. */
-export function issueSentryCredential(
+/** Issue the installation token or org read token for the org read grant. */
+export async function issueSentryCredential(
ctx: IssueCredentialHookContext,
-): PluginCredentialResult {
+): Promise {
if (ctx.grant.name !== ORG_READ_GRANT) {
throw new Error(
`Sentry plugin cannot issue unknown grant "${ctx.grant.name}".`,
);
}
+ const installationUuid = ctx.grant.leaseScope?.startsWith(
+ INSTALLATION_SCOPE_PREFIX,
+ )
+ ? ctx.grant.leaseScope.slice(INSTALLATION_SCOPE_PREFIX.length)
+ : undefined;
+ if (installationUuid) {
+ const config = sentryAppConfig();
+ if (!config) {
+ return {
+ type: "unavailable",
+ message:
+ "Sentry installation access requires SENTRY_APP_CLIENT_ID and SENTRY_APP_CLIENT_SECRET.",
+ };
+ }
+ const installationToken = await sentryInstallationToken(
+ ctx.cache,
+ config,
+ installationUuid,
+ );
+ return lease(
+ installationToken.token,
+ Math.min(Date.now() + LEASE_MS, installationToken.expiresAtMs),
+ );
+ }
const token = orgReadToken();
if (!token) {
return {
@@ -69,14 +170,5 @@ export function issueSentryCredential(
message: "Sentry org read access requires SENTRY_READ_TOKEN.",
};
}
- return {
- type: "lease",
- lease: {
- expiresAt: new Date(Date.now() + LEASE_MS).toISOString(),
- headerTransforms: SENTRY_API_DOMAINS.map((domain) => ({
- domain,
- headers: { Authorization: `Bearer ${token}` },
- })),
- },
- };
+ return lease(token, Date.now() + LEASE_MS);
}
diff --git a/packages/junior-sentry/src/webhooks/handler.ts b/packages/junior-sentry/src/webhooks/handler.ts
index 4ecaf22147..52b19fcc3e 100644
--- a/packages/junior-sentry/src/webhooks/handler.ts
+++ b/packages/junior-sentry/src/webhooks/handler.ts
@@ -1,5 +1,12 @@
import { createHmac, timingSafeEqual } from "node:crypto";
-import type { PluginRoute, EventPublisher } from "@sentry/junior-plugin-api";
+import type {
+ EventPublisher,
+ PluginContext,
+ PluginRoute,
+} from "@sentry/junior-plugin-api";
+import { activeSentryInstallations } from "../installations/store.js";
+import type { SentryAppConfig } from "../installations/tokens.js";
+import { handleSentryInstallationWebhook } from "../installations/webhook.js";
import { normalizeSentryEvents } from "./events.js";
function verifySentrySignature(
@@ -23,8 +30,28 @@ function parseJson(body: string): unknown {
}
}
-/** Create the public, signed Sentry event webhook route. */
+function installationUuid(body: unknown): string | undefined {
+ if (!body || typeof body !== "object") return undefined;
+ const installation = (body as { installation?: unknown }).installation;
+ if (!installation || typeof installation !== "object") return undefined;
+ const uuid = (installation as { uuid?: unknown }).uuid;
+ return typeof uuid === "string" && uuid ? uuid : undefined;
+}
+
+function ignored(): Response {
+ return new Response("Ignored", { status: 202 });
+}
+
+/**
+ * Create the public, signed Sentry event webhook route.
+ *
+ * The public integration and the internal integration sign with their own
+ * client secrets. A delivery from the public integration must come from an
+ * active installation, except the `installation` webhook that creates one.
+ */
export function createSentryWebhookRoute(args: {
+ appConfig(): SentryAppConfig | undefined;
+ ctx: Pick;
events: EventPublisher;
webhookSecret(): string | undefined;
}): PluginRoute {
@@ -34,7 +61,16 @@ export function createSentryWebhookRoute(args: {
async handler(request) {
const rawBody = await request.text();
const signature = request.headers.get("sentry-hook-signature") ?? "";
- if (!verifySentrySignature(rawBody, signature, args.webhookSecret())) {
+ const appConfig = args.appConfig();
+ const fromApp = verifySentrySignature(
+ rawBody,
+ signature,
+ appConfig?.clientSecret,
+ );
+ if (
+ !fromApp &&
+ !verifySentrySignature(rawBody, signature, args.webhookSecret())
+ ) {
return new Response("Unauthorized", { status: 401 });
}
const body = parseJson(rawBody);
@@ -48,6 +84,27 @@ export function createSentryWebhookRoute(args: {
status: 400,
});
}
+ if (fromApp && appConfig) {
+ if (hookResource === "installation") {
+ const handled = await handleSentryInstallationWebhook(
+ args.ctx,
+ appConfig,
+ body,
+ );
+ return handled
+ ? new Response("Accepted", { status: 202 })
+ : ignored();
+ }
+ const uuid = installationUuid(body);
+ const installations = await activeSentryInstallations(args.ctx);
+ if (
+ !installations.some(
+ (installation) => installation.installationUuid === uuid,
+ )
+ ) {
+ return ignored();
+ }
+ }
const events = normalizeSentryEvents({
body,
hookResource,
@@ -57,9 +114,9 @@ export function createSentryWebhookRoute(args: {
for (const event of events) {
await args.events.publish(event);
}
- return new Response(events.length ? "Accepted" : "Ignored", {
- status: 202,
- });
+ return events.length
+ ? new Response("Accepted", { status: 202 })
+ : ignored();
},
};
}
diff --git a/packages/junior-sentry/tests/fixture.ts b/packages/junior-sentry/tests/fixture.ts
new file mode 100644
index 0000000000..5b41e28d34
--- /dev/null
+++ b/packages/junior-sentry/tests/fixture.ts
@@ -0,0 +1,36 @@
+import { readdir, readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import type { PluginCache } from "@sentry/junior-plugin-api";
+import {
+ createLocalPgliteFixture,
+ type LocalPgliteFixture,
+} from "@sentry/junior-testing/pglite";
+import * as sentrySqlSchema from "../src/db/schema";
+import type { SentryDb } from "../src/db/database";
+
+/** Create an in-memory database with the packaged migrations applied. */
+export async function createSentryDb(): Promise> {
+ const fixture = await createLocalPgliteFixture(sentrySqlSchema);
+ const dir = resolve(__dirname, "../migrations");
+ for (const file of (await readdir(dir))
+ .filter((name) => name.endsWith(".sql"))
+ .sort()) {
+ await fixture.execute(await readFile(resolve(dir, file), "utf8"));
+ }
+ return fixture;
+}
+
+/** A plugin cache that keeps entries in memory, like the shared state cache. */
+export function memoryCache(): PluginCache {
+ const entries = new Map();
+ return {
+ async getOrLoad(key, load, options) {
+ if (!options?.refresh && entries.has(key)) {
+ return entries.get(key) as never;
+ }
+ const entry = await load();
+ if (entry.ttlMs !== undefined) entries.set(key, entry.value);
+ return entry.value;
+ },
+ };
+}
diff --git a/packages/junior-sentry/tests/installations.test.ts b/packages/junior-sentry/tests/installations.test.ts
new file mode 100644
index 0000000000..ae92bfbe9c
--- /dev/null
+++ b/packages/junior-sentry/tests/installations.test.ts
@@ -0,0 +1,339 @@
+import { createHmac } from "node:crypto";
+import type {
+ EventInput,
+ PluginCache,
+ PluginGrant,
+ PluginHooks,
+} from "@sentry/junior-plugin-api";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { sentryPlugin } from "../src";
+import { createSentryDb, memoryCache } from "./fixture";
+
+const CLIENT_ID = "junior-client-id";
+const CLIENT_SECRET = "junior-client-secret";
+const INSTALLATION_UUID = "a8e5d37a-696c-4c54-adb5-b3f28d64c7de";
+const OTHER_INSTALLATION_UUID = "0b3b8a4e-2f3c-4f53-9d55-6b1f0c7a1e10";
+
+function hooks(): PluginHooks {
+ const plugin = sentryPlugin();
+ if (!plugin.hooks) throw new Error("Sentry plugin has no hooks");
+ return plugin.hooks;
+}
+
+function signed(
+ body: unknown,
+ resource: string,
+ secret = CLIENT_SECRET,
+): Request {
+ const rawBody = JSON.stringify(body);
+ return new Request("https://junior.example.com/api/webhooks/sentry", {
+ body: rawBody,
+ headers: {
+ "content-type": "application/json",
+ "request-id": "delivery-1",
+ "sentry-hook-resource": resource,
+ "sentry-hook-signature": createHmac("sha256", secret)
+ .update(rawBody)
+ .digest("hex"),
+ },
+ method: "POST",
+ });
+}
+
+function installationBody(
+ action: "created" | "deleted",
+ uuid = INSTALLATION_UUID,
+ org = "sentry",
+) {
+ return {
+ action,
+ actor: { id: 1, name: "Sentry Admin", type: "user" },
+ data: {
+ installation: {
+ app: { slug: "junior", uuid: "2ebf071f-28df-4989-aca9-c37c763b278f" },
+ code: "grant-code",
+ organization: { slug: org },
+ status: "pending",
+ uuid,
+ },
+ },
+ installation: { uuid },
+ };
+}
+
+function issueBody(uuid = INSTALLATION_UUID) {
+ return {
+ action: "created",
+ data: {
+ issue: {
+ id: "7513266773",
+ project: { slug: "junior" },
+ url: "https://sentry.io/api/0/organizations/sentry/issues/7513266773/",
+ },
+ },
+ installation: { uuid },
+ };
+}
+
+function tokenResponse(token: string): Response {
+ return Response.json({
+ expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
+ refreshToken: "unused-refresh-token",
+ token,
+ });
+}
+
+/** Verify a client JWT the same way Sentry does: HS256 with the client secret. */
+function jwtClaims(authorization: string | null): Record {
+ const [header, claims, signature] = (authorization ?? "")
+ .replace(/^Bearer /, "")
+ .split(".");
+ const expected = createHmac("sha256", CLIENT_SECRET)
+ .update(`${header}.${claims}`)
+ .digest("base64url");
+ expect(signature).toBe(expected);
+ return JSON.parse(Buffer.from(claims!, "base64url").toString());
+}
+
+describe("Sentry public integration installations", () => {
+ let fixture: Awaited>;
+ let cache: PluginCache;
+ let events: EventInput[];
+ let fetchMock: ReturnType;
+
+ function route() {
+ const [webhook] =
+ hooks().routes?.({
+ cache,
+ db: fixture.db(),
+ events: {
+ async publish(event: EventInput) {
+ events.push(event);
+ },
+ },
+ } as never) ?? [];
+ if (!webhook) throw new Error("Sentry plugin has no webhook route");
+ return webhook;
+ }
+
+ async function grantFor(url: string, method = "GET") {
+ return await hooks().grantForEgress?.({
+ cache,
+ db: fixture.db(),
+ request: { method, url },
+ } as never);
+ }
+
+ async function issue(grant: PluginGrant) {
+ return await hooks().issueCredential?.({
+ cache,
+ db: fixture.db(),
+ grant,
+ } as never);
+ }
+
+ async function install(uuid = INSTALLATION_UUID, org = "sentry") {
+ fetchMock.mockResolvedValueOnce(tokenResponse(`token-${org}`));
+ const response = await route().handler(
+ signed(installationBody("created", uuid, org), "installation"),
+ );
+ expect(response.status).toBe(202);
+ }
+
+ beforeEach(async () => {
+ vi.stubEnv("SENTRY_APP_CLIENT_ID", CLIENT_ID);
+ vi.stubEnv("SENTRY_APP_CLIENT_SECRET", CLIENT_SECRET);
+ vi.stubEnv("SENTRY_READ_TOKEN", "");
+ vi.stubEnv("SENTRY_WEBHOOK_SECRET", "internal-secret");
+ fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ fixture = await createSentryDb();
+ cache = memoryCache();
+ events = [];
+ });
+
+ afterEach(async () => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ await fixture.close();
+ });
+
+ it("exchanges the grant code and reads the installed org with its token", async () => {
+ await install();
+
+ const [exchangeUrl, exchange] = fetchMock.mock.calls[0]!;
+ expect(exchangeUrl).toBe(
+ `https://sentry.io/api/0/sentry-app-installations/${INSTALLATION_UUID}/authorizations/`,
+ );
+ expect(JSON.parse(exchange.body)).toEqual({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: "grant-code",
+ grant_type: "authorization_code",
+ });
+
+ const grant = await grantFor(
+ "https://us.sentry.io/api/0/organizations/Sentry/issues/",
+ );
+ expect(grant).toEqual({
+ access: "read",
+ leaseScope: `installation:${INSTALLATION_UUID}`,
+ name: "org-read",
+ reason: "sentry.org-read",
+ });
+ await expect(issue(grant!)).resolves.toMatchObject({
+ type: "lease",
+ lease: {
+ headerTransforms: expect.arrayContaining([
+ {
+ domain: "us.sentry.io",
+ headers: { Authorization: "Bearer token-sentry" },
+ },
+ ]),
+ },
+ });
+ // The exchanged token is shared, so no second token request.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("mints a new token with a client JWT when none is cached", async () => {
+ await install();
+ cache = memoryCache();
+ fetchMock.mockResolvedValueOnce(tokenResponse("minted-token"));
+
+ const grant = await grantFor(
+ "https://sentry.io/api/0/projects/sentry/junior/",
+ );
+ await expect(issue(grant!)).resolves.toMatchObject({
+ lease: {
+ headerTransforms: expect.arrayContaining([
+ {
+ domain: "sentry.io",
+ headers: { Authorization: "Bearer minted-token" },
+ },
+ ]),
+ },
+ });
+
+ const [, mint] = fetchMock.mock.calls[1]!;
+ expect(JSON.parse(mint.body)).toEqual({
+ grant_type: "urn:sentry:params:oauth:grant-type:jwt-bearer",
+ });
+ expect(jwtClaims(new Headers(mint.headers).get("authorization"))).toEqual(
+ expect.objectContaining({ iss: CLIENT_ID, sub: CLIENT_ID }),
+ );
+ });
+
+ it("denies reads of an org without an installation", async () => {
+ await install();
+
+ await expect(
+ grantFor("https://sentry.io/api/0/organizations/other/issues/"),
+ ).rejects.toThrow('Junior is not installed in the Sentry org "other".');
+ });
+
+ it("uses the only installation for paths without an org, and denies them when there are more", async () => {
+ await install();
+ await expect(
+ grantFor("https://sentry.io/api/0/issues/123/"),
+ ).resolves.toMatchObject({
+ leaseScope: `installation:${INSTALLATION_UUID}`,
+ });
+
+ await install(OTHER_INSTALLATION_UUID, "other");
+ await expect(
+ grantFor("https://sentry.io/api/0/issues/123/"),
+ ).rejects.toThrow("the Sentry API path must name the org");
+ await expect(
+ grantFor("https://sentry.io/api/0/teams/other/backend/"),
+ ).resolves.toMatchObject({
+ leaseScope: `installation:${OTHER_INSTALLATION_UUID}`,
+ });
+ });
+
+ it("uses the internal integration token for reads that no installation covers", async () => {
+ vi.stubEnv("SENTRY_READ_TOKEN", "org-read-token");
+ await install(OTHER_INSTALLATION_UUID, "other");
+
+ const unscoped = await grantFor("https://sentry.io/api/0/issues/123/");
+ expect(unscoped).not.toHaveProperty("leaseScope");
+ await expect(issue(unscoped!)).resolves.toMatchObject({
+ lease: {
+ headerTransforms: expect.arrayContaining([
+ {
+ domain: "sentry.io",
+ headers: { Authorization: "Bearer org-read-token" },
+ },
+ ]),
+ },
+ });
+ await expect(
+ grantFor("https://sentry.io/api/0/organizations/sentry/issues/"),
+ ).resolves.not.toHaveProperty("leaseScope");
+ await expect(
+ grantFor("https://sentry.io/api/0/organizations/other/issues/"),
+ ).resolves.toMatchObject({
+ leaseScope: `installation:${OTHER_INSTALLATION_UUID}`,
+ });
+ });
+
+ it("keeps writes on user OAuth", async () => {
+ await install();
+
+ await expect(
+ grantFor("https://sentry.io/api/0/organizations/sentry/issues/1/", "PUT"),
+ ).resolves.toBeUndefined();
+ });
+
+ it("publishes events only from active installations", async () => {
+ await install();
+
+ const accepted = await route().handler(signed(issueBody(), "issue"));
+ const unknown = await route().handler(
+ signed(issueBody(OTHER_INSTALLATION_UUID), "issue"),
+ );
+ // The internal integration still signs its own deliveries.
+ const internal = await route().handler(
+ signed(issueBody("internal-uuid"), "issue", "internal-secret"),
+ );
+
+ expect([accepted.status, unknown.status, internal.status]).toEqual([
+ 202, 202, 202,
+ ]);
+ await expect(unknown.text()).resolves.toBe("Ignored");
+ expect(events.map((event) => event.identifier)).toEqual([
+ "sentry/junior#7513266773",
+ "sentry/junior",
+ "sentry/junior#7513266773",
+ "sentry/junior",
+ ]);
+ });
+
+ it("removes an uninstalled org and ignores a late install retry", async () => {
+ await install();
+ await route().handler(signed(installationBody("deleted"), "installation"));
+
+ await expect(
+ grantFor("https://sentry.io/api/0/organizations/sentry/issues/"),
+ ).resolves.toBeUndefined();
+ const lateRetry = await route().handler(
+ signed(installationBody("created"), "installation"),
+ );
+ expect(lateRetry.status).toBe(202);
+ await expect(
+ grantFor("https://sentry.io/api/0/organizations/sentry/issues/"),
+ ).resolves.toBeUndefined();
+ const delivery = await route().handler(signed(issueBody(), "issue"));
+ await expect(delivery.text()).resolves.toBe("Ignored");
+ });
+
+ it("does not accept installation webhooks signed by the internal integration", async () => {
+ const response = await route().handler(
+ signed(installationBody("created"), "installation", "internal-secret"),
+ );
+
+ expect(response.status).toBe(202);
+ await expect(response.text()).resolves.toBe("Ignored");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/junior-sentry/tests/org-read.test.ts b/packages/junior-sentry/tests/org-read.test.ts
index ef114886bf..df9af8c621 100644
--- a/packages/junior-sentry/tests/org-read.test.ts
+++ b/packages/junior-sentry/tests/org-read.test.ts
@@ -31,42 +31,42 @@ describe("sentry org read access", () => {
process.env = { ...ORIGINAL_ENV };
});
- it("uses the org read token for reads and user OAuth for writes", () => {
+ it("uses the org read token for reads and user OAuth for writes", async () => {
const orgRead = {
name: "org-read",
access: "read",
reason: "sentry.org-read",
};
- expect(
+ await expect(
grantFor(
"GET",
"https://us.sentry.io/api/0/organizations/sentry/issues/",
),
- ).toEqual(orgRead);
- expect(
+ ).resolves.toEqual(orgRead);
+ await expect(
grantFor("GET", "https://sentry.io/api/0/projects/sentry/junior/events/"),
- ).toEqual(orgRead);
- expect(grantFor("GET", "https://sentry.io/api/0/issues/123/")).toEqual(
- orgRead,
- );
+ ).resolves.toEqual(orgRead);
+ await expect(
+ grantFor("GET", "https://sentry.io/api/0/issues/123/"),
+ ).resolves.toEqual(orgRead);
// Explore queries are GET requests.
- expect(
+ await expect(
grantFor(
"GET",
"https://us.sentry.io/api/0/organizations/sentry/events/?dataset=spans&field=span.op",
),
- ).toEqual(orgRead);
+ ).resolves.toEqual(orgRead);
// Writes and user endpoints keep user OAuth.
- expect(
+ await expect(
grantFor(
"PUT",
"https://sentry.io/api/0/organizations/sentry/issues/123/",
),
- ).toBeUndefined();
- expect(
+ ).resolves.toBeUndefined();
+ await expect(
grantFor("GET", "https://sentry.io/api/0/users/me/regions/"),
- ).toBeUndefined();
+ ).resolves.toBeUndefined();
});
it("issues the configured token for the org read grant", async () => {
@@ -95,11 +95,11 @@ describe("sentry org read access", () => {
});
});
- it("keeps every request on user OAuth without a configured token", () => {
+ it("keeps every request on user OAuth without a configured token", async () => {
delete process.env.SENTRY_READ_TOKEN;
- expect(
+ await expect(
grantFor("GET", "https://sentry.io/api/0/organizations/sentry/issues/"),
- ).toBeUndefined();
+ ).resolves.toBeUndefined();
});
});
diff --git a/packages/junior-sentry/tests/webhook.test.ts b/packages/junior-sentry/tests/webhook.test.ts
index 8d7c2fa8eb..807c05ad80 100644
--- a/packages/junior-sentry/tests/webhook.test.ts
+++ b/packages/junior-sentry/tests/webhook.test.ts
@@ -91,11 +91,23 @@ function signedRequest(
return signedRawRequest(JSON.stringify(body), options);
}
+/** The internal integration alone reads no installation records. */
+const noAppContext = {
+ cache: {
+ async getOrLoad() {
+ throw new Error("The internal integration reads no installations.");
+ },
+ },
+ db: undefined,
+};
+
function routeFixture() {
const events: EventInput[] = [];
return {
events,
route: createSentryWebhookRoute({
+ appConfig: () => undefined,
+ ctx: noAppContext,
events: {
async publish(event) {
events.push(event);
@@ -437,6 +449,8 @@ describe("Sentry webhook events", () => {
it("propagates publisher failures so Sentry can retry", async () => {
const route = createSentryWebhookRoute({
+ appConfig: () => undefined,
+ ctx: noAppContext,
events: {
async publish() {
throw new Error("queue unavailable");
diff --git a/packages/junior-sentry/tsup.config.ts b/packages/junior-sentry/tsup.config.ts
index ba36c61614..c0289c5fda 100644
--- a/packages/junior-sentry/tsup.config.ts
+++ b/packages/junior-sentry/tsup.config.ts
@@ -4,7 +4,7 @@ export default defineConfig({
clean: true,
dts: false,
entry: ["src/index.ts", "src/testing.ts"],
- external: ["@sentry/junior-plugin-api", "zod"],
+ external: ["@sentry/junior-plugin-api", "drizzle-orm", "zod"],
format: ["esm"],
target: "node24",
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 38c3cf915a..4cf205ba4c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -745,6 +745,9 @@ importers:
'@sentry/junior-plugin-api':
specifier: workspace:*
version: link:../junior-plugin-api
+ drizzle-orm:
+ specifier: 'catalog:'
+ version: 0.45.2
zod:
specifier: 'catalog:'
version: 4.5.4
@@ -758,6 +761,9 @@ importers:
'@types/node':
specifier: ^25.9.1
version: 25.9.1
+ drizzle-kit:
+ specifier: 'catalog:'
+ version: 0.31.10
oxlint:
specifier: ^1.79.0
version: 1.79.0