From fe95038b7df4ca6abc2cadabbc30332da67e6d3d Mon Sep 17 00:00:00 2001
From: "fctl-sh[bot]" <343971779+fctl-sh[bot]@users.noreply.github.com>
Date: Fri, 30 Oct 2026 21:21:02 +0000
Subject: [PATCH 1/7] fix(control): end workspaces' connections when their app
is uninstalled
A workspace's GitHub connection worked through whatever installation of
the GitHub App its account had, and a Linear one through fctl's app
wherever it was installed again: once uninstalled and installed again,
every connection made before came back. An account's or a Linear
workspace's admins had no way to end what they let a workspace reach,
such as a former admin's own workspace, which kept minting tokens there.
A GitHub connection now holds only through the installation it was made
through: sessions fail with a message that says so, and pull request news
stops, until the workspace's admins connect the account again with a
GitHub sign-in. When Linear says fctl's app was uninstalled, or refuses to
renew its token, the fctl workspaces' connections to that Linear workspace
end, and installing the app again connects only the fctl workspace it is
installed from.
---
crates/fctl-control/src/linear.rs | 30 +++++++---
crates/fctl-control/src/pull_requests.rs | 8 ++-
crates/fctl-control/src/workspaces.rs | 71 +++++++++++++++---------
docs/architecture.md | 23 ++++++--
docs/security.md | 14 ++++-
5 files changed, 100 insertions(+), 46 deletions(-)
diff --git a/crates/fctl-control/src/linear.rs b/crates/fctl-control/src/linear.rs
index eb5097e3..f0c540da 100644
--- a/crates/fctl-control/src/linear.rs
+++ b/crates/fctl-control/src/linear.rs
@@ -285,17 +285,29 @@ pub async fn store(
/// Marks fctl's app in a Linear workspace revoked, and forgets its tokens,
/// which no longer work: uninstalled there, or its token no longer renews.
-/// Its fctl workspaces' admins connect it again.
+/// The fctl workspaces connected to it stop working there, and their admins
+/// connect it again: installing the app again, which takes an admin of the
+/// Linear workspace, connects only the fctl workspace it is installed from,
+/// so that the Linear workspace's admins can end what they let fctl
+/// workspaces reach, a former admin's own one too.
pub async fn mark_revoked(db: &D1Database, organization: &str, now: i64) -> AppResult<()> {
- q(
+ crate::db::batch(
db,
- "UPDATE linear_installations SET status = 'revoked', secret_enc = '', expires_at = NULL, updated_at = ? \
- WHERE organization_id = ?",
- &[n(now), s(organization)],
- )?
- .run()
- .await?;
- Ok(())
+ vec![
+ q(
+ db,
+ "UPDATE linear_installations SET status = 'revoked', secret_enc = '', expires_at = NULL, updated_at = ? \
+ WHERE organization_id = ?",
+ &[n(now), s(organization)],
+ )?,
+ q(
+ db,
+ "DELETE FROM workspace_linear WHERE organization_id = ?",
+ &[s(organization)],
+ )?,
+ ],
+ )
+ .await
}
/// Removes fctl's app from a Linear workspace no fctl workspace works in
diff --git a/crates/fctl-control/src/pull_requests.rs b/crates/fctl-control/src/pull_requests.rs
index aa9f79c4..b3dfb341 100644
--- a/crates/fctl-control/src/pull_requests.rs
+++ b/crates/fctl-control/src/pull_requests.rs
@@ -84,13 +84,15 @@ pub(crate) fn key(repo: &RepoRef) -> String {
///
/// With `answered` (parameters, `?N`): GitHub's id of the account that
/// answered and of the installation it answered through, only where the
-/// connection is to that account, or, from before ids were kept, recorded
-/// that installation, since a login can change hands.
+/// connection was made through that installation, or, from before
+/// installations were recorded, is to that account, since a login can
+/// change hands, and a connection made through an installation since
+/// uninstalled has ended (`crate::workspaces::installation`).
pub(crate) fn in_reach(answered: Option<(&str, &str)>) -> String {
let account = answered
.map(|(account, installation)| {
format!(
- " AND (rg.account_id = {account} OR (rg.account_id IS NULL AND rg.installation_id = {installation}))"
+ " AND (rg.installation_id = {installation} OR (rg.installation_id IS NULL AND rg.account_id = {account}))"
)
})
.unwrap_or_default();
diff --git a/crates/fctl-control/src/workspaces.rs b/crates/fctl-control/src/workspaces.rs
index 1afa3ea7..a6a62a6c 100644
--- a/crates/fctl-control/src/workspaces.rs
+++ b/crates/fctl-control/src/workspaces.rs
@@ -667,16 +667,18 @@ pub fn scope(connections: &[ConnectionRow], owner: &str) -> Option {
}
/// The installation of the GitHub App that serves a workspace's connection
-/// to `account`: the one on that login as GitHub says now (the app may have
-/// been installed again since), while the login still belongs to the
-/// account the workspace connected, by id (`fctl_domain::github::binding`).
-/// A connection from before ids were kept takes the account of the
-/// installation it recorded, when GitHub still has it, else the one that
-/// has the login now. The installation is remembered with the workspace's
-/// connection and the others bound to that account; never with another
-/// workspace's connection from before ids were kept, whose recorded
-/// installation is what tells it which account it connected. A workspace
-/// without an active connection to `account` gets none.
+/// to `account`: the one on that login as GitHub says now, while the login
+/// still belongs to the account the workspace connected, by id
+/// (`fctl_domain::github::binding`), and that installation is the one the
+/// workspace connected through. Uninstalling the app from an account ends
+/// the connections made through it: installing it again brings none back
+/// (`installed_again`), so that an account's admins can end what they let
+/// workspaces reach, for a former admin's own workspace too, and the
+/// workspace's admins connect it again, with a GitHub sign-in that checks
+/// what they may connect now. A connection from before installations were
+/// recorded keeps the one it finds the first time, and one from before ids
+/// were kept the account it finds then. A workspace without an active
+/// connection to `account` gets none.
pub async fn installation(
db: &D1Database,
gh: &crate::github::Github<'_>,
@@ -694,17 +696,26 @@ pub async fn installation(
.installation_id
.and_then(|id| u64::try_from(id).ok());
let stored = connection.account_id.and_then(|id| u64::try_from(id).ok());
- let mut connected = stored;
- if stored.is_none()
- && let Some(recorded) = known.filter(|k| *k != found.id)
- && let Some(info) = gh.installation_by_id(recorded).await?
- {
- connected = info.account_id;
+ if let Some(recorded) = known.filter(|k| *k != found.id) {
+ // Where the installation it recorded is still there, the login is
+ // another account's now; else the app was installed again.
+ let connected = match stored {
+ Some(id) => Some(id),
+ None => gh
+ .installation_by_id(recorded)
+ .await?
+ .and_then(|i| i.account_id),
+ };
+ if fctl_domain::github::binding(connected, found.account_id) == Binding::Other {
+ return Err(not_connected(account));
+ }
+ return Err(installed_again(account));
}
- if fctl_domain::github::binding(connected, found.account_id) == Binding::Other {
+ if fctl_domain::github::binding(stored, found.account_id) == Binding::Other {
return Err(not_connected(account));
}
- // A connection from before ids were kept keeps the account from now on.
+ // A connection from before ids were kept keeps the account from now on,
+ // and one from before installations were recorded the installation.
if stored.is_none()
&& let Some(id) = found.account_id
{
@@ -716,17 +727,11 @@ pub async fn installation(
.run()
.await?;
}
- if known != Some(found.id) {
+ if known.is_none() {
q(
db,
- "UPDATE workspace_github SET installation_id = ?1 \
- WHERE (workspace_id = ?2 AND account = ?3) OR (?4 IS NOT NULL AND account_id = ?4)",
- &[
- n(found.id as i64),
- s(&connection.workspace_id),
- s(&connection.account),
- on(found.account_id.map(|id| id as i64)),
- ],
+ "UPDATE workspace_github SET installation_id = ? WHERE workspace_id = ? AND account = ? AND installation_id IS NULL",
+ &[n(found.id as i64), s(&connection.workspace_id), s(&connection.account)],
)?
.run()
.await?;
@@ -744,6 +749,18 @@ fn not_used(account: &str) -> AppError {
))
}
+/// The error for a connection made through an installation of the GitHub
+/// App that the account no longer has: it was uninstalled there, and maybe
+/// installed again since ([`installation`]).
+pub fn installed_again(account: &str) -> AppError {
+ AppError(ApiError::new(
+ ErrorCode::Forbidden,
+ format!(
+ "fctl's GitHub App was uninstalled from {account} after this workspace connected it, which ended the connection. The workspace's admins connect it again under its GitHub tab."
+ ),
+ ))
+}
+
/// The error for a connection whose login belongs to another GitHub
/// account now.
pub fn not_connected(account: &str) -> AppError {
diff --git a/docs/architecture.md b/docs/architecture.md
index 363f5e9e..32c1c558 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -634,10 +634,18 @@ sessions what pull request lookups and webhooks say only through the
installation on a login that still belongs to the account the workspace
connected (`workspaces::installation`, `fctl_domain::github::binding`).
Otherwise sessions fail, and links refuse, saying that the workspace's
-admins connect the account again, by its name now. A connection from
-before ids were kept takes the account of the installation it recorded,
-when GitHub still has it, else the one that has the login, the first time
-it is used.
+admins connect the account again, by its name now. A connection is also
+to the installation it was made through (`workspace_github.installation_id`):
+uninstalling the app from an account ends every workspace's connection
+there, and installing it again brings none back, so that an account's
+admins can end what they let workspaces reach, a former admin's own
+workspace too. Sessions then fail, and pull request news stops
+(`pull_requests::in_reach`), until the workspace's admins connect the
+account again, with a GitHub sign-in that checks what they may connect
+now. A connection from before ids were kept takes the account of the
+installation it recorded, when GitHub still has it, else the one that has
+the login, the first time it is used, and one from before installations
+were recorded the installation it finds then.
Installing the app from a workspace, or giving it more repositories, goes
through `/api/v1/github/install`, which keeps the workspace in a signed
@@ -1823,7 +1831,12 @@ issue's delegate (the person stays its assignee), and mention.
and two renewals that race end alike, as Linear answers a refresh it
already answered the same for half an hour. One whose renewal Linear
refuses, or that Linear says was uninstalled (an `OAuthApp`
- revocation), is revoked here until an admin connects it again. A
+ revocation), is revoked here, and every fctl workspace's connection to
+ it ends (`crate::linear::mark_revoked`): installing the app again, which
+ takes an admin of the Linear workspace, connects only the fctl
+ workspace it is installed from, so that the Linear workspace's admins
+ can end what they let fctl workspaces reach, a former admin's own one
+ too. A
workspace that disconnects a Linear workspace hears nothing more from
it and reaches none of its tickets, whatever other workspaces do; once
no fctl workspace works in it, fctl revokes the app's token there (the
diff --git a/docs/security.md b/docs/security.md
index 55eae46f..148920e8 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -330,7 +330,15 @@ its VM, and nothing outside it except through these channels:
A connection is to the account, by GitHub's id, not its name: when a
connected account is renamed or deleted and someone else takes its name,
the workspace's sessions get no tokens there, and their pull requests no
- news from it, until its admins connect the account again.
+ news from it, until its admins connect the account again. It is also to
+ the installation of the app it was made through: uninstalling the app
+ from an account ends every workspace's connection there, and installing
+ it again brings none back, so that the account's admins can end what
+ they let workspaces reach, a former admin's own workspace too. Each
+ workspace's admins connect it again, with a GitHub sign-in that checks
+ what they may connect then. Short of that, a connection keeps what its
+ admin's sign-in gave it: fctl doesn't check their standing on GitHub
+ again ([ADR 0029](adr/0029-workspaces-connect-github-accounts-with-a-github-sign-in.md)).
- **Claude.** A short-lived access token for the session's owner. Refresh
tokens never enter a sandbox: when the access token expires, Claude asks
the control plane for a new one through its host. A session gets the
@@ -664,7 +672,9 @@ Cloudflare does around the Worker, its server (`deploy/standalone`) does:
to it; once none is, the control plane revokes them at Linear and
deletes them (within the hour if Linear doesn't answer), and it forgets
them as soon as Linear says the app was uninstalled or refuses to renew
- them. A delegation waiting for someone to link their account keeps what
+ them, which ends every fctl workspace's connection to that Linear
+ workspace: installing the app again, which takes one of its admins,
+ connects only the fctl workspace it is installed from. A delegation waiting for someone to link their account keeps what
Linear sent for an hour, and which Linear agent session reached which
fctl session goes with the session.
- Hosts keep a stopped session's sandbox so it can resume, and remove it
From f1144838acdfa5f59f332bd845272f488ca9b54e Mon Sep 17 00:00:00 2001
From: "fctl-sh[bot]" <343971779+fctl-sh[bot]@users.noreply.github.com>
Date: Fri, 30 Oct 2026 21:30:19 +0000
Subject: [PATCH 2/7] fix(control): keep revoked Linear connections in sight
until reconnected
Ending every fctl workspace's connection when Linear revokes fctl's app
deleted their rows, so a workspace's Linear tab showed nothing about why
its projects stopped working, and the hourly job then forgot the
installation. The connections stay now, revoked since
`workspace_linear.revoked_at` (a new migration), and the tab shows them
revoked with its prompt to connect again; installing the app again clears
only the connection of the workspace it is installed from, and nothing
takes tickets, delegations or replies through a revoked one.
---
.../0042_linear_connections_revoked.sql | 9 +++++++++
crates/fctl-control/src/api/linear.rs | 4 ++--
crates/fctl-control/src/delegations.rs | 8 +++++---
crates/fctl-control/src/linear.rs | 15 ++++++++-------
crates/fctl-control/src/tickets.rs | 12 ++++++++----
docs/architecture.md | 11 ++++++-----
6 files changed, 38 insertions(+), 21 deletions(-)
create mode 100644 crates/fctl-control/migrations/0042_linear_connections_revoked.sql
diff --git a/crates/fctl-control/migrations/0042_linear_connections_revoked.sql b/crates/fctl-control/migrations/0042_linear_connections_revoked.sql
new file mode 100644
index 00000000..05557bb3
--- /dev/null
+++ b/crates/fctl-control/migrations/0042_linear_connections_revoked.sql
@@ -0,0 +1,9 @@
+-- When Linear revokes fctl's app in a Linear workspace (it was uninstalled
+-- there, or its token no longer renews), every fctl workspace's connection
+-- to it ends: installing the app again, which takes one of the Linear
+-- workspace's admins, connects only the fctl workspace it is installed
+-- from, so that the Linear workspace's admins can end what they let fctl
+-- workspaces reach. The others keep their row, revoked since `revoked_at`,
+-- which their Linear tab shows until their admins connect it again or
+-- disconnect it.
+ALTER TABLE workspace_linear ADD COLUMN revoked_at INTEGER;
diff --git a/crates/fctl-control/src/api/linear.rs b/crates/fctl-control/src/api/linear.rs
index 9e09a922..ed502515 100644
--- a/crates/fctl-control/src/api/linear.rs
+++ b/crates/fctl-control/src/api/linear.rs
@@ -193,7 +193,7 @@ pub async fn teams(ctx: &Ctx, id: &str, organization: &str) -> AppResult = q(
&ctx.db,
- "SELECT COUNT(*) AS n FROM workspace_linear WHERE workspace_id = ? AND organization_id = ?",
+ "SELECT COUNT(*) AS n FROM workspace_linear WHERE workspace_id = ? AND organization_id = ? AND revoked_at IS NULL",
&[s(a.id()), s(organization)],
)?
.first()
@@ -529,7 +529,7 @@ async fn install(
q(
&ctx.db,
"INSERT INTO workspace_linear (workspace_id, organization_id, connected_by, linear_user, created_at) VALUES (?, ?, ?, ?, ?) \
- ON CONFLICT (workspace_id, organization_id) DO UPDATE SET connected_by = excluded.connected_by",
+ ON CONFLICT (workspace_id, organization_id) DO UPDATE SET connected_by = excluded.connected_by, revoked_at = NULL",
&[
s(a.id()),
s(&viewer.organization),
diff --git a/crates/fctl-control/src/delegations.rs b/crates/fctl-control/src/delegations.rs
index 1196260d..b5c36266 100644
--- a/crates/fctl-control/src/delegations.rs
+++ b/crates/fctl-control/src/delegations.rs
@@ -92,7 +92,8 @@ async fn thread_of(db: &D1Database, session_id: &str) -> AppResult
-
+ {
+ e.preventDefault();
+ goConfirmed(linearLinkUrl(project.slug)).catch((err: Error) => toast(err.message, "error"));
+ }}
+ >
}>Link Linear account
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 5779748e..95688eb5 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -42,6 +42,15 @@ export async function confirmFirst(): Promise {
return (await confirmer?.()) ?? false;
}
+/**
+ * Follows a link that takes confirming it's you first, where the instance
+ * asks for it, as linking a Linear account does: the browser's own
+ * navigation can't go again by itself once the server refuses it.
+ */
+export async function goConfirmed(href: string): Promise {
+ if (await confirmFirst()) window.location.assign(href);
+}
+
async function request(method: string, path: string, body?: unknown, again = true): Promise {
let res: Response;
try {
diff --git a/web/src/lib/tickets.ts b/web/src/lib/tickets.ts
index 7558c2eb..0713150e 100644
--- a/web/src/lib/tickets.ts
+++ b/web/src/lib/tickets.ts
@@ -47,6 +47,7 @@ export const LINEAR_OUTCOMES = [
"failed",
"other_user",
"not_app",
+ "confirm",
] as const;
export type LinearOutcome = (typeof LINEAR_OUTCOMES)[number];
@@ -79,6 +80,11 @@ export function linearOutcome(o: LinearOutcome): { tone: "ok" | "warn" | "error"
};
case "failed":
return { tone: "error", text: "Linear didn't finish the sign-in. Try again in a minute." };
+ case "confirm":
+ return {
+ tone: "warn",
+ text: "Linking a Linear account takes confirming it's you, so nothing changed. Link it again from here.",
+ };
}
}
diff --git a/web/src/routes/settings.tsx b/web/src/routes/settings.tsx
index c7ecbb30..86700dc4 100644
--- a/web/src/routes/settings.tsx
+++ b/web/src/routes/settings.tsx
@@ -54,7 +54,7 @@ import {
} from "@/components/ui";
import { WorkspaceAvatar } from "@/components/workspace/avatar";
import type * as G from "@/gen";
-import { api, confirmFirst, emails, linearLinkUrl, workspaces as wsApi } from "@/lib/api";
+import { api, confirmFirst, emails, goConfirmed, linearLinkUrl, workspaces as wsApi } from "@/lib/api";
import { avatarImage } from "@/lib/avatar-image";
import { tokenOnRunnersIn } from "@/lib/claude";
import { cn } from "@/lib/cn";
@@ -1067,7 +1067,14 @@ function LinearAccounts({ returned }: { returned?: LinearOutcome }) {
)}