From 7eb9cc9aa445bba6fa85177107109dc1fd423c3a Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:41:18 -0700 Subject: [PATCH 1/2] fix: enforce repo access for issue watches --- src/mcp/server.ts | 9 +++---- src/notifications/service.ts | 37 +++++++++++++++++------------ src/services/control-panel-roles.ts | 10 +++++++- test/unit/issue-watch.test.ts | 30 ++++++++++++++++++++--- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3cd1c441a7..68f4bc4780 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,7 +5,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { authenticatePrivateToken, extractBearerToken, type AuthIdentity } from "../auth/security"; -import { loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; +import { canLoginAccessRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; import { countOpenIssues, countOpenPullRequests, @@ -1266,11 +1266,7 @@ export class GittensoryMcp { private async canAccessRepo(fullName: string): Promise { if (this.identity.kind !== "session") return true; - const [scope, repo] = await Promise.all([this.loadSessionAccessScope(), getRepository(this.env, fullName)]); - if (scope.operator) return true; - const requestedRepo = fullName.toLowerCase(); - if (scope.repositoryFullNames.some((name) => name.toLowerCase() === requestedRepo)) return true; - return Boolean(repo && scope.accountLogins.some((login) => login.toLowerCase() === repo.owner.toLowerCase())); + return canLoginAccessRepo(this.env, this.identity.actor, fullName); } private async getRepoOutcomePatterns(input: { owner: string; repo: string }): Promise { @@ -1427,6 +1423,7 @@ export class GittensoryMcp { let changed: string | undefined; if (input.action === "watch" || input.action === "unwatch") { if (!input.repoFullName) return { summary: `${input.action} requires repoFullName.`, data: {} }; + await this.requireRepoAccess(input.repoFullName); if (input.action === "watch") { await upsertIssueWatchSubscription(this.env, { login: input.login, repoFullName: input.repoFullName, labels: input.labels }); changed = `watching ${input.repoFullName}${input.labels && input.labels.length > 0 ? ` (labels: ${input.labels.join(", ")})` : ""}`; diff --git a/src/notifications/service.ts b/src/notifications/service.ts index ba939b7a59..3f4351ac1c 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -8,6 +8,7 @@ import { markNotificationDeliveryDelivered, } from "../db/repositories"; import { isGrabbableHighMultiplierIssue } from "../signals/engine"; +import { canLoginAccessRepo } from "../services/control-panel-roles"; import type { DetectedNotificationEvent, IssueRecord, NotificationChannel, NotificationDeliveryRecord, NotificationSubscriptionRecord } from "../types"; import { nowIso } from "../utils/json"; @@ -76,21 +77,27 @@ export async function detectIssueWatchEvents(env: Env, repoFullName: string, iss const detectedAt = nowIso(); const issueLabels = new Set(issue.labels.map((label) => label.toLowerCase().trim())); const authorLogin = issue.authorLogin?.toLowerCase(); - return watchers - // An empty label filter matches any issue; otherwise at least one watched label must be present. - .filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label))) - // Don't ping the maintainer who opened the issue about their own issue. - .filter((watcher) => watcher.login.toLowerCase() !== authorLogin) - .map((watcher) => ({ - eventType: "issue_watch_match" as const, - recipientLogin: watcher.login, - repoFullName, - pullNumber: issue.number, // carries the ISSUE number for this eventType - dedupKey: `issue_watch_match:${repoFullName}#${issue.number}:${watcher.login.toLowerCase()}`, - deeplink: `https://github.com/${repoFullName}/issues/${issue.number}`, - actorLogin: issue.authorLogin ?? "unknown", - detectedAt, - })); + const authorizedWatchers = ( + await Promise.all( + watchers + // An empty label filter matches any issue; otherwise at least one watched label must be present. + .filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label))) + // Don't ping the maintainer who opened the issue about their own issue. + .filter((watcher) => watcher.login.toLowerCase() !== authorLogin) + .map(async (watcher) => ((await canLoginAccessRepo(env, watcher.login, repoFullName)) ? watcher : null)), + ) + ).filter((watcher) => watcher !== null); + + return authorizedWatchers.map((watcher) => ({ + eventType: "issue_watch_match" as const, + recipientLogin: watcher.login, + repoFullName, + pullNumber: issue.number, // carries the ISSUE number for this eventType + dedupKey: `issue_watch_match:${repoFullName}#${issue.number}:${watcher.login.toLowerCase()}`, + deeplink: `https://github.com/${repoFullName}/issues/${issue.number}`, + actorLogin: issue.authorLogin ?? "unknown", + detectedAt, + })); } function rateLimitWindowStart(now: string): string { diff --git a/src/services/control-panel-roles.ts b/src/services/control-panel-roles.ts index 22dc95df61..ef081712cc 100644 --- a/src/services/control-panel-roles.ts +++ b/src/services/control-panel-roles.ts @@ -1,5 +1,5 @@ import { isAuthorizedGitHubSessionLogin } from "../auth/security"; -import { getFreshOfficialMinerDetection, listAllPullRequests, listInstallations, listRepositories } from "../db/repositories"; +import { getFreshOfficialMinerDetection, getRepository, listAllPullRequests, listInstallations, listRepositories } from "../db/repositories"; import type { ControlPanelRoleCard, ControlPanelRoleName, ControlPanelRoleSummary, InstallationRecord, PullRequestRecord, RepositoryRecord } from "../types"; import { nowIso } from "../utils/json"; @@ -33,6 +33,14 @@ export async function loadControlPanelAccessScope(env: Env, login: string): Prom }); } +export async function canLoginAccessRepo(env: Env, login: string, fullName: string): Promise { + const [scope, repo] = await Promise.all([loadControlPanelAccessScope(env, login), getRepository(env, fullName)]); + if (scope.operator) return true; + const requestedRepo = fullName.toLowerCase(); + if (scope.repositoryFullNames.some((name) => name.toLowerCase() === requestedRepo)) return true; + return Boolean(repo && scope.accountLogins.some((accountLogin) => accountLogin.toLowerCase() === repo.owner.toLowerCase())); +} + export async function loadControlPanelRoleSummary(env: Env, login: string): Promise { const [miner, repositories, installations, pullRequests] = await Promise.all([ getFreshOfficialMinerDetection(env, login).catch(() => null), diff --git a/test/unit/issue-watch.test.ts b/test/unit/issue-watch.test.ts index a8dc1cc204..3ad949d577 100644 --- a/test/unit/issue-watch.test.ts +++ b/test/unit/issue-watch.test.ts @@ -8,6 +8,7 @@ import { listIssueWatchSubscriptionsForLogin, listIssueWatchersForRepo, upsertIssueWatchSubscription, + upsertRepositoryFromGitHub, } from "../../src/db/repositories"; import { isGrabbableHighMultiplierIssue } from "../../src/signals/engine"; import { buildIssueWatchNotification, buildNotificationContent, detectIssueWatchEvents } from "../../src/notifications/service"; @@ -53,7 +54,7 @@ describe("issue-watch subscriptions (CRUD)", () => { describe("detectIssueWatchEvents", () => { it("fans out one event per matching watcher, skips the author, honours the label filter", async () => { - const env = createTestEnv(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "alice bob maintainer" }); await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); // any label await upsertIssueWatchSubscription(env, { login: "bob", repoFullName: "owner/repo", labels: ["bug"] }); // bug only await upsertIssueWatchSubscription(env, { login: "maintainer", repoFullName: "owner/repo" }); // the issue's author @@ -71,15 +72,24 @@ describe("detectIssueWatchEvents", () => { }); it("returns nothing for a non-grabbable issue or when there are no watchers", async () => { - const env = createTestEnv(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "alice" }); await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); expect(await detectIssueWatchEvents(env, "owner/repo", issue({ authorAssociation: "NONE" }))).toEqual([]); // community-authored expect(await detectIssueWatchEvents(env, "owner/repo", issue({ labels: ["wip"] }))).toEqual([]); // maintainer WIP expect(await detectIssueWatchEvents(env, "unwatched/repo", issue())).toEqual([]); // no watchers }); - it("handles an issue with no recorded author (actor falls back to 'unknown', no one is skipped)", async () => { + + it("filters legacy watchers that no longer have repository access", async () => { const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private", full_name: "victim/private", private: true, owner: { login: "victim" }, default_branch: "main" }, 123); + await upsertIssueWatchSubscription(env, { login: "attacker", repoFullName: "victim/private" }); + + await expect(detectIssueWatchEvents(env, "victim/private", issue({ repoFullName: "victim/private", number: 77 }))).resolves.toEqual([]); + }); + + it("handles an issue with no recorded author (actor falls back to 'unknown', no one is skipped)", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "alice" }); await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); const events = await detectIssueWatchEvents(env, "owner/repo", issue({ number: 12, authorLogin: undefined, authorAssociation: "MEMBER" })); expect(events).toHaveLength(1); @@ -134,6 +144,20 @@ describe("MCP gittensory_watch_issues", () => { expect((unwatched.structuredContent as { watching: unknown[] }).watching).toHaveLength(0); }); + + it("blocks session actors from watching inaccessible repositories", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private", full_name: "victim/private", private: true, owner: { login: "victim" }, default_branch: "main" }, 321); + const { session } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); + const client = await connect(env, { kind: "session", actor: "miner", session }); + + const result = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "watch", repoFullName: "victim/private" } }); + + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("session cannot access this repository"); + await expect(listIssueWatchSubscriptionsForLogin(env, "miner")).resolves.toEqual([]); + }); + it("is self-scoped: a session cannot manage another login's watches", async () => { const env = createTestEnv(); const { session } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); From cbd6c7061ccd43d8b3ad0642c5f732226e5f0e61 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:01:11 -0700 Subject: [PATCH 2/2] fix(issue-watch): make the access gate visibility-aware (not maintainer-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo-access enforcement added to issue-watch (this PR) used the maintainer/owner/operator scope (canLoginAccessRepo) unconditionally — which BREAKS the feature: issue-watch is a miner tool, and miners watch PUBLIC gittensor-tracked repos they do NOT own or maintain. Gating on maintainer access blocked every legitimate watcher (the failing `expected [] to deeply equal ['watcher']` test was exactly this). Make the gate visibility-aware instead, matching the real threat (a session subscribing to private repos it cannot see): - New canWatchRepo(env, login, fullName): a tracked PUBLIC repo is watchable by any contributor; a PRIVATE repo requires maintainer/owner/ operator access; an untracked repo (unknown visibility) is not watchable (fail-closed). - watch/unwatch gate (requireWatchableRepo) applies only to SESSION identities (non-session private-token callers stay trusted, matching canAccessRepo) — this is the documented threat surface. - Fan-out resolves the repo once: a public repo notifies every matching watcher; a private/untracked repo only watchers who can access it, so private-repo issues never reach a non-collaborator. Tests reworked accordingly: public-repo watchers no longer need to be admins; added a session-watches-public-repo case, a private-repo authorized-watcher fan-out case, and a fail-closed untracked-repo case. --- src/mcp/server.ts | 13 ++++++++-- src/notifications/service.ts | 28 +++++++++++++-------- src/services/control-panel-roles.ts | 11 ++++++++ test/unit/issue-watch.test.ts | 39 +++++++++++++++++++++++++---- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85fcc9e3be..451c08a68a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,7 +5,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { authenticatePrivateToken, extractBearerToken, type AuthIdentity } from "../auth/security"; -import { canLoginAccessRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; +import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; import { countOpenIssues, countOpenPullRequests, @@ -1241,6 +1241,15 @@ export class GittensoryMcp { throw new Error("Forbidden: session cannot access this repository."); } + // Issue-watch gate (#699 path B). Sessions may only watch repos they can SEE: any gittensory-tracked PUBLIC + // repo (the miner use case) or a PRIVATE repo they can access — never an arbitrary/private repo they cannot, + // so private-repo issues never fan out to them. Non-session (private-token) identities are trusted. + private async requireWatchableRepo(login: string, repoFullName: string): Promise { + if (this.identity.kind !== "session") return; + if (await canWatchRepo(this.env, login, repoFullName)) return; + throw new Error("Forbidden: session cannot watch this repository."); + } + private loadSessionAccessScope(): Promise { if (this.identity.kind !== "session") throw new Error("Session access scope is only available for session identities."); this.accessScopePromise ??= loadControlPanelAccessScope(this.env, this.identity.actor); @@ -1555,7 +1564,7 @@ export class GittensoryMcp { let changed: string | undefined; if (input.action === "watch" || input.action === "unwatch") { if (!input.repoFullName) return { summary: `${input.action} requires repoFullName.`, data: {} }; - await this.requireRepoAccess(input.repoFullName); + await this.requireWatchableRepo(input.login, input.repoFullName); if (input.action === "watch") { await upsertIssueWatchSubscription(this.env, { login: input.login, repoFullName: input.repoFullName, labels: input.labels }); changed = `watching ${input.repoFullName}${input.labels && input.labels.length > 0 ? ` (labels: ${input.labels.join(", ")})` : ""}`; diff --git a/src/notifications/service.ts b/src/notifications/service.ts index 3f4351ac1c..df649f8436 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -2,6 +2,7 @@ import { sanitizePublicComment } from "../github/commands"; import { countRecentNotificationDeliveries, getNotificationDeliveryById, + getRepository, insertNotificationDeliveryIfAbsent, listIssueWatchersForRepo, listNotificationSubscriptionsForLogin, @@ -77,16 +78,23 @@ export async function detectIssueWatchEvents(env: Env, repoFullName: string, iss const detectedAt = nowIso(); const issueLabels = new Set(issue.labels.map((label) => label.toLowerCase().trim())); const authorLogin = issue.authorLogin?.toLowerCase(); - const authorizedWatchers = ( - await Promise.all( - watchers - // An empty label filter matches any issue; otherwise at least one watched label must be present. - .filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label))) - // Don't ping the maintainer who opened the issue about their own issue. - .filter((watcher) => watcher.login.toLowerCase() !== authorLogin) - .map(async (watcher) => ((await canLoginAccessRepo(env, watcher.login, repoFullName)) ? watcher : null)), - ) - ).filter((watcher) => watcher !== null); + const matching = watchers + // An empty label filter matches any issue; otherwise at least one watched label must be present. + .filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label))) + // Don't ping the maintainer who opened the issue about their own issue. + .filter((watcher) => watcher.login.toLowerCase() !== authorLogin); + + // Access gate: a gittensory-tracked PUBLIC repo fans out to every matching watcher (the miner use case); + // a PRIVATE — or untracked/unknown — repo only to watchers who can access it, so private-repo issues never + // reach a non-collaborator. The repo is the same for all watchers, so resolve it once and only pay the + // per-watcher access check on the private path. + const repo = await getRepository(env, repoFullName); + const authorizedWatchers = + repo && !repo.isPrivate + ? matching + : (await Promise.all(matching.map(async (watcher) => ((repo && (await canLoginAccessRepo(env, watcher.login, repoFullName))) ? watcher : null)))).filter( + (watcher) => watcher !== null, + ); return authorizedWatchers.map((watcher) => ({ eventType: "issue_watch_match" as const, diff --git a/src/services/control-panel-roles.ts b/src/services/control-panel-roles.ts index ef081712cc..e143cfe5c5 100644 --- a/src/services/control-panel-roles.ts +++ b/src/services/control-panel-roles.ts @@ -41,6 +41,17 @@ export async function canLoginAccessRepo(env: Env, login: string, fullName: stri return Boolean(repo && scope.accountLogins.some((accountLogin) => accountLogin.toLowerCase() === repo.owner.toLowerCase())); } +// Whether `login` may watch `fullName`'s issues. Issue-watch (#699 path B) is a MINER feature: miners watch +// PUBLIC gittensor-tracked repos they don't own or maintain, so a tracked public repo is watchable by any +// contributor. A PRIVATE repo is gated to maintainer/owner/operator scope so its issues never fan out to a +// non-collaborator. An untracked repo (unknown visibility) is treated as not watchable (fail-closed). +export async function canWatchRepo(env: Env, login: string, fullName: string): Promise { + const repo = await getRepository(env, fullName); + if (!repo) return false; + if (!repo.isPrivate) return true; + return canLoginAccessRepo(env, login, fullName); +} + export async function loadControlPanelRoleSummary(env: Env, login: string): Promise { const [miner, repositories, installations, pullRequests] = await Promise.all([ getFreshOfficialMinerDetection(env, login).catch(() => null), diff --git a/test/unit/issue-watch.test.ts b/test/unit/issue-watch.test.ts index 3ad949d577..171faf05bb 100644 --- a/test/unit/issue-watch.test.ts +++ b/test/unit/issue-watch.test.ts @@ -54,7 +54,9 @@ describe("issue-watch subscriptions (CRUD)", () => { describe("detectIssueWatchEvents", () => { it("fans out one event per matching watcher, skips the author, honours the label filter", async () => { - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "alice bob maintainer" }); + const env = createTestEnv(); + // owner/repo is a tracked PUBLIC repo, so any contributor may watch it (the miner use case). + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" }, 100); await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); // any label await upsertIssueWatchSubscription(env, { login: "bob", repoFullName: "owner/repo", labels: ["bug"] }); // bug only await upsertIssueWatchSubscription(env, { login: "maintainer", repoFullName: "owner/repo" }); // the issue's author @@ -72,7 +74,7 @@ describe("detectIssueWatchEvents", () => { }); it("returns nothing for a non-grabbable issue or when there are no watchers", async () => { - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "alice" }); + const env = createTestEnv(); await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); expect(await detectIssueWatchEvents(env, "owner/repo", issue({ authorAssociation: "NONE" }))).toEqual([]); // community-authored expect(await detectIssueWatchEvents(env, "owner/repo", issue({ labels: ["wip"] }))).toEqual([]); // maintainer WIP @@ -88,8 +90,23 @@ describe("detectIssueWatchEvents", () => { await expect(detectIssueWatchEvents(env, "victim/private", issue({ repoFullName: "victim/private", number: 77 }))).resolves.toEqual([]); }); + it("does not fan out for an untracked repo even if it has watchers (fail-closed on unknown visibility)", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "ghost/repo" }); // repo never upserted + await expect(detectIssueWatchEvents(env, "ghost/repo", issue({ repoFullName: "ghost/repo", number: 88 }))).resolves.toEqual([]); + }); + + it("still fans out a PRIVATE-repo issue to a watcher who can access it", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator" }); // operator has access to any repo + await upsertRepositoryFromGitHub(env, { name: "private", full_name: "acme/private", private: true, owner: { login: "acme" }, default_branch: "main" }, 555); + await upsertIssueWatchSubscription(env, { login: "operator", repoFullName: "acme/private" }); + const events = await detectIssueWatchEvents(env, "acme/private", issue({ repoFullName: "acme/private", number: 90, authorLogin: "acme" })); + expect(events.map((event) => event.recipientLogin)).toEqual(["operator"]); + }); + it("handles an issue with no recorded author (actor falls back to 'unknown', no one is skipped)", async () => { - const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "alice" }); + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" }, 100); await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); const events = await detectIssueWatchEvents(env, "owner/repo", issue({ number: 12, authorLogin: undefined, authorAssociation: "MEMBER" })); expect(events).toHaveLength(1); @@ -145,7 +162,19 @@ describe("MCP gittensory_watch_issues", () => { }); - it("blocks session actors from watching inaccessible repositories", async () => { + it("lets a session watch a tracked PUBLIC repo it does not maintain (the miner use case)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" }, default_branch: "main" }, 100); + const { session } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); + const client = await connect(env, { kind: "session", actor: "miner", session }); + + const result = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "watch", repoFullName: "owner/repo" } }); + + expect(result.isError).toBeFalsy(); + expect((result.structuredContent as { watching: Array<{ repoFullName: string }> }).watching).toEqual([{ repoFullName: "owner/repo", labels: [] }]); + }); + + it("blocks session actors from watching inaccessible (private) repositories", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "private", full_name: "victim/private", private: true, owner: { login: "victim" }, default_branch: "main" }, 321); const { session } = await createSessionForGitHubUser(env, { login: "miner", id: 1 }); @@ -154,7 +183,7 @@ describe("MCP gittensory_watch_issues", () => { const result = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "watch", repoFullName: "victim/private" } }); expect(result.isError).toBe(true); - expect(JSON.stringify(result.content)).toContain("session cannot access this repository"); + expect(JSON.stringify(result.content)).toContain("session cannot watch this repository"); await expect(listIssueWatchSubscriptionsForLogin(env, "miner")).resolves.toEqual([]); });