From 86a78108cfe6f54a5d6e5f50c3105ed4d10f7061 Mon Sep 17 00:00:00 2001 From: Aaron Wirick Date: Thu, 29 Oct 2026 22:59:34 +0000 Subject: [PATCH 1/7] Record the account name of a pasted personal token A personal token saved in Settings -> Credentials had no account data. So engrams did not know whose token it was (for GitHub: the login). A connector can now declare `userCredential.token.identity`: one bounded "who am I" request to one of its hosts. The orchestrator sends it with the token in `PutCredentialRequest.identity`. The coordinator runs it with the token before the seal, and keeps the display and workspace names. The account id stays empty, so a later OAuth connect to a different account still replaces the token. If the provider refuses the token, the token is not saved, and the route returns 400 with a plain message. GitHub declares `GET /user` -> `login`. The parse of the probe moves out of `parseOauth` into shared helpers, so the OAuth metadata probe and the token identity probe use one validation. The credentials form now shows the server's message instead of the status line. Co-Authored-By: Claude Opus 5.5 --- .../engram-coordinator/src/grpc_app/oauth.rs | 19 ++- crates/engram-coordinator/src/oauth.rs | 35 ++++- .../tests/oauth_user_credentials.rs | 76 ++++++++- .../proto/engram/app/v1/oauth.proto | 4 + orchestrator/src/__tests__/connectors.test.ts | 33 ++++ orchestrator/src/__tests__/me-oauth.test.ts | 37 ++++- orchestrator/src/connectors/github.json | 8 +- orchestrator/src/connectors/oauth-spec.ts | 30 ++-- orchestrator/src/connectors/registry.ts | 145 +++++++++++------- .../src/gen/engram/app/v1/oauth_pb.ts | 11 +- orchestrator/src/routes/me.ts | 51 ++++-- .../components/settings/TokensPanel.test.tsx | 34 ++++ web/src/components/settings/TokensPanel.tsx | 5 +- web/src/gen/engram/app/v1/oauth_pb.ts | 11 +- web/src/hooks/useCredentials.ts | 7 +- 15 files changed, 402 insertions(+), 104 deletions(-) diff --git a/crates/engram-coordinator/src/grpc_app/oauth.rs b/crates/engram-coordinator/src/grpc_app/oauth.rs index f980d26a6..2650572be 100644 --- a/crates/engram-coordinator/src/grpc_app/oauth.rs +++ b/crates/engram-coordinator/src/grpc_app/oauth.rs @@ -102,13 +102,17 @@ fn metadata_from_proto(metadata: Option) -> RedirectM let metadata = metadata.unwrap_or_default(); RedirectMetadataSpec { from_token_response: metadata.from_token_response.into_iter().collect(), - probe: metadata.probe.map(|probe| RedirectMetadataProbe { - method: probe.method, - host: probe.host, - path: probe.path, - body: probe.body, - map: probe.map.into_iter().collect(), - }), + probe: metadata.probe.map(probe_from_proto), + } +} + +fn probe_from_proto(probe: app::RedirectMetadataProbe) -> RedirectMetadataProbe { + RedirectMetadataProbe { + method: probe.method, + host: probe.host, + path: probe.path, + body: probe.body, + map: probe.map.into_iter().collect(), } } @@ -446,6 +450,7 @@ impl app::o_auth_credential_service_server::OAuthCredentialService for AppOAuthC .put_static_credential( &key(req.subject, req.provider).map_err(Status::invalid_argument)?, &req.secret, + req.identity.map(probe_from_proto).as_ref(), ) .await .map_err(oauth_status)?; diff --git a/crates/engram-coordinator/src/oauth.rs b/crates/engram-coordinator/src/oauth.rs index 33c27f094..4d02b6a1a 100644 --- a/crates/engram-coordinator/src/oauth.rs +++ b/crates/engram-coordinator/src/oauth.rs @@ -23,6 +23,11 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::{oneshot, Semaphore}; +use crate::oauth_redirect::{ + extract_mapped, RedirectMetadataProbe, RedirectMetadataSpec, META_DISPLAY_NAME, + META_WORKSPACE_ID, META_WORKSPACE_NAME, +}; + pub const OPENAI_CODEX_PROVIDER: &str = "openai-codex"; pub const MAX_OAUTH_BUNDLE_BYTES: usize = 256 * 1024; const FLOW_TTL: chrono::Duration = chrono::Duration::minutes(15); @@ -436,11 +441,18 @@ impl OAuthManager { /// OAuth over PAT are both intended), so no account-identity check runs; /// the read-version CAS still fences concurrent writers. /// + /// `identity` is the connector's optional "who am I" probe. It runs with + /// the token before the seal: its answer names the account (GitHub: the + /// login), and a token the provider rejects is not saved. The account id + /// stays empty even when the probe maps one, so a later OAuth connect to + /// a different account still replaces the token. + /// /// [`ConnectorOAuthKind::StaticToken`]: engram_core::types::connector_oauth::ConnectorOAuthKind::StaticToken pub async fn put_static_credential( &self, key: &OAuthCredentialKey, secret: &str, + identity: Option<&RedirectMetadataProbe>, ) -> Result { validate_key(key)?; if key.subject_kind != OAuthSubjectKind::UserConnector { @@ -454,6 +466,24 @@ impl OAuthManager { "credential value must not be empty".into(), )); } + let mut fields = BTreeMap::new(); + if let Some(probe) = identity { + RedirectMetadataSpec { + from_token_response: BTreeMap::new(), + probe: Some(probe.clone()), + } + .validate() + .map_err(OAuthServiceError::BadRequest)?; + let json = self.run_probe(probe, secret).await?; + extract_mapped(&probe.map, &json, &mut fields); + } + let metadata = OAuthAccountMetadata { + account_id: String::new(), + display_name: fields.get(META_DISPLAY_NAME).cloned(), + plan_type: None, + workspace_id: fields.get(META_WORKSPACE_ID).cloned(), + workspace_name: fields.get(META_WORKSPACE_NAME).cloned(), + }; use engram_core::types::connector_oauth::{ ConnectorOAuthBundle, ConnectorOAuthKind, CONNECTOR_OAUTH_BUNDLE_VERSION, }; @@ -476,9 +506,8 @@ impl OAuthManager { key, ValidatedOAuthBundle { payload, - // No provider-verified identity exists for a pasted token; - // an empty account id opts out of account-switch detection. - metadata: OAuthAccountMetadata::default(), + // An empty account id opts out of account-switch detection. + metadata, expires_at: None, }, current.map(|row| row.version), diff --git a/crates/engram-coordinator/tests/oauth_user_credentials.rs b/crates/engram-coordinator/tests/oauth_user_credentials.rs index 1cb75638e..d7f36e796 100644 --- a/crates/engram-coordinator/tests/oauth_user_credentials.rs +++ b/crates/engram-coordinator/tests/oauth_user_credentials.rs @@ -7,11 +7,13 @@ use std::collections::BTreeMap; use std::sync::Arc; use axum::extract::State; -use axum::routing::post; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; use axum::{Json, Router}; use engram_coordinator::oauth::{OAuthManager, OAuthServiceError}; use engram_coordinator::oauth_redirect::{ RedirectMetadataProbe, RedirectMetadataSpec, RedirectOauthSpec, META_ACCOUNT_ID, + META_DISPLAY_NAME, }; use engram_core::traits::{Clock, MetadataStore}; use engram_core::types::connector_oauth::{ConnectorOAuthBundle, ConnectorOAuthKind}; @@ -40,6 +42,18 @@ async fn serve(provider: Provider) -> String { "/graphql", post(|State(p): State| async move { Json(p.viewer_response.lock().clone()) }), ) + // A token "who am I": only the good token is known to the provider. + .route( + "/user", + get(|headers: HeaderMap| async move { + let auth = headers.get("authorization").and_then(|v| v.to_str().ok()); + if auth == Some("Bearer pat-good") { + Ok(Json(json!({"id": 42, "login": "octo-dev"}))) + } else { + Err(StatusCode::UNAUTHORIZED) + } + }), + ) .with_state(provider); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -176,7 +190,7 @@ async fn put_static_credential_seals_resolves_and_replaces() { let row = fx .manager - .put_static_credential(&key, " pat-secret-1 ") + .put_static_credential(&key, " pat-secret-1 ", None) .await .expect("put"); assert_eq!( @@ -201,7 +215,7 @@ async fn put_static_credential_seals_resolves_and_replaces() { // Replacement is a CAS write over the current version. let replaced = fx .manager - .put_static_credential(&key, "pat-secret-2") + .put_static_credential(&key, "pat-secret-2", None) .await .expect("replace"); assert!(replaced.version > row.version); @@ -223,25 +237,71 @@ async fn put_static_credential_rejects_bad_subjects_and_empty_values() { }; let err = fx .manager - .put_static_credential(&connector_key, "pat") + .put_static_credential(&connector_key, "pat", None) .await .expect_err("connector subjects must be rejected"); assert!(matches!(err, OAuthServiceError::BadRequest(_))); let err = fx .manager - .put_static_credential(&user_key("sentry"), " ") + .put_static_credential(&user_key("sentry"), " ", None) .await .expect_err("blank values must be rejected"); assert!(matches!(err, OAuthServiceError::BadRequest(_))); } +fn identity_probe(host: &str) -> RedirectMetadataProbe { + RedirectMetadataProbe { + method: "GET".into(), + host: host.to_string(), + path: "/user".into(), + body: String::new(), + map: BTreeMap::from([ + (META_DISPLAY_NAME.to_string(), "login".to_string()), + (META_ACCOUNT_ID.to_string(), "id".to_string()), + ]), + } +} + +#[tokio::test] +async fn identity_probe_names_the_account_but_records_no_account_id() { + let fx = fixture().await; + let key = user_key("github"); + let row = fx + .manager + .put_static_credential(&key, " pat-good ", Some(&identity_probe(&fx.host))) + .await + .expect("put"); + assert_eq!(row.metadata.display_name.as_deref(), Some("octo-dev")); + // A mapped id is ignored: a stored id would block a later OAuth connect + // to a different account. + assert_eq!(row.metadata.account_id, ""); +} + +#[tokio::test] +async fn identity_probe_rejects_a_token_the_provider_refuses() { + let fx = fixture().await; + let key = user_key("github"); + let err = fx + .manager + .put_static_credential(&key, "pat-bad", Some(&identity_probe(&fx.host))) + .await + .expect_err("a refused token must not be saved"); + assert!(matches!(err, OAuthServiceError::Driver(_)), "{err:?}"); + assert!(fx + .meta + .get_oauth_credential(&key) + .await + .expect("get") + .is_none()); +} + #[tokio::test] async fn static_tokens_never_enter_the_refresh_sweep() { let fx = fixture().await; let key = user_key("sentry"); fx.manager - .put_static_credential(&key, "pat-secret-1") + .put_static_credential(&key, "pat-secret-1", None) .await .expect("put"); @@ -316,7 +376,7 @@ async fn pat_and_oauth_deliberately_replace_each_other() { // PAT first; an OAuth connect replaces it (empty stored account id // records no identity to protect). fx.manager - .put_static_credential(&key, "pat-secret-1") + .put_static_credential(&key, "pat-secret-1", None) .await .expect("put PAT"); connect_user_oauth(&fx, &sp).await; @@ -327,7 +387,7 @@ async fn pat_and_oauth_deliberately_replace_each_other() { // A PAT write replaces the OAuth credential without an account check: // the user pasted it on purpose. fx.manager - .put_static_credential(&key, "pat-secret-2") + .put_static_credential(&key, "pat-secret-2", None) .await .expect("PAT over OAuth"); let bundle = open_bundle(&fx, &key).await; diff --git a/crates/engram-protocol/proto/engram/app/v1/oauth.proto b/crates/engram-protocol/proto/engram/app/v1/oauth.proto index 6de715c36..137e3ac8b 100644 --- a/crates/engram-protocol/proto/engram/app/v1/oauth.proto +++ b/crates/engram-protocol/proto/engram/app/v1/oauth.proto @@ -143,6 +143,10 @@ message PutCredentialRequest { string provider = 2; // The raw token value. Sealed at rest; never logged or echoed back. string secret = 3; + // Optional "who am I" request, sent with the token before it is sealed. + // It fills the display and workspace names; the account id stays empty. + // A failing probe rejects the token. + RedirectMetadataProbe identity = 4; } message PutCredentialResponse { OAuthCredentialMeta credential = 1; } diff --git a/orchestrator/src/__tests__/connectors.test.ts b/orchestrator/src/__tests__/connectors.test.ts index c230d8ec9..4ec63db56 100644 --- a/orchestrator/src/__tests__/connectors.test.ts +++ b/orchestrator/src/__tests__/connectors.test.ts @@ -768,6 +768,10 @@ describe("on-disk registry", () => { expect(userScopes).toContain("chat:write"); expect(reg.get("sentry")!.userCredential?.token?.hint).toContain("User Auth Tokens"); expect(reg.get("github")!.userCredential?.oauth).toBeUndefined(); + expect(reg.get("github")!.userCredential?.token?.identity).toEqual({ + path: "/user", + map: { displayName: "login" }, + }); expect(reg.get("github")!.userCredential?.inject).toEqual({ header: "Authorization", template: "Bearer {}", @@ -1431,6 +1435,35 @@ describe("user-scoped credentials (ADR 0115)", () => { expect(c.userCredential?.inject).toEqual({ header: "Authorization", template: "Bearer {}" }); }); + test("accepts a token identity probe against one of the connector's hosts", () => { + const withIdentity = { + ...acmeRaw, + userCredential: { + token: { hint: "x", identity: { path: "/me", map: { displayName: "login" } } }, + }, + }; + expect(parseConnector(withIdentity, "acme").userCredential?.token).toEqual({ + hint: "x", + identity: { path: "/me", map: { displayName: "login" } }, + }); + }); + + test("rejects a token identity probe that maps accountId or leaves the hosts", () => { + const token = (identity: unknown) => ({ + ...acmeRaw, + userCredential: { token: { hint: "x", identity } }, + }); + expect(() => + parseConnector(token({ path: "/me", map: { accountId: "id" } }), "t"), + ).toThrow(/must not map "accountId"/); + expect(() => + parseConnector(token({ host: "evil.test", path: "/me", map: { displayName: "login" } }), "t"), + ).toThrow(/token.identity.host/); + expect(() => parseConnector(token({ path: "/me", map: {} }), "t")).toThrow( + /token.identity.map/, + ); + }); + test("rejects an empty facet (no mode)", () => { expect(() => parseConnector({ ...acmeRaw, userCredential: {} }, "t")).toThrow(/at least one mode/); }); diff --git a/orchestrator/src/__tests__/me-oauth.test.ts b/orchestrator/src/__tests__/me-oauth.test.ts index b5d57c72d..eff9f0d42 100644 --- a/orchestrator/src/__tests__/me-oauth.test.ts +++ b/orchestrator/src/__tests__/me-oauth.test.ts @@ -3,6 +3,7 @@ import { makeMeRoute, type OAuthCredentialClient } from "../routes/me.ts"; import type { UserSecretStore } from "../db/user-secrets.ts"; import { OauthSubjectKind } from "../gen/engram/app/v1/oauth_pb.ts"; import { invalidateRegistry } from "../connectors/registry.ts"; +import { Code, ConnectError } from "@connectrpc/connect"; const secrets: UserSecretStore = { put: async () => {}, @@ -130,7 +131,7 @@ describe("/me connector credentials (ADR 0115)", () => { function fakeOauth() { const calls: { - put: Array<{ subject: { kind: OauthSubjectKind; id: string }; provider: string; secret: string }>; + put: Array[0]>; disconnect: Array<{ subject: { kind: OauthSubjectKind; id: string }; provider: string }>; } = { put: [], disconnect: [] }; const oauth: OAuthCredentialClient = { @@ -175,6 +176,40 @@ describe("/me connector credentials (ADR 0115)", () => { ]); }); + test("PUT sends the connector's identity probe with the token", async () => { + const { oauth, calls } = fakeOauth(); + // GitHub (shipped) names the account behind a pasted token by its login. + const response = await appWith(oauth).request("/api/v1/me/connector-credentials/github", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: "ghp-1" }), + }); + expect(response.status).toBe(204); + expect(calls.put).toHaveLength(1); + expect(calls.put[0]).toMatchObject({ + provider: "github", + secret: "ghp-1", + identity: { method: "GET", host: "api.github.com", path: "/user", map: { display_name: "login" } }, + }); + }); + + test("PUT 400s a token the provider does not confirm", async () => { + const { oauth } = fakeOauth(); + oauth.putCredential = async () => { + const metadata = new Headers({ "engram-error-slug": "probe_failed" }); + throw new ConnectError("metadata probe returned HTTP 401", Code.Unavailable, metadata); + }; + const response = await appWith(oauth).request("/api/v1/me/connector-credentials/github", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: "ghp-bad" }), + }); + expect(response.status).toBe(400); + expect(await response.text()).toBe( + "Could not confirm this token with GitHub. Check the token and try again.", + ); + }); + test("PUT 404s for a provider without token mode and 400s an empty value", async () => { const { oauth, calls } = fakeOauth(); const app = appWith(oauth); diff --git a/orchestrator/src/connectors/github.json b/orchestrator/src/connectors/github.json index cadf83bd8..3116b80d5 100644 --- a/orchestrator/src/connectors/github.json +++ b/orchestrator/src/connectors/github.json @@ -18,7 +18,13 @@ }, "userCredential": { "token": { - "hint": "Create a fine-grained personal access token under GitHub Settings \u2192 Developer settings, with the permissions you want sessions to hold." + "hint": "Create a fine-grained personal access token under GitHub Settings \u2192 Developer settings, with the permissions you want sessions to hold.", + "identity": { + "path": "/user", + "map": { + "displayName": "login" + } + } }, "inject": { "header": "Authorization", diff --git a/orchestrator/src/connectors/oauth-spec.ts b/orchestrator/src/connectors/oauth-spec.ts index fc9a7fbee..6c39620f8 100644 --- a/orchestrator/src/connectors/oauth-spec.ts +++ b/orchestrator/src/connectors/oauth-spec.ts @@ -7,7 +7,7 @@ */ import { create } from "@bufbuild/protobuf"; -import type { Connector, OauthMetadataField } from "./registry.ts"; +import type { Connector, OauthMetadataField, OauthMetadataProbe } from "./registry.ts"; import { ClientCredentialsSpecSchema, RedirectMetadataProbeSchema, @@ -15,6 +15,7 @@ import { RedirectOauthSpecSchema, ServiceAccountSpecSchema, type ClientCredentialsSpec, + type RedirectMetadataProbe, type RedirectMetadataSpec, type RedirectOauthSpec, type ServiceAccountSpec, @@ -37,6 +38,23 @@ function wireMap( return out; } +function wireProbe(connector: Connector, probe: OauthMetadataProbe): RedirectMetadataProbe { + return create(RedirectMetadataProbeSchema, { + method: probe.method ?? "GET", + host: probe.host ?? connector.hosts[0] ?? "", + path: probe.path, + body: probe.body ?? "", + map: wireMap(probe.map), + }); +} + +/** The wire probe that names the account behind a pasted personal token, + * when the connector declares one. */ +export function userTokenIdentityProbe(connector: Connector): RedirectMetadataProbe | undefined { + const identity = connector.userCredential?.token?.identity; + return identity ? wireProbe(connector, identity) : undefined; +} + /** Build the wire spec for a connector's oauth facet. `extraParams` lets a * caller append flow extras (e.g. `prompt: "consent"` on a reconnect); facet * params win on collision so a connector's declared behavior is stable. */ @@ -45,15 +63,7 @@ function wireMetadata(connector: Connector): RedirectMetadataSpec | undefined { if (!oauth?.metadata) return undefined; return create(RedirectMetadataSpecSchema, { fromTokenResponse: wireMap(oauth.metadata.fromTokenResponse), - probe: oauth.metadata.probe - ? create(RedirectMetadataProbeSchema, { - method: oauth.metadata.probe.method ?? "GET", - host: oauth.metadata.probe.host ?? connector.hosts[0] ?? "", - path: oauth.metadata.probe.path, - body: oauth.metadata.probe.body ?? "", - map: wireMap(oauth.metadata.probe.map), - }) - : undefined, + probe: oauth.metadata.probe ? wireProbe(connector, oauth.metadata.probe) : undefined, }); } diff --git a/orchestrator/src/connectors/registry.ts b/orchestrator/src/connectors/registry.ts index b81008858..985deb4ca 100644 --- a/orchestrator/src/connectors/registry.ts +++ b/orchestrator/src/connectors/registry.ts @@ -360,10 +360,19 @@ export interface UserCredentialFacet { /** `true` reuses the facet as-is (minus its authorize extras); an object * overrides the user flow's shape. */ oauth?: true | UserOauthOverrides; - token?: { hint: string }; + token?: UserTokenFacet; inject?: { header: string; template: string }; } +export interface UserTokenFacet { + /** The setup line shown in Settings → Credentials. */ + hint: string; + /** Asks the provider who owns a pasted token when the user saves it. The + * answer names the credential's account (GitHub: the login), and a token + * the provider rejects is not saved. */ + identity?: OauthMetadataProbe; +} + /** * The `settings` facet: administrator-set, NON-SECRET connector parameters. * @@ -1224,6 +1233,71 @@ const OAUTH_METADATA_FIELDS: ReadonlySet = new Set([ ]); const OAUTH_METADATA_SEGMENT_RE = /^[A-Za-z0-9_]{1,64}$/; +/** Metadata field → bounded dot-path, as the coordinator's driver accepts it. + * `label` is the JSON path of the map, for error text. */ +function parseMetadataMap( + where: string, + label: string, + v: unknown, +): Partial> { + if (typeof v !== "object" || v === null || Array.isArray(v)) { + fail(where, `"${label}" must be an object of field → dot-path`); + } + const out: Partial> = {}; + for (const [k, path] of Object.entries(v as Record)) { + if (!OAUTH_METADATA_FIELDS.has(k)) { + fail(where, `"${label}" key "${k}" is not a metadata field (${[...OAUTH_METADATA_FIELDS].join(", ")})`); + } + if (typeof path !== "string" || !path) fail(where, `"${label}.${k}" must be a non-empty dot-path`); + const segments = path.split("."); + if (segments.length > MAX_OAUTH_METADATA_PATH_SEGMENTS) { + fail(where, `"${label}.${k}" has too many path segments (max ${MAX_OAUTH_METADATA_PATH_SEGMENTS})`); + } + for (const seg of segments) { + if (!OAUTH_METADATA_SEGMENT_RE.test(seg) || UNSAFE_OBJECT_PATH_SEGMENTS.has(seg)) { + fail(where, `"${label}.${k}" segment "${seg}" is not allowed`); + } + } + out[k as OauthMetadataField] = path; + } + return out; +} + +/** One "who am I" request against one of the connector's hosts. Shared by + * the OAuth facet's metadata and a personal token's identity probe. */ +function parseMetadataProbe( + where: string, + label: string, + raw: unknown, + hosts: string[], +): OauthMetadataProbe { + if (typeof raw !== "object" || raw === null) fail(where, `"${label}" must be an object`); + const p = raw as Record; + if (p.method !== undefined && p.method !== "GET" && p.method !== "POST") { + fail(where, `"${label}.method" must be "GET" or "POST"`); + } + if (p.host !== undefined) { + if (typeof p.host !== "string" || !hosts.includes(p.host)) { + fail(where, `"${label}.host" must be one of the connector's hosts (${hosts.join(", ")})`); + } + } + if (typeof p.path !== "string" || !p.path.startsWith("/") || p.path.length > MAX_OAUTH_PARAM_LENGTH) { + fail(where, `"${label}.path" must be a short absolute path`); + } + if (p.body !== undefined && (typeof p.body !== "string" || p.body.length > MAX_OAUTH_PROBE_BODY)) { + fail(where, `"${label}.body" must be a bounded string`); + } + const map = parseMetadataMap(where, `${label}.map`, p.map); + if (Object.keys(map).length === 0) fail(where, `"${label}.map" must map at least one field`); + return { + ...(p.method !== undefined ? { method: p.method as "GET" | "POST" } : {}), + ...(p.host !== undefined ? { host: p.host as string } : {}), + path: p.path, + ...(p.body !== undefined ? { body: p.body as string } : {}), + map, + }; +} + /** Validate the OAuth acquisition facet (admin-trust boundary). `tokenUrl` must * resolve to one of the connector's `hosts` (it receives the client secret, so an * admin-authored connector can't ship it elsewhere); `authorizeUrl` may instead @@ -1370,67 +1444,16 @@ function parseOauth(where: string, raw: unknown, hosts: string[]): OauthFacet { ); } - const metadataMap = ( - field: string, - v: unknown, - ): Partial> => { - if (typeof v !== "object" || v === null || Array.isArray(v)) { - fail(where, `"oauth.${field}" must be an object of field → dot-path`); - } - const out: Partial> = {}; - for (const [k, path] of Object.entries(v as Record)) { - if (!OAUTH_METADATA_FIELDS.has(k)) { - fail(where, `"oauth.${field}" key "${k}" is not a metadata field (${[...OAUTH_METADATA_FIELDS].join(", ")})`); - } - if (typeof path !== "string" || !path) fail(where, `"oauth.${field}.${k}" must be a non-empty dot-path`); - const segments = path.split("."); - if (segments.length > MAX_OAUTH_METADATA_PATH_SEGMENTS) { - fail(where, `"oauth.${field}.${k}" has too many path segments (max ${MAX_OAUTH_METADATA_PATH_SEGMENTS})`); - } - for (const seg of segments) { - if (!OAUTH_METADATA_SEGMENT_RE.test(seg) || UNSAFE_OBJECT_PATH_SEGMENTS.has(seg)) { - fail(where, `"oauth.${field}.${k}" segment "${seg}" is not allowed`); - } - } - out[k as OauthMetadataField] = path; - } - return out; - }; - let metadata: OauthMetadataSpec | undefined; if (o.metadata !== undefined) { if (typeof o.metadata !== "object" || o.metadata === null) fail(where, '"oauth.metadata" must be an object'); const m = o.metadata as Record; metadata = {}; if (m.fromTokenResponse !== undefined) { - metadata.fromTokenResponse = metadataMap("metadata.fromTokenResponse", m.fromTokenResponse); + metadata.fromTokenResponse = parseMetadataMap(where, "oauth.metadata.fromTokenResponse", m.fromTokenResponse); } if (m.probe !== undefined) { - if (typeof m.probe !== "object" || m.probe === null) fail(where, '"oauth.metadata.probe" must be an object'); - const p = m.probe as Record; - if (p.method !== undefined && p.method !== "GET" && p.method !== "POST") { - fail(where, '"oauth.metadata.probe.method" must be "GET" or "POST"'); - } - if (p.host !== undefined) { - if (typeof p.host !== "string" || !hosts.includes(p.host)) { - fail(where, `"oauth.metadata.probe.host" must be one of the connector's hosts (${hosts.join(", ")})`); - } - } - if (typeof p.path !== "string" || !p.path.startsWith("/") || p.path.length > MAX_OAUTH_PARAM_LENGTH) { - fail(where, '"oauth.metadata.probe.path" must be a short absolute path'); - } - if (p.body !== undefined && (typeof p.body !== "string" || p.body.length > MAX_OAUTH_PROBE_BODY)) { - fail(where, '"oauth.metadata.probe.body" must be a bounded string'); - } - const map = metadataMap("metadata.probe.map", p.map); - if (Object.keys(map).length === 0) fail(where, '"oauth.metadata.probe.map" must map at least one field'); - metadata.probe = { - ...(p.method !== undefined ? { method: p.method as "GET" | "POST" } : {}), - ...(p.host !== undefined ? { host: p.host as string } : {}), - path: p.path, - ...(p.body !== undefined ? { body: p.body as string } : {}), - map, - }; + metadata.probe = parseMetadataProbe(where, "oauth.metadata.probe", m.probe, hosts); } if (metadata.fromTokenResponse === undefined && metadata.probe === undefined) { fail(where, '"oauth.metadata" must declare fromTokenResponse and/or probe'); @@ -2355,14 +2378,24 @@ export function parseConnector( fail(uw, '"oauth" must be true or an overrides object'); } } - let token: { hint: string } | undefined; + let token: UserTokenFacet | undefined; if (uc.token !== undefined) { if (typeof uc.token !== "object" || uc.token === null) fail(uw, '"token" must be an object'); const t = uc.token as Record; if (typeof t.hint !== "string" || !t.hint.trim() || t.hint.length > MAX_DISPLAY_BLURB) { fail(uw, `"token.hint" must be a non-empty string (max ${MAX_DISPLAY_BLURB} chars)`); } - token = { hint: t.hint }; + const identity = + t.identity !== undefined + ? parseMetadataProbe(uw, "token.identity", t.identity, hosts) + : undefined; + // A pasted token records no account id: a stored id would block a later + // OAuth connect to a different account, and a paste is a deliberate + // replacement. + if (identity?.map.accountId !== undefined) { + fail(uw, '"token.identity.map" must not map "accountId"'); + } + token = { hint: t.hint, ...(identity ? { identity } : {}) }; } if (userOauth === undefined && token === undefined) { fail(uw, 'must declare at least one mode ("oauth" and/or "token")'); diff --git a/orchestrator/src/gen/engram/app/v1/oauth_pb.ts b/orchestrator/src/gen/engram/app/v1/oauth_pb.ts index fbdfad680..49aaad600 100644 --- a/orchestrator/src/gen/engram/app/v1/oauth_pb.ts +++ b/orchestrator/src/gen/engram/app/v1/oauth_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file engram/app/v1/oauth.proto. */ export const file_engram_app_v1_oauth: GenFile = /*@__PURE__*/ - fileDesc("ChllbmdyYW0vYXBwL3YxL29hdXRoLnByb3RvEg1lbmdyYW0uYXBwLnYxIkkKDE9BdXRoU3ViamVjdBItCgRraW5kGAEgASgOMh8uZW5ncmFtLmFwcC52MS5PYXV0aFN1YmplY3RLaW5kEgoKAmlkGAIgASgJIlIKEEJlZ2luRmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJImgKEUJlZ2luRmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIYChB2ZXJpZmljYXRpb25fdXJsGAIgASgJEhEKCXVzZXJfY29kZRgDIAEoCSJPCg5HZXRGbG93UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSDwoHZmxvd19pZBgCIAEoCSI5Cg9HZXRGbG93UmVzcG9uc2USJgoEZmxvdxgBIAEoCzIYLmVuZ3JhbS5hcHAudjEuT0F1dGhGbG93IlIKEUNhbmNlbEZsb3dSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIPCgdmbG93X2lkGAIgASgJIjwKEkNhbmNlbEZsb3dSZXNwb25zZRImCgRmbG93GAEgASgLMhguZW5ncmFtLmFwcC52MS5PQXV0aEZsb3ciiQEKCU9BdXRoRmxvdxIKCgJpZBgBIAEoCRIQCghwcm92aWRlchgCIAEoCRIOCgZzdGF0dXMYAyABKAkSFwoKZXJyb3JfY29kZRgEIAEoCUgAiAEBEhIKCmV4cGlyZXNfYXQYBSABKAkSEgoKdXBkYXRlZF9hdBgGIAEoCUINCgtfZXJyb3JfY29kZSLEAQoUT0F1dGhBY2NvdW50TWV0YWRhdGESGQoMZGlzcGxheV9uYW1lGAEgASgJSACIAQESFgoJcGxhbl90eXBlGAIgASgJSAGIAQESGQoMd29ya3NwYWNlX2lkGAMgASgJSAKIAQESGwoOd29ya3NwYWNlX25hbWUYBCABKAlIA4gBAUIPCg1fZGlzcGxheV9uYW1lQgwKCl9wbGFuX3R5cGVCDwoNX3dvcmtzcGFjZV9pZEIRCg9fd29ya3NwYWNlX25hbWUi4QEKE09BdXRoQ3JlZGVudGlhbE1ldGESEAoIcHJvdmlkZXIYASABKAkSDwoHdmVyc2lvbhgCIAEoAxIRCgljb25uZWN0ZWQYAyABKAgSNAoHYWNjb3VudBgEIAEoCzIjLmVuZ3JhbS5hcHAudjEuT0F1dGhBY2NvdW50TWV0YWRhdGESEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJEg4KBnN0YXR1cxgHIAEoCRISCgpleHBpcmVzX2F0GAggASgJEhIKCnN1YmplY3RfaWQYCSABKAkiRgoWTGlzdENyZWRlbnRpYWxzUmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QiUgoXTGlzdENyZWRlbnRpYWxzUmVzcG9uc2USNwoLY3JlZGVudGlhbHMYASADKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEibQoRRGlzY29ubmVjdFJlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMiTAoSRGlzY29ubmVjdFJlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEiZgoUUHV0Q3JlZGVudGlhbFJlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg4KBnNlY3JldBgDIAEoCSJPChVQdXRDcmVkZW50aWFsUmVzcG9uc2USNgoKY3JlZGVudGlhbBgBIAEoCzIiLmVuZ3JhbS5hcHAudjEuT0F1dGhDcmVkZW50aWFsTWV0YSIsChlMb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0Eg8KB2Zsb3dfaWQYASABKAkibAoaTG9va3VwUmVkaXJlY3RGbG93UmVzcG9uc2USLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg4KBnN0YXR1cxgDIAEoCSKgAwoRUmVkaXJlY3RPYXV0aFNwZWMSFQoNYXV0aG9yaXplX3VybBgBIAEoCRIRCgl0b2tlbl91cmwYAiABKAkSDgoGc2NvcGVzGAMgAygJEhcKD3Njb3BlX2RlbGltaXRlchgEIAEoCRJaChZleHRyYV9hdXRob3JpemVfcGFyYW1zGAUgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE9hdXRoU3BlYy5FeHRyYUF1dGhvcml6ZVBhcmFtc0VudHJ5EhUKDWNsaWVudF9pZF9yZWYYBiABKAkSGQoRY2xpZW50X3NlY3JldF9yZWYYByABKAkSDAoEcGtjZRgIIAEoCBI1CghtZXRhZGF0YRgJIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMSFAoMc2NvcGVzX3BhcmFtGAogASgJEhIKCmdyYW50X3BhdGgYCyABKAkaOwoZRXh0cmFBdXRob3JpemVQYXJhbXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIt4BChRSZWRpcmVjdE1ldGFkYXRhU3BlYxJXChNmcm9tX3Rva2VuX3Jlc3BvbnNlGAEgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhU3BlYy5Gcm9tVG9rZW5SZXNwb25zZUVudHJ5EjMKBXByb2JlGAIgASgLMiQuZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhUHJvYmUaOAoWRnJvbVRva2VuUmVzcG9uc2VFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIrkBChVSZWRpcmVjdE1ldGFkYXRhUHJvYmUSDgoGbWV0aG9kGAEgASgJEgwKBGhvc3QYAiABKAkSDAoEcGF0aBgDIAEoCRIMCgRib2R5GAQgASgJEjoKA21hcBgFIAMoCzItLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVByb2JlLk1hcEVudHJ5GioKCE1hcEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi2AEKFUNsaWVudENyZWRlbnRpYWxzU3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIVCg1jbGllbnRfaWRfcmVmGAQgASgJEhkKEWNsaWVudF9zZWNyZXRfcmVmGAUgASgJEhoKEnRva2VuX3JlcXVlc3RfYm9keRgGIAEoCRI1CghtZXRhZGF0YRgHIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMikwEKHUdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIQCghwcm92aWRlchgCIAEoCRIyCgRzcGVjGAMgASgLMiQuZW5ncmFtLmFwcC52MS5DbGllbnRDcmVkZW50aWFsc1NwZWMiWAoeR3JhbnRDbGllbnRDcmVkZW50aWFsc1Jlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEiqQEKElNlcnZpY2VBY2NvdW50U3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIPCgdrZXlfcmVmGAQgASgJEg8KB3N1YmplY3QYBSABKAkSNQoIbWV0YWRhdGEYBiABKAsyIy5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0TWV0YWRhdGFTcGVjIo0BChpHcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSEAoIcHJvdmlkZXIYAiABKAkSLwoEc3BlYxgDIAEoCzIhLmVuZ3JhbS5hcHAudjEuU2VydmljZUFjY291bnRTcGVjIlUKG0dyYW50U2VydmljZUFjY291bnRSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIqABChhCZWdpblJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEi4KBHNwZWMYAyABKAsyIC5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0T2F1dGhTcGVjEhQKDHJlZGlyZWN0X3VyaRgEIAEoCSJaChlCZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIVCg1hdXRob3JpemVfdXJsGAIgASgJIsIBChtDb21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg8KB2Zsb3dfaWQYAyABKAkSDAoEY29kZRgEIAEoCRIUCgxyZWRpcmVjdF91cmkYBSABKAkSLgoEc3BlYxgGIAEoCzIgLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RPYXV0aFNwZWMiVgocQ29tcGxldGVSZWRpcmVjdEZsb3dSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIkkKHUZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJIloKHkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZRIQCghwcm92aWRlchgBIAEoCRIPCgd2ZXJzaW9uGAIgASgDEhUKDW9wYXF1ZV9idW5kbGUYAyABKAwiewoeVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMSFQoNb3BhcXVlX2J1bmRsZRgEIAEoDCJbCh9VcGRhdGVTZXNzaW9uQ3JlZGVudGlhbFJlc3BvbnNlEhAKCHByb3ZpZGVyGAEgASgJEg8KB3ZlcnNpb24YAiABKAMSFQoNb3BhcXVlX2J1bmRsZRgDIAEoDCq4AQoQT2F1dGhTdWJqZWN0S2luZBIiCh5PQVVUSF9TVUJKRUNUX0tJTkRfVU5TUEVDSUZJRUQQABIbChdPQVVUSF9TVUJKRUNUX0tJTkRfVVNFUhABEiAKHE9BVVRIX1NVQkpFQ1RfS0lORF9DT05ORUNUT1IQAhIaChZPQVVUSF9TVUJKRUNUX0tJTkRfTUNQEAMSJQohT0FVVEhfU1VCSkVDVF9LSU5EX1VTRVJfQ09OTkVDVE9SEAQysAoKFk9BdXRoQ3JlZGVudGlhbFNlcnZpY2USTgoJQmVnaW5GbG93Eh8uZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXF1ZXN0GiAuZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXNwb25zZRJICgdHZXRGbG93Eh0uZW5ncmFtLmFwcC52MS5HZXRGbG93UmVxdWVzdBoeLmVuZ3JhbS5hcHAudjEuR2V0Rmxvd1Jlc3BvbnNlElEKCkNhbmNlbEZsb3cSIC5lbmdyYW0uYXBwLnYxLkNhbmNlbEZsb3dSZXF1ZXN0GiEuZW5ncmFtLmFwcC52MS5DYW5jZWxGbG93UmVzcG9uc2USYAoPTGlzdENyZWRlbnRpYWxzEiUuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXF1ZXN0GiYuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXNwb25zZRJRCgpEaXNjb25uZWN0EiAuZW5ncmFtLmFwcC52MS5EaXNjb25uZWN0UmVxdWVzdBohLmVuZ3JhbS5hcHAudjEuRGlzY29ubmVjdFJlc3BvbnNlEloKDVB1dENyZWRlbnRpYWwSIy5lbmdyYW0uYXBwLnYxLlB1dENyZWRlbnRpYWxSZXF1ZXN0GiQuZW5ncmFtLmFwcC52MS5QdXRDcmVkZW50aWFsUmVzcG9uc2USaQoSTG9va3VwUmVkaXJlY3RGbG93EiguZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0GikuZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXNwb25zZRJmChFCZWdpblJlZGlyZWN0RmxvdxInLmVuZ3JhbS5hcHAudjEuQmVnaW5SZWRpcmVjdEZsb3dSZXF1ZXN0GiguZW5ncmFtLmFwcC52MS5CZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEm8KFENvbXBsZXRlUmVkaXJlY3RGbG93EiouZW5ncmFtLmFwcC52MS5Db21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QaKy5lbmdyYW0uYXBwLnYxLkNvbXBsZXRlUmVkaXJlY3RGbG93UmVzcG9uc2USdQoWR3JhbnRDbGllbnRDcmVkZW50aWFscxIsLmVuZ3JhbS5hcHAudjEuR3JhbnRDbGllbnRDcmVkZW50aWFsc1JlcXVlc3QaLS5lbmdyYW0uYXBwLnYxLkdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXNwb25zZRJsChNHcmFudFNlcnZpY2VBY2NvdW50EikuZW5ncmFtLmFwcC52MS5HcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuR3JhbnRTZXJ2aWNlQWNjb3VudFJlc3BvbnNlEnUKFkZldGNoU2Vzc2lvbkNyZWRlbnRpYWwSLC5lbmdyYW0uYXBwLnYxLkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0Gi0uZW5ncmFtLmFwcC52MS5GZXRjaFNlc3Npb25DcmVkZW50aWFsUmVzcG9uc2USeAoXVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWwSLS5lbmdyYW0uYXBwLnYxLlVwZGF0ZVNlc3Npb25DcmVkZW50aWFsUmVxdWVzdBouLmVuZ3JhbS5hcHAudjEuVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZWIGcHJvdG8z"); + fileDesc("ChllbmdyYW0vYXBwL3YxL29hdXRoLnByb3RvEg1lbmdyYW0uYXBwLnYxIkkKDE9BdXRoU3ViamVjdBItCgRraW5kGAEgASgOMh8uZW5ncmFtLmFwcC52MS5PYXV0aFN1YmplY3RLaW5kEgoKAmlkGAIgASgJIlIKEEJlZ2luRmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJImgKEUJlZ2luRmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIYChB2ZXJpZmljYXRpb25fdXJsGAIgASgJEhEKCXVzZXJfY29kZRgDIAEoCSJPCg5HZXRGbG93UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSDwoHZmxvd19pZBgCIAEoCSI5Cg9HZXRGbG93UmVzcG9uc2USJgoEZmxvdxgBIAEoCzIYLmVuZ3JhbS5hcHAudjEuT0F1dGhGbG93IlIKEUNhbmNlbEZsb3dSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIPCgdmbG93X2lkGAIgASgJIjwKEkNhbmNlbEZsb3dSZXNwb25zZRImCgRmbG93GAEgASgLMhguZW5ncmFtLmFwcC52MS5PQXV0aEZsb3ciiQEKCU9BdXRoRmxvdxIKCgJpZBgBIAEoCRIQCghwcm92aWRlchgCIAEoCRIOCgZzdGF0dXMYAyABKAkSFwoKZXJyb3JfY29kZRgEIAEoCUgAiAEBEhIKCmV4cGlyZXNfYXQYBSABKAkSEgoKdXBkYXRlZF9hdBgGIAEoCUINCgtfZXJyb3JfY29kZSLEAQoUT0F1dGhBY2NvdW50TWV0YWRhdGESGQoMZGlzcGxheV9uYW1lGAEgASgJSACIAQESFgoJcGxhbl90eXBlGAIgASgJSAGIAQESGQoMd29ya3NwYWNlX2lkGAMgASgJSAKIAQESGwoOd29ya3NwYWNlX25hbWUYBCABKAlIA4gBAUIPCg1fZGlzcGxheV9uYW1lQgwKCl9wbGFuX3R5cGVCDwoNX3dvcmtzcGFjZV9pZEIRCg9fd29ya3NwYWNlX25hbWUi4QEKE09BdXRoQ3JlZGVudGlhbE1ldGESEAoIcHJvdmlkZXIYASABKAkSDwoHdmVyc2lvbhgCIAEoAxIRCgljb25uZWN0ZWQYAyABKAgSNAoHYWNjb3VudBgEIAEoCzIjLmVuZ3JhbS5hcHAudjEuT0F1dGhBY2NvdW50TWV0YWRhdGESEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJEg4KBnN0YXR1cxgHIAEoCRISCgpleHBpcmVzX2F0GAggASgJEhIKCnN1YmplY3RfaWQYCSABKAkiRgoWTGlzdENyZWRlbnRpYWxzUmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QiUgoXTGlzdENyZWRlbnRpYWxzUmVzcG9uc2USNwoLY3JlZGVudGlhbHMYASADKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEibQoRRGlzY29ubmVjdFJlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMiTAoSRGlzY29ubmVjdFJlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEingEKFFB1dENyZWRlbnRpYWxSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIQCghwcm92aWRlchgCIAEoCRIOCgZzZWNyZXQYAyABKAkSNgoIaWRlbnRpdHkYBCABKAsyJC5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0TWV0YWRhdGFQcm9iZSJPChVQdXRDcmVkZW50aWFsUmVzcG9uc2USNgoKY3JlZGVudGlhbBgBIAEoCzIiLmVuZ3JhbS5hcHAudjEuT0F1dGhDcmVkZW50aWFsTWV0YSIsChlMb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0Eg8KB2Zsb3dfaWQYASABKAkibAoaTG9va3VwUmVkaXJlY3RGbG93UmVzcG9uc2USLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg4KBnN0YXR1cxgDIAEoCSKgAwoRUmVkaXJlY3RPYXV0aFNwZWMSFQoNYXV0aG9yaXplX3VybBgBIAEoCRIRCgl0b2tlbl91cmwYAiABKAkSDgoGc2NvcGVzGAMgAygJEhcKD3Njb3BlX2RlbGltaXRlchgEIAEoCRJaChZleHRyYV9hdXRob3JpemVfcGFyYW1zGAUgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE9hdXRoU3BlYy5FeHRyYUF1dGhvcml6ZVBhcmFtc0VudHJ5EhUKDWNsaWVudF9pZF9yZWYYBiABKAkSGQoRY2xpZW50X3NlY3JldF9yZWYYByABKAkSDAoEcGtjZRgIIAEoCBI1CghtZXRhZGF0YRgJIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMSFAoMc2NvcGVzX3BhcmFtGAogASgJEhIKCmdyYW50X3BhdGgYCyABKAkaOwoZRXh0cmFBdXRob3JpemVQYXJhbXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIt4BChRSZWRpcmVjdE1ldGFkYXRhU3BlYxJXChNmcm9tX3Rva2VuX3Jlc3BvbnNlGAEgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhU3BlYy5Gcm9tVG9rZW5SZXNwb25zZUVudHJ5EjMKBXByb2JlGAIgASgLMiQuZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhUHJvYmUaOAoWRnJvbVRva2VuUmVzcG9uc2VFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIrkBChVSZWRpcmVjdE1ldGFkYXRhUHJvYmUSDgoGbWV0aG9kGAEgASgJEgwKBGhvc3QYAiABKAkSDAoEcGF0aBgDIAEoCRIMCgRib2R5GAQgASgJEjoKA21hcBgFIAMoCzItLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVByb2JlLk1hcEVudHJ5GioKCE1hcEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi2AEKFUNsaWVudENyZWRlbnRpYWxzU3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIVCg1jbGllbnRfaWRfcmVmGAQgASgJEhkKEWNsaWVudF9zZWNyZXRfcmVmGAUgASgJEhoKEnRva2VuX3JlcXVlc3RfYm9keRgGIAEoCRI1CghtZXRhZGF0YRgHIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMikwEKHUdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIQCghwcm92aWRlchgCIAEoCRIyCgRzcGVjGAMgASgLMiQuZW5ncmFtLmFwcC52MS5DbGllbnRDcmVkZW50aWFsc1NwZWMiWAoeR3JhbnRDbGllbnRDcmVkZW50aWFsc1Jlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEiqQEKElNlcnZpY2VBY2NvdW50U3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIPCgdrZXlfcmVmGAQgASgJEg8KB3N1YmplY3QYBSABKAkSNQoIbWV0YWRhdGEYBiABKAsyIy5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0TWV0YWRhdGFTcGVjIo0BChpHcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSEAoIcHJvdmlkZXIYAiABKAkSLwoEc3BlYxgDIAEoCzIhLmVuZ3JhbS5hcHAudjEuU2VydmljZUFjY291bnRTcGVjIlUKG0dyYW50U2VydmljZUFjY291bnRSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIqABChhCZWdpblJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEi4KBHNwZWMYAyABKAsyIC5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0T2F1dGhTcGVjEhQKDHJlZGlyZWN0X3VyaRgEIAEoCSJaChlCZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIVCg1hdXRob3JpemVfdXJsGAIgASgJIsIBChtDb21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg8KB2Zsb3dfaWQYAyABKAkSDAoEY29kZRgEIAEoCRIUCgxyZWRpcmVjdF91cmkYBSABKAkSLgoEc3BlYxgGIAEoCzIgLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RPYXV0aFNwZWMiVgocQ29tcGxldGVSZWRpcmVjdEZsb3dSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIkkKHUZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJIloKHkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZRIQCghwcm92aWRlchgBIAEoCRIPCgd2ZXJzaW9uGAIgASgDEhUKDW9wYXF1ZV9idW5kbGUYAyABKAwiewoeVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMSFQoNb3BhcXVlX2J1bmRsZRgEIAEoDCJbCh9VcGRhdGVTZXNzaW9uQ3JlZGVudGlhbFJlc3BvbnNlEhAKCHByb3ZpZGVyGAEgASgJEg8KB3ZlcnNpb24YAiABKAMSFQoNb3BhcXVlX2J1bmRsZRgDIAEoDCq4AQoQT2F1dGhTdWJqZWN0S2luZBIiCh5PQVVUSF9TVUJKRUNUX0tJTkRfVU5TUEVDSUZJRUQQABIbChdPQVVUSF9TVUJKRUNUX0tJTkRfVVNFUhABEiAKHE9BVVRIX1NVQkpFQ1RfS0lORF9DT05ORUNUT1IQAhIaChZPQVVUSF9TVUJKRUNUX0tJTkRfTUNQEAMSJQohT0FVVEhfU1VCSkVDVF9LSU5EX1VTRVJfQ09OTkVDVE9SEAQysAoKFk9BdXRoQ3JlZGVudGlhbFNlcnZpY2USTgoJQmVnaW5GbG93Eh8uZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXF1ZXN0GiAuZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXNwb25zZRJICgdHZXRGbG93Eh0uZW5ncmFtLmFwcC52MS5HZXRGbG93UmVxdWVzdBoeLmVuZ3JhbS5hcHAudjEuR2V0Rmxvd1Jlc3BvbnNlElEKCkNhbmNlbEZsb3cSIC5lbmdyYW0uYXBwLnYxLkNhbmNlbEZsb3dSZXF1ZXN0GiEuZW5ncmFtLmFwcC52MS5DYW5jZWxGbG93UmVzcG9uc2USYAoPTGlzdENyZWRlbnRpYWxzEiUuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXF1ZXN0GiYuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXNwb25zZRJRCgpEaXNjb25uZWN0EiAuZW5ncmFtLmFwcC52MS5EaXNjb25uZWN0UmVxdWVzdBohLmVuZ3JhbS5hcHAudjEuRGlzY29ubmVjdFJlc3BvbnNlEloKDVB1dENyZWRlbnRpYWwSIy5lbmdyYW0uYXBwLnYxLlB1dENyZWRlbnRpYWxSZXF1ZXN0GiQuZW5ncmFtLmFwcC52MS5QdXRDcmVkZW50aWFsUmVzcG9uc2USaQoSTG9va3VwUmVkaXJlY3RGbG93EiguZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0GikuZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXNwb25zZRJmChFCZWdpblJlZGlyZWN0RmxvdxInLmVuZ3JhbS5hcHAudjEuQmVnaW5SZWRpcmVjdEZsb3dSZXF1ZXN0GiguZW5ncmFtLmFwcC52MS5CZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEm8KFENvbXBsZXRlUmVkaXJlY3RGbG93EiouZW5ncmFtLmFwcC52MS5Db21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QaKy5lbmdyYW0uYXBwLnYxLkNvbXBsZXRlUmVkaXJlY3RGbG93UmVzcG9uc2USdQoWR3JhbnRDbGllbnRDcmVkZW50aWFscxIsLmVuZ3JhbS5hcHAudjEuR3JhbnRDbGllbnRDcmVkZW50aWFsc1JlcXVlc3QaLS5lbmdyYW0uYXBwLnYxLkdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXNwb25zZRJsChNHcmFudFNlcnZpY2VBY2NvdW50EikuZW5ncmFtLmFwcC52MS5HcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuR3JhbnRTZXJ2aWNlQWNjb3VudFJlc3BvbnNlEnUKFkZldGNoU2Vzc2lvbkNyZWRlbnRpYWwSLC5lbmdyYW0uYXBwLnYxLkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0Gi0uZW5ncmFtLmFwcC52MS5GZXRjaFNlc3Npb25DcmVkZW50aWFsUmVzcG9uc2USeAoXVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWwSLS5lbmdyYW0uYXBwLnYxLlVwZGF0ZVNlc3Npb25DcmVkZW50aWFsUmVxdWVzdBouLmVuZ3JhbS5hcHAudjEuVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZWIGcHJvdG8z"); /** * @generated from message engram.app.v1.OAuthSubject @@ -403,6 +403,15 @@ export type PutCredentialRequest = Message<"engram.app.v1.PutCredentialRequest"> * @generated from field: string secret = 3; */ secret: string; + + /** + * Optional "who am I" request, sent with the token before it is sealed. + * It fills the display and workspace names; the account id stays empty. + * A failing probe rejects the token. + * + * @generated from field: engram.app.v1.RedirectMetadataProbe identity = 4; + */ + identity?: RedirectMetadataProbe; }; /** diff --git a/orchestrator/src/routes/me.ts b/orchestrator/src/routes/me.ts index 42d3ad094..a9a563450 100644 --- a/orchestrator/src/routes/me.ts +++ b/orchestrator/src/routes/me.ts @@ -31,7 +31,9 @@ import { HTTPException } from "hono/http-exception"; import { makeUserSecretStore, type UserSecretStore } from "../db/user-secrets.ts"; import { harnessCatalog as defaultHarnessCatalog } from "../control-plane/client.ts"; import { oauthCredential as defaultOAuthCredential } from "../control-plane/client.ts"; -import { OauthSubjectKind } from "../gen/engram/app/v1/oauth_pb.ts"; +import { OauthSubjectKind, type RedirectMetadataProbe } from "../gen/engram/app/v1/oauth_pb.ts"; +import { userTokenIdentityProbe } from "../connectors/oauth-spec.ts"; +import { Code, ConnectError } from "@connectrpc/connect"; import { getSessionFromHeaders } from "../auth/session.ts"; import { loadRegistry, type CustomConnectorSource } from "../connectors/registry.ts"; import { makeConnectorStore } from "../db/connectors.ts"; @@ -125,9 +127,20 @@ export interface OAuthCredentialClient { subject: { kind: OauthSubjectKind; id: string }; provider: string; secret: string; + identity?: RedirectMetadataProbe; }): Promise; } +/** A connector that accepts a personal credential, as the routes see it. */ +interface UserConnectorEntry { + name: string; + oauth: boolean; + token: boolean; + tokenHint?: string; + /** Names the account behind a pasted token, when the connector declares it. */ + identity?: RedirectMetadataProbe; +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -206,14 +219,9 @@ export function makeMeRoute(deps?: MeDeps): Hono { /** Connectors declaring user-scoped support → member-safe surface data. * The map's keys are the only providers the PAT PUT/DELETE routes accept. */ - async function connectorUnion(): Promise< - Map - > { + async function connectorUnion(): Promise> { const registry = await loadRegistry(resolveConnectors()); - const union = new Map< - string, - { name: string; oauth: boolean; token: boolean; tokenHint?: string } - >(); + const union = new Map(); for (const connector of registry.values()) { const uc = connector.userCredential; if (!uc) continue; @@ -222,6 +230,7 @@ export function makeMeRoute(deps?: MeDeps): Hono { oauth: uc.oauth !== undefined, token: uc.token !== undefined, ...(uc.token ? { tokenHint: uc.token.hint } : {}), + identity: userTokenIdentityProbe(connector), }); } return union; @@ -369,11 +378,27 @@ export function makeMeRoute(deps?: MeDeps): Hono { throw new HTTPException(400, { message: "value must not be empty" }); } // NEVER log the value. Sealed in the coordinator's credential store. - await resolveOAuth().putCredential({ - subject: connectorSubject(user.id), - provider, - secret: value, - }); + try { + await resolveOAuth().putCredential({ + subject: connectorSubject(user.id), + provider, + secret: value, + ...(entry.identity ? { identity: entry.identity } : {}), + }); + } catch (error) { + // The connector asks the provider who owns the token before the seal; + // a refusal or an unreachable provider leaves the token unconfirmed. + if ( + error instanceof ConnectError && + error.code === Code.Unavailable && + error.metadata.get("engram-error-slug") === "probe_failed" + ) { + throw new HTTPException(400, { + message: `Could not confirm this token with ${entry.name}. Check the token and try again.`, + }); + } + throw error; + } return new Response(null, { status: 204 }); }); diff --git a/web/src/components/settings/TokensPanel.test.tsx b/web/src/components/settings/TokensPanel.test.tsx index 5e1baff49..09c32f36f 100644 --- a/web/src/components/settings/TokensPanel.test.tsx +++ b/web/src/components/settings/TokensPanel.test.tsx @@ -204,6 +204,40 @@ describe("Credentials connector cards", () => { } }); + test("shows the server's reason when a pasted token is refused", async () => { + const originalFetch = global.fetch; + global.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/me/credentials") && !init?.method) { + return Response.json({ credentials: [connectorEntry()] }); + } + if (path.endsWith("/me/harness-env")) return Response.json({ vars: [] }); + if (path.endsWith("/me/connector-credentials/acme") && init?.method === "PUT") { + return new Response( + "Could not confirm this token with Acme. Check the token and try again.", + { + status: 400, + }, + ); + } + throw new Error(`unexpected fetch ${path}`); + }) as typeof fetch; + try { + renderWithProviders(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Add token" })); + await user.type(screen.getByPlaceholderText("paste token…"), "pat-bad"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect( + await screen.findByText( + "Could not confirm this token with Acme. Check the token and try again.", + ), + ).toBeTruthy(); + } finally { + global.fetch = originalFetch; + } + }); + test("disconnects a connected personal credential and surfaces a broken one", async () => { const originalFetch = global.fetch; let deleted = false; diff --git a/web/src/components/settings/TokensPanel.tsx b/web/src/components/settings/TokensPanel.tsx index 808eceddc..b9535507e 100644 --- a/web/src/components/settings/TokensPanel.tsx +++ b/web/src/components/settings/TokensPanel.tsx @@ -8,6 +8,7 @@ import { useDeleteHarnessEnv, type HarnessEnvVar, } from "../../hooks/useHarnessEnv"; +import { errorMessage } from "../../lib/errors"; import { PageHeading } from "../page-heading"; import { EmptyState } from "@/components/empty-state"; import { SkeletonRows } from "@/components/skeleton-rows"; @@ -162,7 +163,7 @@ function ConnectorTokenForm({ provider, onDone }: { provider: string; onDone: () { provider, value: data.value }, { onSuccess: onDone, - onError: (e) => form.setError("root", { message: String(e) }), + onError: (e) => form.setError("root", { message: errorMessage(e) }), }, ); @@ -375,7 +376,7 @@ function EnvVarForm({ envVar, onDone }: { envVar: string; onDone: () => void }) { envVar, value: data.value }, { onSuccess: onDone, - onError: (e) => form.setError("root", { message: String(e) }), + onError: (e) => form.setError("root", { message: errorMessage(e) }), }, ); diff --git a/web/src/gen/engram/app/v1/oauth_pb.ts b/web/src/gen/engram/app/v1/oauth_pb.ts index fbdfad680..49aaad600 100644 --- a/web/src/gen/engram/app/v1/oauth_pb.ts +++ b/web/src/gen/engram/app/v1/oauth_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file engram/app/v1/oauth.proto. */ export const file_engram_app_v1_oauth: GenFile = /*@__PURE__*/ - fileDesc("ChllbmdyYW0vYXBwL3YxL29hdXRoLnByb3RvEg1lbmdyYW0uYXBwLnYxIkkKDE9BdXRoU3ViamVjdBItCgRraW5kGAEgASgOMh8uZW5ncmFtLmFwcC52MS5PYXV0aFN1YmplY3RLaW5kEgoKAmlkGAIgASgJIlIKEEJlZ2luRmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJImgKEUJlZ2luRmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIYChB2ZXJpZmljYXRpb25fdXJsGAIgASgJEhEKCXVzZXJfY29kZRgDIAEoCSJPCg5HZXRGbG93UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSDwoHZmxvd19pZBgCIAEoCSI5Cg9HZXRGbG93UmVzcG9uc2USJgoEZmxvdxgBIAEoCzIYLmVuZ3JhbS5hcHAudjEuT0F1dGhGbG93IlIKEUNhbmNlbEZsb3dSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIPCgdmbG93X2lkGAIgASgJIjwKEkNhbmNlbEZsb3dSZXNwb25zZRImCgRmbG93GAEgASgLMhguZW5ncmFtLmFwcC52MS5PQXV0aEZsb3ciiQEKCU9BdXRoRmxvdxIKCgJpZBgBIAEoCRIQCghwcm92aWRlchgCIAEoCRIOCgZzdGF0dXMYAyABKAkSFwoKZXJyb3JfY29kZRgEIAEoCUgAiAEBEhIKCmV4cGlyZXNfYXQYBSABKAkSEgoKdXBkYXRlZF9hdBgGIAEoCUINCgtfZXJyb3JfY29kZSLEAQoUT0F1dGhBY2NvdW50TWV0YWRhdGESGQoMZGlzcGxheV9uYW1lGAEgASgJSACIAQESFgoJcGxhbl90eXBlGAIgASgJSAGIAQESGQoMd29ya3NwYWNlX2lkGAMgASgJSAKIAQESGwoOd29ya3NwYWNlX25hbWUYBCABKAlIA4gBAUIPCg1fZGlzcGxheV9uYW1lQgwKCl9wbGFuX3R5cGVCDwoNX3dvcmtzcGFjZV9pZEIRCg9fd29ya3NwYWNlX25hbWUi4QEKE09BdXRoQ3JlZGVudGlhbE1ldGESEAoIcHJvdmlkZXIYASABKAkSDwoHdmVyc2lvbhgCIAEoAxIRCgljb25uZWN0ZWQYAyABKAgSNAoHYWNjb3VudBgEIAEoCzIjLmVuZ3JhbS5hcHAudjEuT0F1dGhBY2NvdW50TWV0YWRhdGESEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJEg4KBnN0YXR1cxgHIAEoCRISCgpleHBpcmVzX2F0GAggASgJEhIKCnN1YmplY3RfaWQYCSABKAkiRgoWTGlzdENyZWRlbnRpYWxzUmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QiUgoXTGlzdENyZWRlbnRpYWxzUmVzcG9uc2USNwoLY3JlZGVudGlhbHMYASADKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEibQoRRGlzY29ubmVjdFJlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMiTAoSRGlzY29ubmVjdFJlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEiZgoUUHV0Q3JlZGVudGlhbFJlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg4KBnNlY3JldBgDIAEoCSJPChVQdXRDcmVkZW50aWFsUmVzcG9uc2USNgoKY3JlZGVudGlhbBgBIAEoCzIiLmVuZ3JhbS5hcHAudjEuT0F1dGhDcmVkZW50aWFsTWV0YSIsChlMb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0Eg8KB2Zsb3dfaWQYASABKAkibAoaTG9va3VwUmVkaXJlY3RGbG93UmVzcG9uc2USLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg4KBnN0YXR1cxgDIAEoCSKgAwoRUmVkaXJlY3RPYXV0aFNwZWMSFQoNYXV0aG9yaXplX3VybBgBIAEoCRIRCgl0b2tlbl91cmwYAiABKAkSDgoGc2NvcGVzGAMgAygJEhcKD3Njb3BlX2RlbGltaXRlchgEIAEoCRJaChZleHRyYV9hdXRob3JpemVfcGFyYW1zGAUgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE9hdXRoU3BlYy5FeHRyYUF1dGhvcml6ZVBhcmFtc0VudHJ5EhUKDWNsaWVudF9pZF9yZWYYBiABKAkSGQoRY2xpZW50X3NlY3JldF9yZWYYByABKAkSDAoEcGtjZRgIIAEoCBI1CghtZXRhZGF0YRgJIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMSFAoMc2NvcGVzX3BhcmFtGAogASgJEhIKCmdyYW50X3BhdGgYCyABKAkaOwoZRXh0cmFBdXRob3JpemVQYXJhbXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIt4BChRSZWRpcmVjdE1ldGFkYXRhU3BlYxJXChNmcm9tX3Rva2VuX3Jlc3BvbnNlGAEgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhU3BlYy5Gcm9tVG9rZW5SZXNwb25zZUVudHJ5EjMKBXByb2JlGAIgASgLMiQuZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhUHJvYmUaOAoWRnJvbVRva2VuUmVzcG9uc2VFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIrkBChVSZWRpcmVjdE1ldGFkYXRhUHJvYmUSDgoGbWV0aG9kGAEgASgJEgwKBGhvc3QYAiABKAkSDAoEcGF0aBgDIAEoCRIMCgRib2R5GAQgASgJEjoKA21hcBgFIAMoCzItLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVByb2JlLk1hcEVudHJ5GioKCE1hcEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi2AEKFUNsaWVudENyZWRlbnRpYWxzU3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIVCg1jbGllbnRfaWRfcmVmGAQgASgJEhkKEWNsaWVudF9zZWNyZXRfcmVmGAUgASgJEhoKEnRva2VuX3JlcXVlc3RfYm9keRgGIAEoCRI1CghtZXRhZGF0YRgHIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMikwEKHUdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIQCghwcm92aWRlchgCIAEoCRIyCgRzcGVjGAMgASgLMiQuZW5ncmFtLmFwcC52MS5DbGllbnRDcmVkZW50aWFsc1NwZWMiWAoeR3JhbnRDbGllbnRDcmVkZW50aWFsc1Jlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEiqQEKElNlcnZpY2VBY2NvdW50U3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIPCgdrZXlfcmVmGAQgASgJEg8KB3N1YmplY3QYBSABKAkSNQoIbWV0YWRhdGEYBiABKAsyIy5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0TWV0YWRhdGFTcGVjIo0BChpHcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSEAoIcHJvdmlkZXIYAiABKAkSLwoEc3BlYxgDIAEoCzIhLmVuZ3JhbS5hcHAudjEuU2VydmljZUFjY291bnRTcGVjIlUKG0dyYW50U2VydmljZUFjY291bnRSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIqABChhCZWdpblJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEi4KBHNwZWMYAyABKAsyIC5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0T2F1dGhTcGVjEhQKDHJlZGlyZWN0X3VyaRgEIAEoCSJaChlCZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIVCg1hdXRob3JpemVfdXJsGAIgASgJIsIBChtDb21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg8KB2Zsb3dfaWQYAyABKAkSDAoEY29kZRgEIAEoCRIUCgxyZWRpcmVjdF91cmkYBSABKAkSLgoEc3BlYxgGIAEoCzIgLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RPYXV0aFNwZWMiVgocQ29tcGxldGVSZWRpcmVjdEZsb3dSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIkkKHUZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJIloKHkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZRIQCghwcm92aWRlchgBIAEoCRIPCgd2ZXJzaW9uGAIgASgDEhUKDW9wYXF1ZV9idW5kbGUYAyABKAwiewoeVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMSFQoNb3BhcXVlX2J1bmRsZRgEIAEoDCJbCh9VcGRhdGVTZXNzaW9uQ3JlZGVudGlhbFJlc3BvbnNlEhAKCHByb3ZpZGVyGAEgASgJEg8KB3ZlcnNpb24YAiABKAMSFQoNb3BhcXVlX2J1bmRsZRgDIAEoDCq4AQoQT2F1dGhTdWJqZWN0S2luZBIiCh5PQVVUSF9TVUJKRUNUX0tJTkRfVU5TUEVDSUZJRUQQABIbChdPQVVUSF9TVUJKRUNUX0tJTkRfVVNFUhABEiAKHE9BVVRIX1NVQkpFQ1RfS0lORF9DT05ORUNUT1IQAhIaChZPQVVUSF9TVUJKRUNUX0tJTkRfTUNQEAMSJQohT0FVVEhfU1VCSkVDVF9LSU5EX1VTRVJfQ09OTkVDVE9SEAQysAoKFk9BdXRoQ3JlZGVudGlhbFNlcnZpY2USTgoJQmVnaW5GbG93Eh8uZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXF1ZXN0GiAuZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXNwb25zZRJICgdHZXRGbG93Eh0uZW5ncmFtLmFwcC52MS5HZXRGbG93UmVxdWVzdBoeLmVuZ3JhbS5hcHAudjEuR2V0Rmxvd1Jlc3BvbnNlElEKCkNhbmNlbEZsb3cSIC5lbmdyYW0uYXBwLnYxLkNhbmNlbEZsb3dSZXF1ZXN0GiEuZW5ncmFtLmFwcC52MS5DYW5jZWxGbG93UmVzcG9uc2USYAoPTGlzdENyZWRlbnRpYWxzEiUuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXF1ZXN0GiYuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXNwb25zZRJRCgpEaXNjb25uZWN0EiAuZW5ncmFtLmFwcC52MS5EaXNjb25uZWN0UmVxdWVzdBohLmVuZ3JhbS5hcHAudjEuRGlzY29ubmVjdFJlc3BvbnNlEloKDVB1dENyZWRlbnRpYWwSIy5lbmdyYW0uYXBwLnYxLlB1dENyZWRlbnRpYWxSZXF1ZXN0GiQuZW5ncmFtLmFwcC52MS5QdXRDcmVkZW50aWFsUmVzcG9uc2USaQoSTG9va3VwUmVkaXJlY3RGbG93EiguZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0GikuZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXNwb25zZRJmChFCZWdpblJlZGlyZWN0RmxvdxInLmVuZ3JhbS5hcHAudjEuQmVnaW5SZWRpcmVjdEZsb3dSZXF1ZXN0GiguZW5ncmFtLmFwcC52MS5CZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEm8KFENvbXBsZXRlUmVkaXJlY3RGbG93EiouZW5ncmFtLmFwcC52MS5Db21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QaKy5lbmdyYW0uYXBwLnYxLkNvbXBsZXRlUmVkaXJlY3RGbG93UmVzcG9uc2USdQoWR3JhbnRDbGllbnRDcmVkZW50aWFscxIsLmVuZ3JhbS5hcHAudjEuR3JhbnRDbGllbnRDcmVkZW50aWFsc1JlcXVlc3QaLS5lbmdyYW0uYXBwLnYxLkdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXNwb25zZRJsChNHcmFudFNlcnZpY2VBY2NvdW50EikuZW5ncmFtLmFwcC52MS5HcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuR3JhbnRTZXJ2aWNlQWNjb3VudFJlc3BvbnNlEnUKFkZldGNoU2Vzc2lvbkNyZWRlbnRpYWwSLC5lbmdyYW0uYXBwLnYxLkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0Gi0uZW5ncmFtLmFwcC52MS5GZXRjaFNlc3Npb25DcmVkZW50aWFsUmVzcG9uc2USeAoXVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWwSLS5lbmdyYW0uYXBwLnYxLlVwZGF0ZVNlc3Npb25DcmVkZW50aWFsUmVxdWVzdBouLmVuZ3JhbS5hcHAudjEuVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZWIGcHJvdG8z"); + fileDesc("ChllbmdyYW0vYXBwL3YxL29hdXRoLnByb3RvEg1lbmdyYW0uYXBwLnYxIkkKDE9BdXRoU3ViamVjdBItCgRraW5kGAEgASgOMh8uZW5ncmFtLmFwcC52MS5PYXV0aFN1YmplY3RLaW5kEgoKAmlkGAIgASgJIlIKEEJlZ2luRmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJImgKEUJlZ2luRmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIYChB2ZXJpZmljYXRpb25fdXJsGAIgASgJEhEKCXVzZXJfY29kZRgDIAEoCSJPCg5HZXRGbG93UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSDwoHZmxvd19pZBgCIAEoCSI5Cg9HZXRGbG93UmVzcG9uc2USJgoEZmxvdxgBIAEoCzIYLmVuZ3JhbS5hcHAudjEuT0F1dGhGbG93IlIKEUNhbmNlbEZsb3dSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIPCgdmbG93X2lkGAIgASgJIjwKEkNhbmNlbEZsb3dSZXNwb25zZRImCgRmbG93GAEgASgLMhguZW5ncmFtLmFwcC52MS5PQXV0aEZsb3ciiQEKCU9BdXRoRmxvdxIKCgJpZBgBIAEoCRIQCghwcm92aWRlchgCIAEoCRIOCgZzdGF0dXMYAyABKAkSFwoKZXJyb3JfY29kZRgEIAEoCUgAiAEBEhIKCmV4cGlyZXNfYXQYBSABKAkSEgoKdXBkYXRlZF9hdBgGIAEoCUINCgtfZXJyb3JfY29kZSLEAQoUT0F1dGhBY2NvdW50TWV0YWRhdGESGQoMZGlzcGxheV9uYW1lGAEgASgJSACIAQESFgoJcGxhbl90eXBlGAIgASgJSAGIAQESGQoMd29ya3NwYWNlX2lkGAMgASgJSAKIAQESGwoOd29ya3NwYWNlX25hbWUYBCABKAlIA4gBAUIPCg1fZGlzcGxheV9uYW1lQgwKCl9wbGFuX3R5cGVCDwoNX3dvcmtzcGFjZV9pZEIRCg9fd29ya3NwYWNlX25hbWUi4QEKE09BdXRoQ3JlZGVudGlhbE1ldGESEAoIcHJvdmlkZXIYASABKAkSDwoHdmVyc2lvbhgCIAEoAxIRCgljb25uZWN0ZWQYAyABKAgSNAoHYWNjb3VudBgEIAEoCzIjLmVuZ3JhbS5hcHAudjEuT0F1dGhBY2NvdW50TWV0YWRhdGESEgoKY3JlYXRlZF9hdBgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgJEg4KBnN0YXR1cxgHIAEoCRISCgpleHBpcmVzX2F0GAggASgJEhIKCnN1YmplY3RfaWQYCSABKAkiRgoWTGlzdENyZWRlbnRpYWxzUmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QiUgoXTGlzdENyZWRlbnRpYWxzUmVzcG9uc2USNwoLY3JlZGVudGlhbHMYASADKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEibQoRRGlzY29ubmVjdFJlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMiTAoSRGlzY29ubmVjdFJlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEingEKFFB1dENyZWRlbnRpYWxSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIQCghwcm92aWRlchgCIAEoCRIOCgZzZWNyZXQYAyABKAkSNgoIaWRlbnRpdHkYBCABKAsyJC5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0TWV0YWRhdGFQcm9iZSJPChVQdXRDcmVkZW50aWFsUmVzcG9uc2USNgoKY3JlZGVudGlhbBgBIAEoCzIiLmVuZ3JhbS5hcHAudjEuT0F1dGhDcmVkZW50aWFsTWV0YSIsChlMb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0Eg8KB2Zsb3dfaWQYASABKAkibAoaTG9va3VwUmVkaXJlY3RGbG93UmVzcG9uc2USLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg4KBnN0YXR1cxgDIAEoCSKgAwoRUmVkaXJlY3RPYXV0aFNwZWMSFQoNYXV0aG9yaXplX3VybBgBIAEoCRIRCgl0b2tlbl91cmwYAiABKAkSDgoGc2NvcGVzGAMgAygJEhcKD3Njb3BlX2RlbGltaXRlchgEIAEoCRJaChZleHRyYV9hdXRob3JpemVfcGFyYW1zGAUgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE9hdXRoU3BlYy5FeHRyYUF1dGhvcml6ZVBhcmFtc0VudHJ5EhUKDWNsaWVudF9pZF9yZWYYBiABKAkSGQoRY2xpZW50X3NlY3JldF9yZWYYByABKAkSDAoEcGtjZRgIIAEoCBI1CghtZXRhZGF0YRgJIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMSFAoMc2NvcGVzX3BhcmFtGAogASgJEhIKCmdyYW50X3BhdGgYCyABKAkaOwoZRXh0cmFBdXRob3JpemVQYXJhbXNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIt4BChRSZWRpcmVjdE1ldGFkYXRhU3BlYxJXChNmcm9tX3Rva2VuX3Jlc3BvbnNlGAEgAygLMjouZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhU3BlYy5Gcm9tVG9rZW5SZXNwb25zZUVudHJ5EjMKBXByb2JlGAIgASgLMiQuZW5ncmFtLmFwcC52MS5SZWRpcmVjdE1ldGFkYXRhUHJvYmUaOAoWRnJvbVRva2VuUmVzcG9uc2VFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIrkBChVSZWRpcmVjdE1ldGFkYXRhUHJvYmUSDgoGbWV0aG9kGAEgASgJEgwKBGhvc3QYAiABKAkSDAoEcGF0aBgDIAEoCRIMCgRib2R5GAQgASgJEjoKA21hcBgFIAMoCzItLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVByb2JlLk1hcEVudHJ5GioKCE1hcEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEi2AEKFUNsaWVudENyZWRlbnRpYWxzU3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIVCg1jbGllbnRfaWRfcmVmGAQgASgJEhkKEWNsaWVudF9zZWNyZXRfcmVmGAUgASgJEhoKEnRva2VuX3JlcXVlc3RfYm9keRgGIAEoCRI1CghtZXRhZGF0YRgHIAEoCzIjLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RNZXRhZGF0YVNwZWMikwEKHUdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXF1ZXN0EiwKB3N1YmplY3QYASABKAsyGy5lbmdyYW0uYXBwLnYxLk9BdXRoU3ViamVjdBIQCghwcm92aWRlchgCIAEoCRIyCgRzcGVjGAMgASgLMiQuZW5ncmFtLmFwcC52MS5DbGllbnRDcmVkZW50aWFsc1NwZWMiWAoeR3JhbnRDbGllbnRDcmVkZW50aWFsc1Jlc3BvbnNlEjYKCmNyZWRlbnRpYWwYASABKAsyIi5lbmdyYW0uYXBwLnYxLk9BdXRoQ3JlZGVudGlhbE1ldGEiqQEKElNlcnZpY2VBY2NvdW50U3BlYxIRCgl0b2tlbl91cmwYASABKAkSDgoGc2NvcGVzGAIgAygJEhcKD3Njb3BlX2RlbGltaXRlchgDIAEoCRIPCgdrZXlfcmVmGAQgASgJEg8KB3N1YmplY3QYBSABKAkSNQoIbWV0YWRhdGEYBiABKAsyIy5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0TWV0YWRhdGFTcGVjIo0BChpHcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBIsCgdzdWJqZWN0GAEgASgLMhsuZW5ncmFtLmFwcC52MS5PQXV0aFN1YmplY3QSEAoIcHJvdmlkZXIYAiABKAkSLwoEc3BlYxgDIAEoCzIhLmVuZ3JhbS5hcHAudjEuU2VydmljZUFjY291bnRTcGVjIlUKG0dyYW50U2VydmljZUFjY291bnRSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIqABChhCZWdpblJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEi4KBHNwZWMYAyABKAsyIC5lbmdyYW0uYXBwLnYxLlJlZGlyZWN0T2F1dGhTcGVjEhQKDHJlZGlyZWN0X3VyaRgEIAEoCSJaChlCZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEiYKBGZsb3cYASABKAsyGC5lbmdyYW0uYXBwLnYxLk9BdXRoRmxvdxIVCg1hdXRob3JpemVfdXJsGAIgASgJIsIBChtDb21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QSLAoHc3ViamVjdBgBIAEoCzIbLmVuZ3JhbS5hcHAudjEuT0F1dGhTdWJqZWN0EhAKCHByb3ZpZGVyGAIgASgJEg8KB2Zsb3dfaWQYAyABKAkSDAoEY29kZRgEIAEoCRIUCgxyZWRpcmVjdF91cmkYBSABKAkSLgoEc3BlYxgGIAEoCzIgLmVuZ3JhbS5hcHAudjEuUmVkaXJlY3RPYXV0aFNwZWMiVgocQ29tcGxldGVSZWRpcmVjdEZsb3dSZXNwb25zZRI2CgpjcmVkZW50aWFsGAEgASgLMiIuZW5ncmFtLmFwcC52MS5PQXV0aENyZWRlbnRpYWxNZXRhIkkKHUZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJIloKHkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZRIQCghwcm92aWRlchgBIAEoCRIPCgd2ZXJzaW9uGAIgASgDEhUKDW9wYXF1ZV9idW5kbGUYAyABKAwiewoeVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSFAoMYnJva2VyX3Rva2VuGAIgASgJEhgKEGV4cGVjdGVkX3ZlcnNpb24YAyABKAMSFQoNb3BhcXVlX2J1bmRsZRgEIAEoDCJbCh9VcGRhdGVTZXNzaW9uQ3JlZGVudGlhbFJlc3BvbnNlEhAKCHByb3ZpZGVyGAEgASgJEg8KB3ZlcnNpb24YAiABKAMSFQoNb3BhcXVlX2J1bmRsZRgDIAEoDCq4AQoQT2F1dGhTdWJqZWN0S2luZBIiCh5PQVVUSF9TVUJKRUNUX0tJTkRfVU5TUEVDSUZJRUQQABIbChdPQVVUSF9TVUJKRUNUX0tJTkRfVVNFUhABEiAKHE9BVVRIX1NVQkpFQ1RfS0lORF9DT05ORUNUT1IQAhIaChZPQVVUSF9TVUJKRUNUX0tJTkRfTUNQEAMSJQohT0FVVEhfU1VCSkVDVF9LSU5EX1VTRVJfQ09OTkVDVE9SEAQysAoKFk9BdXRoQ3JlZGVudGlhbFNlcnZpY2USTgoJQmVnaW5GbG93Eh8uZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXF1ZXN0GiAuZW5ncmFtLmFwcC52MS5CZWdpbkZsb3dSZXNwb25zZRJICgdHZXRGbG93Eh0uZW5ncmFtLmFwcC52MS5HZXRGbG93UmVxdWVzdBoeLmVuZ3JhbS5hcHAudjEuR2V0Rmxvd1Jlc3BvbnNlElEKCkNhbmNlbEZsb3cSIC5lbmdyYW0uYXBwLnYxLkNhbmNlbEZsb3dSZXF1ZXN0GiEuZW5ncmFtLmFwcC52MS5DYW5jZWxGbG93UmVzcG9uc2USYAoPTGlzdENyZWRlbnRpYWxzEiUuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXF1ZXN0GiYuZW5ncmFtLmFwcC52MS5MaXN0Q3JlZGVudGlhbHNSZXNwb25zZRJRCgpEaXNjb25uZWN0EiAuZW5ncmFtLmFwcC52MS5EaXNjb25uZWN0UmVxdWVzdBohLmVuZ3JhbS5hcHAudjEuRGlzY29ubmVjdFJlc3BvbnNlEloKDVB1dENyZWRlbnRpYWwSIy5lbmdyYW0uYXBwLnYxLlB1dENyZWRlbnRpYWxSZXF1ZXN0GiQuZW5ncmFtLmFwcC52MS5QdXRDcmVkZW50aWFsUmVzcG9uc2USaQoSTG9va3VwUmVkaXJlY3RGbG93EiguZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXF1ZXN0GikuZW5ncmFtLmFwcC52MS5Mb29rdXBSZWRpcmVjdEZsb3dSZXNwb25zZRJmChFCZWdpblJlZGlyZWN0RmxvdxInLmVuZ3JhbS5hcHAudjEuQmVnaW5SZWRpcmVjdEZsb3dSZXF1ZXN0GiguZW5ncmFtLmFwcC52MS5CZWdpblJlZGlyZWN0Rmxvd1Jlc3BvbnNlEm8KFENvbXBsZXRlUmVkaXJlY3RGbG93EiouZW5ncmFtLmFwcC52MS5Db21wbGV0ZVJlZGlyZWN0Rmxvd1JlcXVlc3QaKy5lbmdyYW0uYXBwLnYxLkNvbXBsZXRlUmVkaXJlY3RGbG93UmVzcG9uc2USdQoWR3JhbnRDbGllbnRDcmVkZW50aWFscxIsLmVuZ3JhbS5hcHAudjEuR3JhbnRDbGllbnRDcmVkZW50aWFsc1JlcXVlc3QaLS5lbmdyYW0uYXBwLnYxLkdyYW50Q2xpZW50Q3JlZGVudGlhbHNSZXNwb25zZRJsChNHcmFudFNlcnZpY2VBY2NvdW50EikuZW5ncmFtLmFwcC52MS5HcmFudFNlcnZpY2VBY2NvdW50UmVxdWVzdBoqLmVuZ3JhbS5hcHAudjEuR3JhbnRTZXJ2aWNlQWNjb3VudFJlc3BvbnNlEnUKFkZldGNoU2Vzc2lvbkNyZWRlbnRpYWwSLC5lbmdyYW0uYXBwLnYxLkZldGNoU2Vzc2lvbkNyZWRlbnRpYWxSZXF1ZXN0Gi0uZW5ncmFtLmFwcC52MS5GZXRjaFNlc3Npb25DcmVkZW50aWFsUmVzcG9uc2USeAoXVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWwSLS5lbmdyYW0uYXBwLnYxLlVwZGF0ZVNlc3Npb25DcmVkZW50aWFsUmVxdWVzdBouLmVuZ3JhbS5hcHAudjEuVXBkYXRlU2Vzc2lvbkNyZWRlbnRpYWxSZXNwb25zZWIGcHJvdG8z"); /** * @generated from message engram.app.v1.OAuthSubject @@ -403,6 +403,15 @@ export type PutCredentialRequest = Message<"engram.app.v1.PutCredentialRequest"> * @generated from field: string secret = 3; */ secret: string; + + /** + * Optional "who am I" request, sent with the token before it is sealed. + * It fills the display and workspace names; the account id stays empty. + * A failing probe rejects the token. + * + * @generated from field: engram.app.v1.RedirectMetadataProbe identity = 4; + */ + identity?: RedirectMetadataProbe; }; /** diff --git a/web/src/hooks/useCredentials.ts b/web/src/hooks/useCredentials.ts index 85c80dcf4..2ce488b0c 100644 --- a/web/src/hooks/useCredentials.ts +++ b/web/src/hooks/useCredentials.ts @@ -69,7 +69,12 @@ async function request(path: string, init?: RequestInit): Promise { headers: { Accept: "application/json", ...init?.headers }, ...init, }); - if (!response.ok) throw new Error(`${path} → ${response.status}`); + if (!response.ok) { + // The server's message says what went wrong in words (e.g. a token the + // provider did not confirm); the status line is the fallback. + const message = (await response.text().catch(() => "")).trim(); + throw new Error(message && message.length <= 300 ? message : `${path} → ${response.status}`); + } if (response.status === 204) return undefined as T; return response.json() as Promise; } From 32b71d4f86ff43a8ff1a4d88c06b35488b08e1fe Mon Sep 17 00:00:00 2001 From: Aaron Wirick Date: Thu, 29 Oct 2026 22:59:44 +0000 Subject: [PATCH 2/7] Add an All / Mine switch to the reviews ledger and rail "Mine" shows only pull requests whose author is the reader's GitHub login, as their connected GitHub credential names it. It uses the existing server author filter, and hides the Author filter while it is on. The rail uses the same per-browser choice, so the switcher and the ledger list the same pull requests. If GitHub is not connected, or the token was saved before engrams recorded the login, "Mine" says how to fix it instead of showing an empty list. Co-Authored-By: Claude Opus 5.5 --- web/src/hooks/useCredentials.ts | 15 +++++ web/src/pages/reviews/ReviewScopeNotice.tsx | 23 +++++++ web/src/pages/reviews/Reviews.test.tsx | 40 +++++++++++ web/src/pages/reviews/Reviews.tsx | 54 ++++++++++++--- web/src/pages/reviews/ReviewsRail.tsx | 22 ++++-- web/src/pages/reviews/review-scope.ts | 75 +++++++++++++++++++++ 6 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 web/src/pages/reviews/ReviewScopeNotice.tsx create mode 100644 web/src/pages/reviews/review-scope.ts diff --git a/web/src/hooks/useCredentials.ts b/web/src/hooks/useCredentials.ts index 2ce488b0c..08b898e21 100644 --- a/web/src/hooks/useCredentials.ts +++ b/web/src/hooks/useCredentials.ts @@ -176,3 +176,18 @@ export function connectorOAuthAuthorizeUrl(provider: string, force = false): str const qs = force ? "?force=1" : ""; return `${API_BASE}/me/connector-credentials/${encodeURIComponent(provider)}/oauth/authorize${qs}`; } + +/** The account behind the caller's personal credential for a connector, as + * the provider named it when the credential was saved (GitHub: the login). */ +export function useConnectorAccount(provider: string): { + isPending: boolean; + connected: boolean; + name: string | undefined; +} { + const { data, isPending } = useCredentials(); + const entry = data?.find( + (c): c is ConnectorCredentialEntry => c.kind === "connector" && c.provider === provider, + ); + const connected = entry?.connected ?? false; + return { isPending, connected, name: connected ? entry?.account?.displayName : undefined }; +} diff --git a/web/src/pages/reviews/ReviewScopeNotice.tsx b/web/src/pages/reviews/ReviewScopeNotice.tsx new file mode 100644 index 000000000..f6612c832 --- /dev/null +++ b/web/src/pages/reviews/ReviewScopeNotice.tsx @@ -0,0 +1,23 @@ +import { Link } from "@tanstack/react-router"; + +import type { ReviewScopeState } from "./review-scope"; + +/** Why "Mine" shows no pull requests, and where to fix it. */ +export function ReviewScopeNotice({ + reason, +}: { + reason: NonNullable; +}) { + const settings = ( + + Settings → Credentials + + ); + return reason === "not_connected" ? ( + <>To see your pull requests, connect your GitHub account in {settings}. + ) : ( + // A GitHub token saved before engrams asked GitHub for the login names no + // account; saving it again records the login. + <>To see your pull requests, save your GitHub token again in {settings}. + ); +} diff --git a/web/src/pages/reviews/Reviews.test.tsx b/web/src/pages/reviews/Reviews.test.tsx index 889a21776..945087a0e 100644 --- a/web/src/pages/reviews/Reviews.test.tsx +++ b/web/src/pages/reviews/Reviews.test.tsx @@ -235,6 +235,14 @@ vi.mock("../../hooks/useReviews", () => ({ })); vi.mock("../../hooks/useNow", () => ({ useNow: () => 0 })); +/** The reader's GitHub credential, as Settings → Credentials reports it. */ +const github: { isPending: boolean; connected: boolean; name: string | undefined } = { + isPending: false, + connected: true, + name: "octo-dev", +}; +vi.mock("../../hooks/useCredentials", () => ({ useConnectorAccount: () => github })); + // jsdom has no IntersectionObserver. Record what the sentinel observes and let // a test bring it into view. const observed: Element[] = []; @@ -283,6 +291,10 @@ beforeEach(() => { view.hasNextPage = false; listParams.length = 0; observed.length = 0; + localStorage.clear(); + github.isPending = false; + github.connected = true; + github.name = "octo-dev"; retryMutate.mockClear(); fetchNextPage.mockClear(); }); @@ -406,6 +418,34 @@ describe("Reviews ledger", () => { expect(screen.getByText("Bump quinn-proto from 0.11.14 to 0.11.16")).toBeTruthy(); }); + it("narrows to the reader's own pull requests by their GitHub login", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await screen.findByText("Bump quinn-proto from 0.11.14 to 0.11.16"); + expect(listParams.at(-1)?.authors).toBeUndefined(); + + await user.click(screen.getByRole("tab", { name: "Mine" })); + + await waitFor(() => expect(listParams.at(-1)).toMatchObject({ authors: ["octo-dev"] })); + // The choice outlives the page, so the rail and the next visit agree. + expect(localStorage.getItem("engrams.reviewsScope")).toBe("mine"); + }); + + it("says how to get Mine working when GitHub names no account", async () => { + localStorage.setItem("engrams.reviewsScope", "mine"); + github.connected = false; + github.name = undefined; + const { unmount } = renderWithProviders(); + expect(await screen.findByText(/connect your GitHub account/)).toBeTruthy(); + expect(screen.queryByText("Bump quinn-proto from 0.11.14 to 0.11.16")).toBeNull(); + unmount(); + + // A token saved before engrams asked GitHub for the login. + github.connected = true; + renderWithProviders(); + expect(await screen.findByText(/save your GitHub token again/)).toBeTruthy(); + }); + it("reads the next page from a sentinel and says how much is in hand", async () => { view.totalCount = 40; view.hasNextPage = true; diff --git a/web/src/pages/reviews/Reviews.tsx b/web/src/pages/reviews/Reviews.tsx index 1b6f4c41f..fd997ee77 100644 --- a/web/src/pages/reviews/Reviews.tsx +++ b/web/src/pages/reviews/Reviews.tsx @@ -11,6 +11,7 @@ import { relativeAge } from "@/lib/relative-time"; import { FilterBar, type FilterField } from "../sessions/filter-bar"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EmptyState } from "@/components/empty-state"; import { SkeletonRows } from "@/components/skeleton-rows"; import { GitFork, X } from "lucide-react"; @@ -18,6 +19,8 @@ import { useAbility } from "../../auth/AuthProvider"; import { ReviewStage } from "./ReviewGlyph"; import { Sep } from "./Sep"; import { groupByPr, type PrGroup } from "./review-groups"; +import { useReviewScope, type ReviewScope } from "./review-scope"; +import { ReviewScopeNotice } from "./ReviewScopeNotice"; import { diffShort, isActive, @@ -58,6 +61,10 @@ export function Reviews() { const [query, setQuery] = useState(""); const search = useDebouncedValue(query, 150).trim(); const [filters, setFilters] = useState>({}); + // "Mine" narrows the list to the reader's own pull requests by author, so + // it replaces the author filter while it is on. + const reviewScope = useReviewScope(); + const mine = reviewScope.scope === "mine"; const { reviews, @@ -72,7 +79,7 @@ export function Reviews() { { search, repos: filters["repo"], - authors: filters["author"], + authors: mine ? reviewScope.authors : filters["author"], prStates: filters["pr_state"], statuses: filters["stage"], severities: filters["severity"], @@ -97,11 +104,15 @@ export function Reviews() { label: "Repository", options: facets.repos.map((repo) => ({ value: repo, label: repo })), }, - { - key: "author", - label: "Author", - options: facets.authors.map((author) => ({ value: author, label: author })), - }, + ...(mine + ? [] + : [ + { + key: "author", + label: "Author", + options: facets.authors.map((author) => ({ value: author, label: author })), + }, + ]), { key: "pr_state", label: "Pull request", @@ -127,10 +138,13 @@ export function Reviews() { })), }, ], - [facets], + [facets, mine], ); - const hasFilters = query !== "" || Object.values(filters).some((v) => v.length > 0); + // The author filter does not apply under "Mine", so it does not count. + const hasFilters = + query !== "" || + Object.entries(filters).some(([key, v]) => v.length > 0 && !(mine && key === "author")); // One count, in the masthead: the pull requests matching the filters, which // the server knows whether or not the reader has scrolled to them all. @@ -156,6 +170,20 @@ export function Reviews() { />
+ reviewScope.setScope(value as ReviewScope)} + > + + All + + Mine + + + setQuery(e.target.value)} @@ -179,7 +207,11 @@ export function Reviews() { )}
- {isPending ? ( + {reviewScope.unavailable ? ( + + + + ) : isPending || reviewScope.pending ? ( {hasFilters ? "No pull requests match these filters." - : "No pull requests reviewed yet. Enrol a repository in settings, or ask for a review on a PR with @engrams review."} + : mine + ? `No pull requests by ${reviewScope.login} have been reviewed yet.` + : "No pull requests reviewed yet. Enrol a repository in settings, or ask for a review on a PR with @engrams review."} ) : ( <> diff --git a/web/src/pages/reviews/ReviewsRail.tsx b/web/src/pages/reviews/ReviewsRail.tsx index 2fb116840..be3d915b6 100644 --- a/web/src/pages/reviews/ReviewsRail.tsx +++ b/web/src/pages/reviews/ReviewsRail.tsx @@ -11,6 +11,8 @@ import { EngramMark } from "../../components/EngramMark"; import { LivePulse } from "../../components/LivePulse"; import { ReviewGlyph } from "./ReviewGlyph"; import { groupByPr } from "./review-groups"; +import { useReviewScope } from "./review-scope"; +import { ReviewScopeNotice } from "./ReviewScopeNotice"; import { isActive, prTitleOf, reviewCreatedAt, stageOf } from "./review-format"; /** Pull requests per page of the rail. */ @@ -44,13 +46,17 @@ export function ReviewsRail() { const now = useNow(); const [search, setSearch] = useState(""); const debounced = useDebouncedValue(search, 150).trim(); + // The rail follows the ledger's All / Mine choice, so the switcher lists + // the same pull requests the ledger does. + const reviewScope = useReviewScope(); + const mine = reviewScope.scope === "mine"; // The search runs on the server, over what the row shows — the repo, the // number, the title and the author — so searching for "quinn" finds the PR a // developer remembers by name, on whichever page it sits. The rail reads a // page of pull requests at a time, like the tasks rail. const { reviews, hasNextPage, isFetchingNextPage, fetchNextPage, isPending, error } = - useReviewsInfinite({ search: debounced }, PAGE_SIZE); + useReviewsInfinite({ search: debounced, authors: reviewScope.authors }, PAGE_SIZE); const loadMoreRef = useLoadMoreSentinel({ hasMore: hasNextPage, isFetching: isFetchingNextPage, @@ -80,7 +86,7 @@ export function ReviewsRail() { - Reviewed PRs + {mine ? "My reviewed PRs" : "Reviewed PRs"} {/* The review glyph reads content-surface instrument tokens, which lose contrast on the deep-green rail. Re-tone them to the @@ -99,7 +105,11 @@ export function ReviewsRail() { } as CSSProperties } > - {isPending ? ( + {reviewScope.unavailable ? ( +

+ +

+ ) : isPending || reviewScope.pending ? ( Array.from({ length: 5 }).map((_, i) => ( @@ -114,7 +124,11 @@ export function ReviewsRail() {

) : rows.length === 0 ? (

- {search ? "No matching pull requests." : "No reviews yet."} + {search + ? "No matching pull requests." + : mine + ? "None of your pull requests have been reviewed yet." + : "No reviews yet."}

) : ( rows.map((group) => { diff --git a/web/src/pages/reviews/review-scope.ts b/web/src/pages/reviews/review-scope.ts new file mode 100644 index 000000000..5dbc49faf --- /dev/null +++ b/web/src/pages/reviews/review-scope.ts @@ -0,0 +1,75 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import { useConnectorAccount } from "../../hooks/useCredentials"; + +// Which pull requests the reviews ledger and rail show: every reviewed PR, or +// only the ones the reader opened. "Mine" means the PR author is the GitHub +// login of the reader's connected GitHub credential — reviews come from +// GitHub, and the login is the name GitHub records as the author. +// +// The choice is a per-browser preference, like the rail width, so the ledger +// and the rail read one key and stay in step: the custom event syncs the +// same tab, the `storage` event the other tabs. + +export type ReviewScope = "all" | "mine"; + +const STORAGE_KEY = "engrams.reviewsScope"; +const CHANGE_EVENT = "engrams-reviews-scope-change"; + +function read(): ReviewScope { + if (typeof localStorage === "undefined") return "all"; + return localStorage.getItem(STORAGE_KEY) === "mine" ? "mine" : "all"; +} + +function subscribe(onChange: () => void): () => void { + window.addEventListener("storage", onChange); + window.addEventListener(CHANGE_EVENT, onChange); + return () => { + window.removeEventListener("storage", onChange); + window.removeEventListener(CHANGE_EVENT, onChange); + }; +} + +export interface ReviewScopeState { + scope: ReviewScope; + setScope: (scope: ReviewScope) => void; + /** The reader's GitHub login, when their GitHub credential names one. */ + login: string | undefined; + /** "mine" is chosen and the reader's GitHub account is still loading. */ + pending: boolean; + /** "mine" is chosen but cannot apply: no GitHub credential, or one that + * names no account (saved before engrams asked GitHub for the login). */ + unavailable: "not_connected" | "no_login" | undefined; + /** The author filter the scope adds to the list query. */ + authors: string[] | undefined; +} + +export function useReviewScope(): ReviewScopeState { + const scope = useSyncExternalStore(subscribe, read, () => "all" as const); + const github = useConnectorAccount("github"); + + const setScope = useCallback((next: ReviewScope) => { + localStorage.setItem(STORAGE_KEY, next); + window.dispatchEvent(new Event(CHANGE_EVENT)); + }, []); + + const login = github.name; + const mine = scope === "mine"; + // "Mine" without a login says why, rather than showing every PR under a + // "Mine" label or an empty list that reads as "you have no pull requests". + const unavailable = + !mine || github.isPending || login + ? undefined + : github.connected + ? "no_login" + : "not_connected"; + + return { + scope, + setScope, + login, + pending: mine && github.isPending, + unavailable, + authors: mine && login ? [login] : undefined, + }; +} From a3acdd3dd422d04aabf5d29b12eafaab851b9dbd Mon Sep 17 00:00:00 2001 From: Aaron Wirick Date: Fri, 30 Oct 2026 19:51:21 +0000 Subject: [PATCH 3/7] Ask OAuth token endpoints for a JSON response RFC 6749 token endpoints answer in JSON, but GitHub answers in form encoding unless the request asks for JSON. The coordinator could then not read a GitHub token response ("token response was not JSON"). The token request (code exchange, refresh, and server-side grants) now sends `Accept: application/json`. This does not change the result for providers that already answer in JSON. Co-Authored-By: Claude Opus 5.5 --- .../engram-coordinator/src/oauth_redirect.rs | 6 +++- .../tests/oauth_redirect.rs | 29 ++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/engram-coordinator/src/oauth_redirect.rs b/crates/engram-coordinator/src/oauth_redirect.rs index b050a1722..a680538b3 100644 --- a/crates/engram-coordinator/src/oauth_redirect.rs +++ b/crates/engram-coordinator/src/oauth_redirect.rs @@ -640,7 +640,11 @@ impl OAuthManager { .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| driver_err("http_client", e.to_string()))?; - let request = http.post(token_url); + // RFC 6749 answers in JSON, but some providers (GitHub) answer the + // token request in form encoding unless the client asks for JSON. + let request = http + .post(token_url) + .header(reqwest::header::ACCEPT, "application/json"); let request = match body { TokenRequestBody::Form => request.form(form), TokenRequestBody::Json => { diff --git a/crates/engram-coordinator/tests/oauth_redirect.rs b/crates/engram-coordinator/tests/oauth_redirect.rs index 882e6f2bf..623d75022 100644 --- a/crates/engram-coordinator/tests/oauth_redirect.rs +++ b/crates/engram-coordinator/tests/oauth_redirect.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use axum::extract::State; +use axum::http::HeaderMap; use axum::routing::{get, post}; use axum::{Json, Router}; use engram_coordinator::oauth::OAuthManager; @@ -28,6 +29,8 @@ use serde_json::{json, Value}; struct Provider { token_response: Arc>, token_requests: Arc>>>, + /// The `Accept` header of each token request. + token_accepts: Arc>>, viewer_response: Arc>, } @@ -35,13 +38,20 @@ async fn serve(provider: Provider) -> String { let router = Router::new() .route( "/oauth/token", - post(|State(p): State, body: String| async move { - let form: BTreeMap = url::form_urlencoded::parse(body.as_bytes()) - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect(); - p.token_requests.lock().push(form); - Json(p.token_response.lock().clone()) - }), + post( + |State(p): State, headers: HeaderMap, body: String| async move { + let accept = headers.get("accept").and_then(|v| v.to_str().ok()); + p.token_accepts + .lock() + .push(accept.unwrap_or_default().to_owned()); + let form: BTreeMap = + url::form_urlencoded::parse(body.as_bytes()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + p.token_requests.lock().push(form); + Json(p.token_response.lock().clone()) + }, + ), ) .route( "/graphql", @@ -258,6 +268,11 @@ async fn complete_publishes_bundle_with_rotation_and_probe_metadata() { assert_eq!(reqs[0]["client_id"], "client-123"); assert_eq!(reqs[0]["client_secret"], "secret-456"); assert_eq!(reqs[0]["redirect_uri"], "https://cb.example.com/x"); + // GitHub answers in form encoding unless the request asks for JSON. + assert_eq!( + *fx.provider.token_accepts.lock(), + vec!["application/json".to_owned()] + ); // Probe metadata extracted; expiry mirrored outside the ciphertext. assert_eq!(row.metadata.account_id, "app-user-1"); From 7498f520d168f9035f444fed3a64e1918d5140d9 Mon Sep 17 00:00:00 2001 From: Aaron Wirick Date: Fri, 30 Oct 2026 19:51:22 +0000 Subject: [PATCH 4/7] Let members connect GitHub with OAuth through the GitHub App A member could only paste a personal access token for GitHub. Now a member can click Connect in Settings -> Credentials and authorize the org's GitHub App to act as them. The user token has the App's permissions, limited to the member's own access. It expires after eight hours and the coordinator refreshes it, as it does for other personal OAuth credentials. - The `github_app` mint kind gets two optional fields, `client_id` and `client_secret`: the App's OAuth client. The admin enters them in the same GitHub setup form. Minting does not use them. - A mint connector has no org OAuth facet to reuse, so its `userCredential.oauth` is now a complete authorization-code flow of its own (`oauthFacet`). Its token URL may be on an acquisition host: GitHub exchanges codes on github.com, and adding github.com to `hosts` would open it to session injection. A personal flow can name no scopes, because GitHub ignores scopes for App user tokens. - GitHub's flow asks `GET /user` for the account: `id` is the account id and `login` the name, so the Reviews "Mine" filter works after connect. - Settings -> Credentials offers Connect only when the org has the app's client id and secret. Without them the button could only fail. - The GitHub setup panel shows the callback URL to set on the App, and the App manifest sets it. Co-Authored-By: Claude Opus 5.5 --- crates/engram-git-github/src/lib.rs | 19 ++++- crates/engram-git-github/tests/github.rs | 17 ++++- orchestrator/src/__tests__/connectors.test.ts | 72 ++++++++++++++++--- orchestrator/src/__tests__/me-oauth.test.ts | 28 +++++++- .../src/__tests__/slack-integration.test.ts | 33 +++++++++ orchestrator/src/connectors/github.json | 18 +++++ orchestrator/src/connectors/oauth-spec.ts | 27 +++++-- orchestrator/src/connectors/registry.ts | 64 +++++++++++++---- orchestrator/src/routes/integration-oauth.ts | 25 +++++-- orchestrator/src/routes/me.ts | 56 ++++++++++++--- .../integrations/GithubSetupPanel.tsx | 43 ++++++++++- web/src/lib/githubSetup.test.ts | 6 ++ web/src/lib/githubSetup.ts | 11 +++ 13 files changed, 373 insertions(+), 46 deletions(-) diff --git a/crates/engram-git-github/src/lib.rs b/crates/engram-git-github/src/lib.rs index b63803e3e..ed2581499 100644 --- a/crates/engram-git-github/src/lib.rs +++ b/crates/engram-git-github/src/lib.rs @@ -560,7 +560,9 @@ async fn ensure_ok( /// ID + RSA private-key PEM. An admin supplies both via the integrations UI (the /// PEM sealed in the org-secret store); the coordinator resolves the two fields /// (org secrets `github_app.app_id` / `github_app.private_key_pem`, name = -/// `.`) and calls `build` to construct the engine lazily. This is +/// `.`) and calls `build` to construct the engine lazily. The +/// optional `client_id` / `client_secret` are the App's OAuth client, which +/// the GitHub connector's personal OAuth flow reads. This is /// the data-driven frame around minting — the mint logic stays in [`GitHubApp`]. pub fn github_app_descriptor() -> MintKindDescriptor { MintKindDescriptor { @@ -580,6 +582,21 @@ pub fn github_app_descriptor() -> MintKindDescriptor { field_kind: MintFieldKind::SealedSecret, required: true, }, + // The App's own OAuth client. Optional: with both set, members + // can connect their personal GitHub account through the App + // instead of pasting a token. Minting does not use them. + MintFieldSchema { + name: "client_id", + label: "Client ID (optional, for personal connect)", + field_kind: MintFieldKind::Config, + required: false, + }, + MintFieldSchema { + name: "client_secret", + label: "Client secret (optional, for personal connect)", + field_kind: MintFieldKind::SealedSecret, + required: false, + }, ], build: |fields: &ResolvedFields| { let app_id = fields.get("app_id").ok_or_else(|| { diff --git a/crates/engram-git-github/tests/github.rs b/crates/engram-git-github/tests/github.rs index bc63018b4..87837ab32 100644 --- a/crates/engram-git-github/tests/github.rs +++ b/crates/engram-git-github/tests/github.rs @@ -121,7 +121,10 @@ fn github_app_descriptor_metadata() { // The field names are the org-secret suffixes the coordinator resolves; the // Plane-A form (C4) renders from this metadata, so pin the contract. let names: Vec<&str> = d.fields.iter().map(|f| f.name).collect(); - assert_eq!(names, vec!["app_id", "private_key_pem"]); + assert_eq!( + names, + vec!["app_id", "private_key_pem", "client_id", "client_secret"] + ); let pem = d .fields .iter() @@ -131,6 +134,18 @@ fn github_app_descriptor_metadata() { assert!(pem.required); let app_id = d.fields.iter().find(|f| f.name == "app_id").unwrap(); assert_eq!(app_id.field_kind, MintFieldKind::Config); + // The App's OAuth client serves personal connect only; minting works + // without it. + let client_secret = d.fields.iter().find(|f| f.name == "client_secret").unwrap(); + assert_eq!(client_secret.field_kind, MintFieldKind::SealedSecret); + assert!(!client_secret.required); + assert!( + !d.fields + .iter() + .find(|f| f.name == "client_id") + .unwrap() + .required + ); } #[test] diff --git a/orchestrator/src/__tests__/connectors.test.ts b/orchestrator/src/__tests__/connectors.test.ts index 4ec63db56..2a2d5dd1b 100644 --- a/orchestrator/src/__tests__/connectors.test.ts +++ b/orchestrator/src/__tests__/connectors.test.ts @@ -767,7 +767,17 @@ describe("on-disk registry", () => { expect(userScopes).toContain("channels:write"); expect(userScopes).toContain("chat:write"); expect(reg.get("sentry")!.userCredential?.token?.hint).toContain("User Auth Tokens"); - expect(reg.get("github")!.userCredential?.oauth).toBeUndefined(); + // GitHub's personal OAuth is the App's own user flow: it exchanges the + // code on github.com and names the account by its login. + expect(reg.get("github")!.userCredential?.oauth).toBe(true); + expect(reg.get("github")!.userCredential?.oauthFacet).toMatchObject({ + authorizeUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + scopes: [], + clientIdRef: "github_app.client_id", + clientSecretRef: "github_app.client_secret", + metadata: { probe: { path: "/user", map: { accountId: "id", displayName: "login" } } }, + }); expect(reg.get("github")!.userCredential?.token?.identity).toEqual({ path: "/user", map: { displayName: "login" }, @@ -1495,16 +1505,60 @@ describe("user-scoped credentials (ADR 0115)", () => { expect(() => parseConnector(noInject, "t")).toThrow(/"inject" is required on a mint connector/); }); - test("rejects oauth mode on a mint connector", () => { - const withOauth = { - ...acmeMintRaw, - userCredential: { - oauth: true, - token: { hint: "x" }, - inject: { header: "Authorization", template: "Bearer {}" }, + // A mint connector has no org OAuth facet, so its personal flow is a + // complete authorization-code flow of its own (GitHub's App user flow). + const mintFlow = { + authorizeUrl: "https://acmemint.test/login/oauth/authorize", + acquisitionHosts: ["acmemint.test"], + tokenUrl: "https://acmemint.test/login/oauth/access_token", + clientIdRef: "acme_app.client_id", + clientSecretRef: "acme_app.client_secret", + }; + const withMintOauth = (oauth: unknown) => ({ + ...acmeMintRaw, + userCredential: { + oauth, + token: { hint: "x" }, + inject: { header: "Authorization", template: "Bearer {}" }, + }, + }); + + test("accepts a mint connector's own personal OAuth flow", () => { + const c = parseConnector(withMintOauth(mintFlow), "acmemint"); + expect(c.userCredential?.oauth).toBe(true); + // The token URL may sit on an acquisition host, and no scopes is fine. + expect(c.userCredential?.oauthFacet).toMatchObject({ + grant: "authorization_code", + tokenUrl: "https://acmemint.test/login/oauth/access_token", + scopes: [], + clientIdRef: "acme_app.client_id", + }); + }); + + test("rejects a mint connector's oauth that is not a flow of its own", () => { + expect(() => parseConnector(withMintOauth(true), "t")).toThrow(/must declare its own flow/); + expect(() => + parseConnector(withMintOauth({ ...mintFlow, grant: "client_credentials" }), "t"), + ).toThrow(/"authorization_code" for a personal flow/); + expect(() => + parseConnector( + withMintOauth({ ...mintFlow, tokenUrl: "https://elsewhere.test/token" }), + "t", + ), + ).toThrow(/"oauth.tokenUrl" host "elsewhere.test"/); + }); + + test("an org OAuth facet keeps its token URL on the connector's hosts", () => { + // The relaxation is for a personal flow only. + const org = { + ...acmeRaw, + credential: { + source: "inject", + injects: [{ header: "Authorization", template: "Bearer {}" }], }, + oauth: { ...mintFlow, scopes: ["read"], tokenUrl: "https://acmemint.test/token" }, }; - expect(() => parseConnector(withOauth, "t")).toThrow(/token" mode only/); + expect(() => parseConnector(org, "t")).toThrow(/"oauth.tokenUrl" host/); }); test("rejects oauth mode without the top-level oauth facet", () => { diff --git a/orchestrator/src/__tests__/me-oauth.test.ts b/orchestrator/src/__tests__/me-oauth.test.ts index eff9f0d42..d97080928 100644 --- a/orchestrator/src/__tests__/me-oauth.test.ts +++ b/orchestrator/src/__tests__/me-oauth.test.ts @@ -27,10 +27,16 @@ const acmeConnector = { userCredential: { token: { hint: "Create a PAT." } }, }; +/** The org secret names the fake coordinator holds. */ +let orgSecretNames: string[] = []; + function appWith(oauth: OAuthCredentialClient) { return makeMeRoute({ secrets, oauth, + orgSecrets: { + listSecrets: async () => ({ secrets: orgSecretNames.map((name) => ({ name })) }), + }, getSession: async () => ({ user: { id: "user-42", role: "user" } }), connectors: { list: async () => [{ provider: "acme", config: acmeConnector }] }, harnessCatalog: { @@ -127,7 +133,10 @@ describe("/me OAuth credentials", () => { }); describe("/me connector credentials (ADR 0115)", () => { - beforeEach(() => invalidateRegistry()); + beforeEach(() => { + invalidateRegistry(); + orgSecretNames = []; + }); function fakeOauth() { const calls: { @@ -210,6 +219,23 @@ describe("/me connector credentials (ADR 0115)", () => { ); }); + test("offers OAuth connect only when the app's client id and secret are set", async () => { + const { oauth } = fakeOauth(); + const githubModes = async () => { + const listed = await appWith(oauth).request("/api/v1/me/credentials"); + const body = (await listed.json()) as { credentials: Array> }; + return body.credentials.find( + (entry) => entry.kind === "connector" && entry.provider === "github", + )?.modes; + }; + // Without the GitHub App's OAuth client, a Connect button would only fail. + expect(await githubModes()).toEqual({ oauth: false, token: true }); + orgSecretNames = ["github_app.client_id"]; + expect(await githubModes()).toEqual({ oauth: false, token: true }); + orgSecretNames = ["github_app.client_id", "github_app.client_secret"]; + expect(await githubModes()).toEqual({ oauth: true, token: true }); + }); + test("PUT 404s for a provider without token mode and 400s an empty value", async () => { const { oauth, calls } = fakeOauth(); const app = appWith(oauth); diff --git a/orchestrator/src/__tests__/slack-integration.test.ts b/orchestrator/src/__tests__/slack-integration.test.ts index c03b2013b..a2e83771f 100644 --- a/orchestrator/src/__tests__/slack-integration.test.ts +++ b/orchestrator/src/__tests__/slack-integration.test.ts @@ -654,6 +654,39 @@ describe("integration oauth route", () => { }); }); + test("a mint connector's own personal flow: GitHub authorize and callback", async () => { + const { client, calls } = fakeRedirectClient({ + flowSubject: { kind: OauthSubjectKind.USER_CONNECTOR, id: "u2" }, + }); + const app = makeIntegrationOauthRoute({ + connectors: emptySource, + oauthCredential: client as never, + connectionIdFor, + getSession: memberSession, + }); + const begun = await app.request("/api/v1/me/connector-credentials/github/oauth/authorize"); + expect(begun.status).toBe(302); + const spec = calls.begin[0]!.spec!; + expect(calls.begin[0]!.subject).toMatchObject({ kind: OauthSubjectKind.USER_CONNECTOR, id: "u2" }); + expect(spec.authorizeUrl).toBe("https://github.com/login/oauth/authorize"); + expect(spec.tokenUrl).toBe("https://github.com/login/oauth/access_token"); + expect(spec.scopes).toEqual([]); + expect(spec.clientIdRef).toBe("github_app.client_id"); + expect(spec.metadata?.probe).toMatchObject({ + host: "api.github.com", + path: "/user", + map: { account_id: "id", display_name: "login" }, + }); + + const done = await app.request( + "/api/v1/integrations/github/oauth/callback?code=abc&state=10600000-0000-4000-8000-0000000000aa", + ); + expect(done.status).toBe(302); + expect(done.headers.get("location")).toBe("/settings/credentials?connected=github"); + // The completion uses the same spec the flow began with. + expect(calls.complete[0]!.spec?.tokenUrl).toBe("https://github.com/login/oauth/access_token"); + }); + test("callback: a user-subject flow is fenced to its owner", async () => { const { client, calls } = fakeRedirectClient({ flowSubject: { kind: OauthSubjectKind.USER_CONNECTOR, id: "someone-else" }, diff --git a/orchestrator/src/connectors/github.json b/orchestrator/src/connectors/github.json index 3116b80d5..afd56be72 100644 --- a/orchestrator/src/connectors/github.json +++ b/orchestrator/src/connectors/github.json @@ -17,6 +17,24 @@ } }, "userCredential": { + "oauth": { + "authorizeUrl": "https://github.com/login/oauth/authorize", + "acquisitionHosts": [ + "github.com" + ], + "tokenUrl": "https://github.com/login/oauth/access_token", + "clientIdRef": "github_app.client_id", + "clientSecretRef": "github_app.client_secret", + "metadata": { + "probe": { + "path": "/user", + "map": { + "accountId": "id", + "displayName": "login" + } + } + } + }, "token": { "hint": "Create a fine-grained personal access token under GitHub Settings \u2192 Developer settings, with the permissions you want sessions to hold.", "identity": { diff --git a/orchestrator/src/connectors/oauth-spec.ts b/orchestrator/src/connectors/oauth-spec.ts index 6c39620f8..81018a047 100644 --- a/orchestrator/src/connectors/oauth-spec.ts +++ b/orchestrator/src/connectors/oauth-spec.ts @@ -7,7 +7,12 @@ */ import { create } from "@bufbuild/protobuf"; -import type { Connector, OauthMetadataField, OauthMetadataProbe } from "./registry.ts"; +import type { + Connector, + OauthFacet, + OauthMetadataField, + OauthMetadataProbe, +} from "./registry.ts"; import { ClientCredentialsSpecSchema, RedirectMetadataProbeSchema, @@ -58,8 +63,10 @@ export function userTokenIdentityProbe(connector: Connector): RedirectMetadataPr /** Build the wire spec for a connector's oauth facet. `extraParams` lets a * caller append flow extras (e.g. `prompt: "consent"` on a reconnect); facet * params win on collision so a connector's declared behavior is stable. */ -function wireMetadata(connector: Connector): RedirectMetadataSpec | undefined { - const oauth = connector.oauth; +function wireMetadata( + connector: Connector, + oauth: OauthFacet | undefined = connector.oauth, +): RedirectMetadataSpec | undefined { if (!oauth?.metadata) return undefined; return create(RedirectMetadataSpecSchema, { fromTokenResponse: wireMap(oauth.metadata.fromTokenResponse), @@ -111,10 +118,18 @@ export function redirectOauthSpec( ): RedirectOauthSpec { const oauth = connector.oauth; if (!oauth) throw new Error(`connector "${connector.provider}" has no oauth facet`); + return redirectSpecFromFacet(connector, oauth, extraParams); +} + +function redirectSpecFromFacet( + connector: Connector, + oauth: OauthFacet, + extraParams?: Record, +): RedirectOauthSpec { if (oauth.grant === "client_credentials" || oauth.authorizeUrl === undefined) { throw new Error(`connector "${connector.provider}" has no authorization-code flow`); } - const metadata = wireMetadata(connector); + const metadata = wireMetadata(connector, oauth); return create(RedirectOauthSpecSchema, { authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, @@ -141,6 +156,10 @@ export function userRedirectOauthSpec( connector: Connector, extraParams?: Record, ): RedirectOauthSpec { + // A mint connector's personal flow is its own facet (GitHub: the App's + // user authorization); nothing org-side is inherited, so it applies as is. + const own = connector.userCredential?.oauthFacet; + if (own) return redirectSpecFromFacet(connector, own, extraParams); const oauth = connector.oauth; const mode = connector.userCredential?.oauth; if (!oauth || mode === undefined) { diff --git a/orchestrator/src/connectors/registry.ts b/orchestrator/src/connectors/registry.ts index 985deb4ca..830eeb840 100644 --- a/orchestrator/src/connectors/registry.ts +++ b/orchestrator/src/connectors/registry.ts @@ -325,7 +325,9 @@ export interface OauthMetadataProbe { * into the launching user's PERSONAL credential for human sessions. * * - `oauth: true` reuses the top-level {@link OauthFacet} (same BYO app, same - * scopes) with a user subject in the sealed store. + * scopes) with a user subject in the sealed store. A mint connector has no + * top-level facet, so its `oauth` is a complete authorization-code flow of + * its own (GitHub: the App's user authorization). * - `token` accepts a pasted personal access token; `hint` is the setup line * shown in Settings → Credentials. * - `inject` is required for MINT connectors only (the mint engine renders @@ -358,8 +360,12 @@ export interface UserOauthOverrides { export interface UserCredentialFacet { /** `true` reuses the facet as-is (minus its authorize extras); an object - * overrides the user flow's shape. */ + * overrides the user flow's shape. On a mint connector the JSON object is + * the flow itself, parsed into `oauthFacet`, and this is `true`. */ oauth?: true | UserOauthOverrides; + /** A mint connector's own personal OAuth flow (GitHub: the App's user + * authorization). Mint connectors have no org OAuth facet to reuse. */ + oauthFacet?: OauthFacet; token?: UserTokenFacet; inject?: { header: string; template: string }; } @@ -1303,9 +1309,27 @@ function parseMetadataProbe( * admin-authored connector can't ship it elsewhere); `authorizeUrl` may instead * sit on the facet's own `acquisitionHosts` — a browser-redirect surface that is * never compiled into the session egress policy. */ -function parseOauth(where: string, raw: unknown, hosts: string[]): OauthFacet { +function parseOauth( + where: string, + raw: unknown, + hosts: string[], + personal = false, +): OauthFacet { if (typeof raw !== "object" || raw === null) fail(where, '"oauth" must be an object'); const o0 = raw as Record; + // A personal flow of its own (a mint connector has no org OAuth facet to + // reuse) is a member's consent screen: authorization code only, with no + // org-side secrets. Its token URL may sit on an acquisition host — GitHub + // exchanges codes on github.com, and putting github.com into `hosts` would + // open it to session injection. + if (personal) { + if (o0.grant !== undefined && o0.grant !== "authorization_code") { + fail(where, '"oauth.grant" must be "authorization_code" for a personal flow'); + } + for (const field of ["signingSecretRef", "signingSecretHint", "serviceAccountKeyRef"]) { + if (o0[field] !== undefined) fail(where, `"oauth.${field}" does not apply to a personal flow`); + } + } let grant: OauthFacet["grant"] = "authorization_code"; if (o0.grant !== undefined) { if ( @@ -1383,9 +1407,16 @@ function parseOauth(where: string, raw: unknown, hosts: string[]): OauthFacet { ? undefined : httpsUrlOn("authorizeUrl", o.authorizeUrl, [...hosts, ...(acquisitionHosts ?? [])]); // Strict: the token exchange carries the client secret. - const tokenUrl = httpsUrlOn("tokenUrl", o.tokenUrl, hosts); + const tokenUrl = httpsUrlOn( + "tokenUrl", + o.tokenUrl, + personal ? [...hosts, ...(acquisitionHosts ?? [])] : hosts, + ); - const scopes = asStringArray(`${where} oauth`, "scopes", o.scopes); + // A personal flow may name no scopes: a GitHub App's user token carries the + // App's permissions, and GitHub ignores the scope parameter. + const scopes = + personal && o.scopes === undefined ? [] : asStringArray(`${where} oauth`, "scopes", o.scopes); if (scopes.length > MAX_OAUTH_SCOPES) fail(where, `"oauth.scopes" has ${scopes.length} entries (max ${MAX_OAUTH_SCOPES})`); for (const s of scopes) { if (/[\s\r\n]/.test(s)) fail(where, `"oauth.scopes" entry "${s}" must not contain whitespace`); @@ -2257,9 +2288,10 @@ export function parseConnector( // - at least one mode (oauth / token); // - inject connectors need exactly ONE header (the user value renders // through it) and must NOT carry a `userCredential.inject`; - // - mint connectors are token-only and MUST carry `userCredential.inject` - // (the mint engine renders the org header, so user mode needs its own); - // - `oauth: true` requires the top-level oauth facet. + // - mint connectors MUST carry `userCredential.inject` (the mint engine + // renders the org header, so user mode needs its own), and their + // `oauth` is a full personal flow of its own; + // - `oauth: true` on an inject connector requires the top-level facet. let userCredential: UserCredentialFacet | undefined; if (o.userCredential !== undefined) { const uw = `${where} userCredential`; @@ -2268,7 +2300,17 @@ export function parseConnector( } const uc = o.userCredential as Record; let userOauth: true | UserOauthOverrides | undefined; - if (uc.oauth !== undefined) { + let userOauthFacet: OauthFacet | undefined; + if (uc.oauth !== undefined && credential.source === "mint") { + if (typeof uc.oauth !== "object" || uc.oauth === null || Array.isArray(uc.oauth)) { + fail( + uw, + '"oauth" on a mint connector must declare its own flow (authorizeUrl, tokenUrl, client refs): there is no org OAuth facet to reuse', + ); + } + userOauthFacet = parseOauth(uw, uc.oauth, hosts, true); + userOauth = true; + } else if (uc.oauth !== undefined) { if (uc.oauth === true) { userOauth = true; } else if (typeof uc.oauth === "object" && uc.oauth !== null && !Array.isArray(uc.oauth)) { @@ -2426,15 +2468,13 @@ export function parseConnector( fail(uw, '"inject" is only for mint connectors (inject connectors render the user value through their existing header)'); } } else { - if (userOauth !== undefined) { - fail(uw, 'mint connectors support "token" mode only (user-to-server OAuth is not implemented)'); - } if (userInject === undefined) { fail(uw, '"inject" is required on a mint connector (the mint engine renders the org header, so user mode needs its own header spec)'); } } userCredential = { ...(userOauth !== undefined ? { oauth: userOauth } : {}), + ...(userOauthFacet !== undefined ? { oauthFacet: userOauthFacet } : {}), ...(token ? { token } : {}), ...(userInject ? { inject: userInject } : {}), }; diff --git a/orchestrator/src/routes/integration-oauth.ts b/orchestrator/src/routes/integration-oauth.ts index a03c69cf6..309446511 100644 --- a/orchestrator/src/routes/integration-oauth.ts +++ b/orchestrator/src/routes/integration-oauth.ts @@ -112,6 +112,20 @@ export function makeIntegrationOauthRoute(deps?: IntegrationOauthDeps): Hono { return conn; } + /** A connector a member can connect with their own OAuth consent: through + * the org facet (Linear, Slack) or through a personal flow of its own on a + * mint connector (GitHub). */ + async function userOauthConnector(provider: string): Promise { + const registry = await loadRegistry(connectorSource()); + const conn = registry.get(provider); + if (conn?.userCredential?.oauth === undefined) { + throw new HTTPException(404, { + message: `connector "${provider}" has no user-scoped OAuth flow`, + }); + } + return conn; + } + async function subjectFor(connector: Connector) { const id = await connectionIdFor( connector.provider, @@ -162,12 +176,7 @@ export function makeIntegrationOauthRoute(deps?: IntegrationOauthDeps): Hono { app.get("/api/v1/me/connector-credentials/:provider/oauth/authorize", async (c) => { const user = await requireUser(c.req.raw.headers); const provider = c.req.param("provider"); - const connector = await oauthConnector(provider); - if (connector.userCredential?.oauth === undefined) { - throw new HTTPException(404, { - message: `connector "${provider}" has no user-scoped OAuth flow`, - }); - } + const connector = await userOauthConnector(provider); const force = c.req.query("force") === "1"; const { authorizeUrl } = await oauthCredential.beginRedirectFlow({ subject: { kind: OauthSubjectKind.USER_CONNECTOR, id: user.id }, @@ -267,7 +276,9 @@ export function makeIntegrationOauthRoute(deps?: IntegrationOauthDeps): Hono { await requireAdmin(c.req.raw.headers); } - const connector = await oauthConnector(provider); + const connector = isUserFlow + ? await userOauthConnector(provider) + : await oauthConnector(provider); const done = isUserFlow ? finishUser : finish; try { await oauthCredential.completeRedirectFlow({ diff --git a/orchestrator/src/routes/me.ts b/orchestrator/src/routes/me.ts index a9a563450..d723673fe 100644 --- a/orchestrator/src/routes/me.ts +++ b/orchestrator/src/routes/me.ts @@ -30,7 +30,10 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { makeUserSecretStore, type UserSecretStore } from "../db/user-secrets.ts"; import { harnessCatalog as defaultHarnessCatalog } from "../control-plane/client.ts"; -import { oauthCredential as defaultOAuthCredential } from "../control-plane/client.ts"; +import { + oauthCredential as defaultOAuthCredential, + orgSecret as defaultOrgSecret, +} from "../control-plane/client.ts"; import { OauthSubjectKind, type RedirectMetadataProbe } from "../gen/engram/app/v1/oauth_pb.ts"; import { userTokenIdentityProbe } from "../connectors/oauth-spec.ts"; import { Code, ConnectError } from "@connectrpc/connect"; @@ -84,6 +87,13 @@ export interface MeDeps { oauth?: OAuthCredentialClient; /** ADR 0115: custom-connector source for the user-credential surface. */ connectors?: CustomConnectorSource; + /** The org secret names, to offer OAuth connect only when the app's client + * id and secret are set. */ + orgSecrets?: OrgSecretNames; +} + +export interface OrgSecretNames { + listSecrets(req: Record): Promise<{ secrets: Array<{ name: string }> }>; } export interface OAuthCredentialClient { @@ -139,6 +149,9 @@ interface UserConnectorEntry { tokenHint?: string; /** Names the account behind a pasted token, when the connector declares it. */ identity?: RedirectMetadataProbe; + /** The org secrets the personal OAuth flow needs (the app's client id and + * secret), when the connector has one. */ + oauthClientRefs?: string[]; } // --------------------------------------------------------------------------- @@ -155,6 +168,18 @@ export function makeMeRoute(deps?: MeDeps): Hono { deps?.harnessCatalog ?? (defaultHarnessCatalog as unknown as HarnessCatalogReader); const resolveOAuth = (): OAuthCredentialClient => deps?.oauth ?? defaultOAuthCredential; + const resolveOrgSecrets = (): OrgSecretNames => deps?.orgSecrets ?? defaultOrgSecret; + + /** The org secret names, or undefined when the listing fails. */ + async function orgSecretNames(): Promise | undefined> { + try { + const listed = await resolveOrgSecrets().listSecrets({}); + return new Set(listed.secrets.map((secret) => secret.name)); + } catch (error) { + console.error(`me: org-secret listing unavailable — ${(error as Error).message}`); + return undefined; + } + } const resolveSession: GetSession = deps?.getSession ?? @@ -225,12 +250,16 @@ export function makeMeRoute(deps?: MeDeps): Hono { for (const connector of registry.values()) { const uc = connector.userCredential; if (!uc) continue; + const oauthFacet = uc.oauth === undefined ? undefined : (uc.oauthFacet ?? connector.oauth); union.set(connector.provider, { name: connector.display.name, oauth: uc.oauth !== undefined, token: uc.token !== undefined, ...(uc.token ? { tokenHint: uc.token.hint } : {}), identity: userTokenIdentityProbe(connector), + ...(oauthFacet + ? { oauthClientRefs: [oauthFacet.clientIdRef, oauthFacet.clientSecretRef] } + : {}), }); } return union; @@ -293,13 +322,15 @@ export function makeMeRoute(deps?: MeDeps): Hono { // existing sealed-value flow; OAuth entries expose metadata and lifecycle. app.get("/api/v1/me/credentials", async (c) => { const user = await requireUser(c.req.raw.headers); - const [envVars, providers, connectors, oauthRows, connectorRows] = await Promise.all([ - userEnvUnion(), - oauthUnion(), - connectorUnion(), - resolveOAuth().listCredentials({ subject: oauthSubject(user.id) }), - resolveOAuth().listCredentials({ subject: connectorSubject(user.id) }), - ]); + const [envVars, providers, connectors, oauthRows, connectorRows, secretNames] = + await Promise.all([ + userEnvUnion(), + oauthUnion(), + connectorUnion(), + resolveOAuth().listCredentials({ subject: oauthSubject(user.id) }), + resolveOAuth().listCredentials({ subject: connectorSubject(user.id) }), + orgSecretNames(), + ]); const byProvider = new Map(oauthRows.credentials.map((row) => [row.provider, row])); const connectorByProvider = new Map( connectorRows.credentials.map((row) => [row.provider, row]), @@ -308,11 +339,18 @@ export function makeMeRoute(deps?: MeDeps): Hono { // against the caller's personal credential row (PAT or OAuth — one slot). const connectorEntries = [...connectors.entries()].map(([provider, entry]) => { const row = connectorByProvider.get(provider); + // OAuth connect needs the app's client id and secret in the org store; + // without them the button would only fail. A failed listing keeps the + // button, and the flow reports the real error. + const oauthReady = + entry.oauth && + (secretNames === undefined || + (entry.oauthClientRefs ?? []).every((ref) => secretNames.has(ref))); return { kind: "connector" as const, provider, display: { name: entry.name }, - modes: { oauth: entry.oauth, token: entry.token }, + modes: { oauth: oauthReady, token: entry.token }, ...(entry.tokenHint ? { tokenHint: entry.tokenHint } : {}), connected: row?.connected ?? false, status: row?.status ?? "", diff --git a/web/src/components/integrations/GithubSetupPanel.tsx b/web/src/components/integrations/GithubSetupPanel.tsx index c37f1c6cd..eb7f917b7 100644 --- a/web/src/components/integrations/GithubSetupPanel.tsx +++ b/web/src/components/integrations/GithubSetupPanel.tsx @@ -21,6 +21,7 @@ import { CopyIcon, KeyRoundIcon, ShieldCheckIcon, + UserRoundIcon, WebhookIcon, } from "lucide-react"; @@ -29,6 +30,7 @@ import { Text } from "@/components/ui/text"; import { WEBHOOK_EVENTS, buildGithubManifest, + githubOAuthCallbackUrl, githubWebhookUrl, isMinimumPermission, permissionLabel, @@ -40,14 +42,15 @@ import type { ConnectorView } from "./useConnectorViews"; const NEW_APP_URL = "https://github.com/settings/apps/new"; export function GithubSetupPanel({ view }: { view: ConnectorView }) { - const [copied, setCopied] = useState<"url" | "manifest" | null>(null); + const [copied, setCopied] = useState<"url" | "callback" | "manifest" | null>(null); const origin = API_ORIGIN || (typeof window !== "undefined" ? window.location.origin : ""); const permissions = permissionsForCapabilities(view.capabilities); const webhookUrl = githubWebhookUrl(origin); + const callbackUrl = githubOAuthCallbackUrl(origin); const manifest = buildGithubManifest({ origin, permissions, events: WEBHOOK_EVENTS }); - const copy = (what: "url" | "manifest", text: string) => { + const copy = (what: "url" | "callback" | "manifest", text: string) => { if (typeof navigator === "undefined" || !navigator.clipboard) return; void navigator.clipboard.writeText(text).then(() => { setCopied(what); @@ -200,6 +203,42 @@ export function GithubSetupPanel({ view }: { view: ConnectorView }) {

+ + {/* Step 5 — optional personal connect */} +
+ +

+ So members can connect GitHub in Settings → Credentials instead of pasting a token, set + the App's Callback URL to this URL. Then generate a + client secret, and copy the Client ID and the{" "} + client secret into the optional fields below. The + manifest already sets the callback URL. +

+
+ + + Callback URL + + + {callbackUrl} + +
+ +
+
+
); } diff --git a/web/src/lib/githubSetup.test.ts b/web/src/lib/githubSetup.test.ts index 8881a4944..be94a2273 100644 --- a/web/src/lib/githubSetup.test.ts +++ b/web/src/lib/githubSetup.test.ts @@ -93,6 +93,12 @@ describe("buildGithubManifest", () => { expect(m.hook_attributes.active).toBe(true); }); + test("registers the OAuth callback members connect through", () => { + expect(parse().callback_urls).toEqual([ + "https://engrams.example.com/api/v1/integrations/github/oauth/callback", + ]); + }); + test("carries the derived permissions as default_permissions", () => { const m = parse(); expect(m.default_permissions.metadata).toBe("read"); diff --git a/web/src/lib/githubSetup.ts b/web/src/lib/githubSetup.ts index 8f3c3a142..6edb9e84a 100644 --- a/web/src/lib/githubSetup.ts +++ b/web/src/lib/githubSetup.ts @@ -102,6 +102,15 @@ export function githubWebhookUrl(origin: string): string { return `${origin}/api/v1/integrations/github/events`; } +/** + * Where GitHub sends a member back after they authorize the App to act as + * them — the orchestrator's shared OAuth callback. It is the App's + * "Callback URL", and it must match exactly. + */ +export function githubOAuthCallbackUrl(origin: string): string { + return `${origin}/api/v1/integrations/github/oauth/callback`; +} + /** The engrams App as it appears in the target org. */ const APP_NAME = "engrams"; @@ -130,6 +139,8 @@ export function buildGithubManifest({ origin, permissions, events }: GithubManif url: origin, hook_attributes: { url: githubWebhookUrl(origin), active: true }, redirect_url: `${origin}/settings/integrations/github`, + // Members can connect their own GitHub account through the App. + callback_urls: [githubOAuthCallbackUrl(origin)], public: false, default_permissions, default_events: events, From 8707d4df5139290959723d4d3d5f9374b6f8f599 Mon Sep 17 00:00:00 2001 From: Aaron Wirick Date: Fri, 30 Oct 2026 19:58:45 +0000 Subject: [PATCH 5/7] Document GitHub personal connect and the Reviews Mine filter The GitHub guide gets the optional fifth setup step (the App's callback URL and OAuth client), the two ways a person connects their own GitHub account, and the Mine selection on the Reviews page. Co-Authored-By: Claude Opus 5.5 --- .../docs/docs/guides/connect-github.md | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/site/src/content/docs/docs/guides/connect-github.md b/site/src/content/docs/docs/guides/connect-github.md index e636000ab..110009fef 100644 --- a/site/src/content/docs/docs/guides/connect-github.md +++ b/site/src/content/docs/docs/guides/connect-github.md @@ -12,7 +12,7 @@ pull request review. ## Create the App -Settings → Integrations → GitHub walks through four steps. +Settings → Integrations → GitHub walks through four steps, and an optional fifth. 1. **Register the App.** Either open GitHub's new-app page and fill it in, or copy the manifest from the panel and use GitHub's "register from a manifest" flow. The manifest @@ -30,7 +30,13 @@ Settings → Integrations → GitHub walks through four steps. owns the repositories, generate a private key, and paste the numeric App ID and the downloaded key into the connect sheet. -The App ID and the key are stored as org secrets; the key is sealed and never returned. +5. **Let people connect their own account (optional).** Set the App's callback URL to + `/api/v1/integrations/github/oauth/callback`; the manifest already sets it. Generate + a client secret, and paste the App's client ID and the client secret into the optional + fields of the connect sheet. + +The App ID and the key are stored as org secrets; the key is sealed and never returned. The +client secret is sealed in the same way. ## What sessions get @@ -58,10 +64,18 @@ To enroll a repository, open Settings → Automations → PR review → Inputs a The handle defaults to the App's own login. The other inputs are the reviewer profile, the review categories, and free-text instructions the reviewers read. The Reviews page lists -every reviewed pull request with its findings and what was posted. +every reviewed pull request with its findings and what was posted. Select **Mine** to see +only the pull requests you opened. It matches the GitHub login of your personal GitHub +credential. ## Personal GitHub credentials -A person can add a fine-grained personal access token under Settings → Credentials. A -profile set to act as the launching person uses it instead of the App token, so commits and -pull requests carry that person's identity. +A person connects their own GitHub account under Settings → Credentials. A profile set to act +as the launching person uses it instead of the App token, so commits and pull requests carry +that person's identity. There are two ways to connect: + +- **Connect** authorizes the App to act as you. It is available when an admin completed step + 5. The token has the App's permissions, limited to what you can access, and engrams renews + it. +- **Add token** saves a fine-grained personal access token. engrams asks GitHub for the + token's login when you save it, and does not save a token that GitHub refuses. From be66a525a189a87faf7fbc4c53e7c534689966d5 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 4 Nov 2026 07:25:33 -0800 Subject: [PATCH 6/7] fix(connectors): tell a refused token from an unreachable provider The identity probe of a pasted token reported every failure as "probe_failed", and the route answered each one with 400 "Check the token". A GitHub outage or a 5xx then told the user that the token was bad. The probe now reports HTTP 401 and 403 as "probe_rejected". The route answers a refusal with 400 and tells the user to check the token. It answers any other probe failure with 503 and tells the user to try again. The token is not saved in either case. Co-Authored-By: Claude Opus 5.5 --- .../engram-coordinator/src/oauth_redirect.rs | 9 +++++- .../tests/oauth_user_credentials.rs | 29 ++++++++++++++++++- orchestrator/src/__tests__/me-oauth.test.ts | 22 ++++++++++---- orchestrator/src/routes/me.ts | 24 ++++++++------- 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/engram-coordinator/src/oauth_redirect.rs b/crates/engram-coordinator/src/oauth_redirect.rs index b050a1722..86ce747e1 100644 --- a/crates/engram-coordinator/src/oauth_redirect.rs +++ b/crates/engram-coordinator/src/oauth_redirect.rs @@ -759,8 +759,15 @@ impl OAuthManager { .map_err(|e| driver_err("probe_failed", format!("metadata probe failed: {e}")))?; let status = resp.status(); if !status.is_success() { + // 401/403 is the provider refusing the token; any other failure + // says nothing about the token, so the caller can retry. + let code = if matches!(status.as_u16(), 401 | 403) { + "probe_rejected" + } else { + "probe_failed" + }; return Err(driver_err( - "probe_failed", + code, format!("metadata probe returned HTTP {status}"), )); } diff --git a/crates/engram-coordinator/tests/oauth_user_credentials.rs b/crates/engram-coordinator/tests/oauth_user_credentials.rs index d7f36e796..55dc0390f 100644 --- a/crates/engram-coordinator/tests/oauth_user_credentials.rs +++ b/crates/engram-coordinator/tests/oauth_user_credentials.rs @@ -287,7 +287,34 @@ async fn identity_probe_rejects_a_token_the_provider_refuses() { .put_static_credential(&key, "pat-bad", Some(&identity_probe(&fx.host))) .await .expect_err("a refused token must not be saved"); - assert!(matches!(err, OAuthServiceError::Driver(_)), "{err:?}"); + assert!( + matches!(&err, OAuthServiceError::Driver(d) if d.code == "probe_rejected"), + "{err:?}" + ); + assert!(fx + .meta + .get_oauth_credential(&key) + .await + .expect("get") + .is_none()); +} + +#[tokio::test] +async fn identity_probe_failure_that_is_not_a_refusal_is_retryable() { + let fx = fixture().await; + let key = user_key("github"); + // The provider answers 404: nothing is known about the token. + let mut probe = identity_probe(&fx.host); + probe.path = "/missing".into(); + let err = fx + .manager + .put_static_credential(&key, "pat-good", Some(&probe)) + .await + .expect_err("an unconfirmed token must not be saved"); + assert!( + matches!(&err, OAuthServiceError::Driver(d) if d.code == "probe_failed"), + "{err:?}" + ); assert!(fx .meta .get_oauth_credential(&key) diff --git a/orchestrator/src/__tests__/me-oauth.test.ts b/orchestrator/src/__tests__/me-oauth.test.ts index eff9f0d42..21182fa5c 100644 --- a/orchestrator/src/__tests__/me-oauth.test.ts +++ b/orchestrator/src/__tests__/me-oauth.test.ts @@ -193,23 +193,35 @@ describe("/me connector credentials (ADR 0115)", () => { }); }); - test("PUT 400s a token the provider does not confirm", async () => { + function probeFailure(slug: string) { const { oauth } = fakeOauth(); oauth.putCredential = async () => { - const metadata = new Headers({ "engram-error-slug": "probe_failed" }); - throw new ConnectError("metadata probe returned HTTP 401", Code.Unavailable, metadata); + const metadata = new Headers({ "engram-error-slug": slug }); + throw new ConnectError("metadata probe failed", Code.Unavailable, metadata); }; - const response = await appWith(oauth).request("/api/v1/me/connector-credentials/github", { + return appWith(oauth).request("/api/v1/me/connector-credentials/github", { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ value: "ghp-bad" }), + body: JSON.stringify({ value: "ghp-1" }), }); + } + + test("PUT 400s a token the provider refuses", async () => { + const response = await probeFailure("probe_rejected"); expect(response.status).toBe(400); expect(await response.text()).toBe( "Could not confirm this token with GitHub. Check the token and try again.", ); }); + test("PUT 503s when the provider cannot confirm the token for another reason", async () => { + const response = await probeFailure("probe_failed"); + expect(response.status).toBe(503); + expect(await response.text()).toBe( + "Could not reach GitHub to confirm this token. Try again later.", + ); + }); + test("PUT 404s for a provider without token mode and 400s an empty value", async () => { const { oauth, calls } = fakeOauth(); const app = appWith(oauth); diff --git a/orchestrator/src/routes/me.ts b/orchestrator/src/routes/me.ts index a9a563450..c7d20f877 100644 --- a/orchestrator/src/routes/me.ts +++ b/orchestrator/src/routes/me.ts @@ -386,16 +386,20 @@ export function makeMeRoute(deps?: MeDeps): Hono { ...(entry.identity ? { identity: entry.identity } : {}), }); } catch (error) { - // The connector asks the provider who owns the token before the seal; - // a refusal or an unreachable provider leaves the token unconfirmed. - if ( - error instanceof ConnectError && - error.code === Code.Unavailable && - error.metadata.get("engram-error-slug") === "probe_failed" - ) { - throw new HTTPException(400, { - message: `Could not confirm this token with ${entry.name}. Check the token and try again.`, - }); + // The connector asks the provider who owns the token before the seal. + // A refusal is the token's fault; any other probe failure is not. + if (error instanceof ConnectError && error.code === Code.Unavailable) { + const slug = error.metadata.get("engram-error-slug"); + if (slug === "probe_rejected") { + throw new HTTPException(400, { + message: `Could not confirm this token with ${entry.name}. Check the token and try again.`, + }); + } + if (slug === "probe_failed") { + throw new HTTPException(503, { + message: `Could not reach ${entry.name} to confirm this token. Try again later.`, + }); + } } throw error; } From 54ffd9cd64ddb648bcfe085efd9f8f788389e8bf Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Wed, 4 Nov 2026 07:27:13 -0800 Subject: [PATCH 7/7] fix(oauth): mark a credential broken on GitHub's bad_refresh_token GitHub answers a dead refresh token with HTTP 200 and the error code "bad_refresh_token", not the RFC's "invalid_grant". The token grant reported that as "exchange_failed", which the refresh sweep treats as transient. A GitHub App user credential whose refresh token expired or was revoked then stayed "connected": the sweep retried each minute, and sessions got an expired token with no reconnect prompt. The grant now reports "bad_refresh_token" as "invalid_grant". The sweep marks the credential broken, and keeps the existing check that reloads the winner when a concurrent refresh moved the row version. Co-Authored-By: Claude Opus 5.5 --- .../engram-coordinator/src/oauth_redirect.rs | 4 ++- .../tests/oauth_redirect.rs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engram-coordinator/src/oauth_redirect.rs b/crates/engram-coordinator/src/oauth_redirect.rs index 11e7132dc..21ab32765 100644 --- a/crates/engram-coordinator/src/oauth_redirect.rs +++ b/crates/engram-coordinator/src/oauth_redirect.rs @@ -692,7 +692,9 @@ impl OAuthManager { format!("no access token under {grant_path:?}") } }); - let code = if err == "invalid_grant" { + // GitHub answers a dead refresh token with HTTP 200 and its own + // `bad_refresh_token` code, not the RFC's `invalid_grant`. + let code = if err == "invalid_grant" || err == "bad_refresh_token" { "invalid_grant" } else { "exchange_failed" diff --git a/crates/engram-coordinator/tests/oauth_redirect.rs b/crates/engram-coordinator/tests/oauth_redirect.rs index 623d75022..82f6e1f59 100644 --- a/crates/engram-coordinator/tests/oauth_redirect.rs +++ b/crates/engram-coordinator/tests/oauth_redirect.rs @@ -761,6 +761,33 @@ async fn invalid_grant_breaks_the_credential_and_reconnect_repairs_it() { assert_eq!(resolved.secret, "at-3"); } +#[tokio::test] +async fn github_bad_refresh_token_breaks_the_credential() { + let fx = fixture().await; + let sp = spec(&fx.host, false); + connected(&fx, &sp).await; + + // GitHub's shape: HTTP 200, its own error code. + *fx.provider.token_response.lock() = json!({ + "error": "bad_refresh_token", + "error_description": "The refresh token passed is incorrect or expired.", + }); + fx.clock.advance(std::time::Duration::from_secs(19 * 3600)); + let sweep = fx + .manager + .run_connector_refresh_once() + .await + .expect("sweep"); + assert_eq!(sweep.broke, 1, "{sweep:?}"); + let row = fx + .meta + .get_oauth_credential(&linear_key()) + .await + .expect("get") + .expect("row"); + assert_eq!(row.broken_reason.as_deref(), Some("invalid_grant")); +} + #[tokio::test] async fn non_refreshable_expiring_token_breaks_at_expiry_not_before() { let fx = fixture().await;