pierre-review 0.1.65 → 0.1.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,25 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { config } from '../../config.js';
3
3
  import { encryptToken } from '../../auth/crypto.js';
4
- import { upsertCloudAccount } from '../../auth/account.js';
4
+ import { getAccessToken, upsertCloudAccount } from '../../auth/account.js';
5
+ // Revoke the OAuth App's grant for a user's token (Basic auth = the app's own id/secret). This
6
+ // forces the NEXT authorize to re-show consent — which is what re-injects the SAML SSO step; a
7
+ // silent re-issue would skip it and re-mint a still-de-authorized token. 404 = already gone.
8
+ async function revokeOAuthGrant(token) {
9
+ const basic = Buffer.from(`${config.githubOAuthClientId}:${config.githubOAuthClientSecret}`).toString('base64');
10
+ const res = await fetch(`https://api.github.com/applications/${config.githubOAuthClientId}/grant`, {
11
+ method: 'DELETE',
12
+ headers: {
13
+ authorization: `Basic ${basic}`,
14
+ accept: 'application/vnd.github+json',
15
+ 'x-github-api-version': '2022-11-28',
16
+ },
17
+ body: JSON.stringify({ access_token: token }),
18
+ });
19
+ if (!res.ok && res.status !== 404) {
20
+ throw new Error(`revoke grant -> ${res.status}`);
21
+ }
22
+ }
5
23
  // Per-provider credential + the scope to request (empty for the GitHub App).
6
24
  function providerCredentials(provider) {
7
25
  return provider === 'app'
@@ -60,8 +78,33 @@ export async function authRoutes(app) {
60
78
  const provider = req.params.provider === 'app' ? 'app' : 'oauth';
61
79
  return startLogin(provider, reply);
62
80
  });
63
- // Back-compat bare login (landing/billing CTAs) the default provider.
64
- app.get('/api/auth/login', async (_req, reply) => startLogin(defaultProvider, reply));
81
+ // Bare login (landing / billing CTAs). When BOTH providers are configured, don't silently
82
+ // pick one — bounce to the in-app sign-in gate so the user explicitly chooses OAuth App vs
83
+ // GitHub App. When only one is configured there's no choice to make, so go straight to it.
84
+ app.get('/api/auth/login', async (_req, reply) => {
85
+ if (config.oauthProviderEnabled && config.appProviderEnabled) {
86
+ return reply.redirect('/app');
87
+ }
88
+ return startLogin(defaultProvider, reply);
89
+ });
90
+ // "Reconnect GitHub" (the SAML-block banner action): revoke this app's grant so the re-auth
91
+ // shows FRESH consent — forcing the SAML SSO step that a silent re-issue would skip — then
92
+ // clear our session (so the callback re-runs the code exchange, not the already-signed-in
93
+ // short-circuit) and start a fresh OAuth login. Best-effort revoke: even if it fails, the
94
+ // re-login can still succeed if the user's SAML session is active.
95
+ app.get('/api/auth/reconnect', async (req, reply) => {
96
+ const accountId = req.session.get('accountId');
97
+ if (accountId != null) {
98
+ try {
99
+ await revokeOAuthGrant(await getAccessToken(accountId));
100
+ }
101
+ catch (err) {
102
+ req.log.warn({ err }, 'reconnect: grant revoke failed, continuing to re-auth');
103
+ }
104
+ req.session.delete();
105
+ }
106
+ return startLogin('oauth', reply);
107
+ });
65
108
  // Verify state → exchange code for a token (against the state's provider) → fetch the user →
66
109
  // upsert account → set session → 302 to the app.
67
110
  app.get('/api/auth/callback', async (req, reply) => {
@@ -3,6 +3,7 @@ import { accountToLocalUser } from '../../auth/account.js';
3
3
  import { accountIdOf } from '../plugins/auth.js';
4
4
  import { EMPTY_CAPABILITIES, entitledProCapabilities, } from '../../pro/contract.js';
5
5
  import { getFyiProvider } from '../../feed/fyi-provider.js';
6
+ import { getAuthNotices } from '../../sync/auth-notices.js';
6
7
  import { dismissMyTurn, getCompletedDismissals, getFeedLastSeenAt, getMyTurn, markFeedSeen, undismissMyTurn, } from '../../db/queries.js';
7
8
  const dismissSchema = {
8
9
  body: {
@@ -56,6 +57,8 @@ export async function meRoutes(app) {
56
57
  // Claude Review is now the Pro `claudeReview` capability (in `pro` below).
57
58
  deploymentMode: config.deploymentMode,
58
59
  pro: entitled,
60
+ // Orgs currently SAML-blocked for this account (empty in the normal case + in local).
61
+ authNotices: getAuthNotices(accountId),
59
62
  };
60
63
  });
61
64
  app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
@@ -1,11 +1,26 @@
1
1
  import { graphql, GraphqlResponseError } from '@octokit/graphql';
2
2
  import { getGithubToken } from './auth.js';
3
- // A parenthetical hint appended to a partial-GraphQL log, ONLY when the forbidden field is
4
- // actually a CI-checks field so a NOT_FOUND (deleted/inaccessible PR) isn't mislabelled as a
5
- // checks-access problem. Empty otherwise (the raw errors still get logged). Cloud sign-in is an
6
- // OAuth App (needs the `public_repo` scope for checks) and/or a GitHub App (needs installing on
7
- // the repo's org with "Checks" read); either way public-repo checks read once that's in place.
3
+ // True when the partial errors are GitHub's SAML-SSO wall: the token authenticates but isn't
4
+ // authorized for the repo owner's org, so the whole `repository` node is forbidden. GitHub's
5
+ // message is "Resource protected by organization SAML enforcement. You must grant your OAuth
6
+ // token access to this organization." This gates PRs/checks even on PUBLIC repos of a SAML org.
7
+ export function isSamlBlock(errors) {
8
+ return (Array.isArray(errors) &&
9
+ errors.some((e) => {
10
+ // GraphQL exposes a machine-readable flag on the error — the most reliable signal
11
+ // (docs: FORBIDDEN + extensions.saml_failure === true). Fall back to the message text.
12
+ if (e.extensions?.saml_failure === true)
13
+ return true;
14
+ return /saml enforcement|grant your oauth token access/i.test(e.message ?? '');
15
+ }));
16
+ }
17
+ // A parenthetical hint appended to a partial-GraphQL log. SAML enforcement gets the actionable
18
+ // re-auth hint; otherwise, ONLY when the forbidden field is a CI-checks field (so a NOT_FOUND —
19
+ // deleted/inaccessible PR — isn't mislabelled). Empty otherwise (the raw errors still get logged).
8
20
  export function graphqlChecksHint(errors) {
21
+ if (isSamlBlock(errors)) {
22
+ return " — the sign-in token isn't authorized for this org's SAML SSO; re-authorize it inside an active SAML session for the org (see docs/GITHUB-AUTH-SETUP.md)";
23
+ }
9
24
  const mentionsChecks = Array.isArray(errors) &&
10
25
  errors.some((e) => {
11
26
  const path = e.path;
@@ -0,0 +1,36 @@
1
+ // Process-local record of orgs whose sync is currently BLOCKED by SAML SSO for an account — the
2
+ // sign-in token authenticates but isn't SSO-authorized for that org (see docs/GITHUB-AUTH-SETUP.md
3
+ // "the SAML wall"). Populated by the sync (recordSamlBlock when a repo's fetch is SAML-forbidden,
4
+ // cleared when any repo of that org next syncs cleanly), read by /api/me to drive the global
5
+ // "Reconnect GitHub" banner.
6
+ //
7
+ // Deliberately IN-MEMORY, not persisted: it's a rare, self-healing state. After a restart it
8
+ // simply repopulates on the next sync tick, and it clears itself once the user re-authorizes and
9
+ // the org reads cleanly again — so a DB column + dual-dialect migration would be overkill. (Cloud
10
+ // runs a single Fastify process, so the sync that writes this and the /api/me that reads it share
11
+ // the same map.) Local mode never watches SAML orgs / never populates this.
12
+ const blockedOrgsByAccount = new Map();
13
+ // Mark `org` (a repo owner login) as SAML-blocked for this account.
14
+ export function recordSamlBlock(accountId, org) {
15
+ let set = blockedOrgsByAccount.get(accountId);
16
+ if (!set) {
17
+ set = new Set();
18
+ blockedOrgsByAccount.set(accountId, set);
19
+ }
20
+ set.add(org);
21
+ }
22
+ // Clear `org` for this account — called when any of its repos syncs cleanly (a SAML block is
23
+ // org-wide, so one clean read proves the token is authorized for the whole org again).
24
+ export function clearSamlBlock(accountId, org) {
25
+ const set = blockedOrgsByAccount.get(accountId);
26
+ if (!set)
27
+ return;
28
+ if (set.delete(org) && set.size === 0)
29
+ blockedOrgsByAccount.delete(accountId);
30
+ }
31
+ // The account's current auth notices (one per blocked org), for /api/me.
32
+ export function getAuthNotices(accountId) {
33
+ const set = blockedOrgsByAccount.get(accountId);
34
+ return set ? [...set].map((org) => ({ kind: 'saml_sso', org })) : [];
35
+ }
36
+ //# sourceMappingURL=auth-notices.js.map
@@ -17,7 +17,7 @@ import { eq } from 'drizzle-orm';
17
17
  import { db, schema } from '../db/client.js';
18
18
  import { config } from '../config.js';
19
19
  import { getAccessToken } from '../auth/account.js';
20
- import { getGraphqlClientFor, graphqlChecksHint, graphqlTolerant, summarizeGraphqlErrors, } from '../github/client.js';
20
+ import { getGraphqlClientFor, graphqlChecksHint, graphqlTolerant, isSamlBlock, summarizeGraphqlErrors, } from '../github/client.js';
21
21
  import { PR_DETAIL_QUERY } from '../github/queries.js';
22
22
  import { checkRunsFrom } from './upsert.js';
23
23
  const { reviews, reviewComments, prComments, reviewThreads, pullRequests, repos } = schema;
@@ -32,26 +32,34 @@ function splitRepoFullName(fullName) {
32
32
  return { owner: fullName, name: '' };
33
33
  return { owner: fullName.slice(0, slash), name: fullName.slice(slash + 1) };
34
34
  }
35
- // Fetch the single PR's text from GitHub. Returns null on any failure (deleted PR,
36
- // lost access, network) so callers degrade gracefully to the stored metadata.
35
+ // Fetch the single PR's text from GitHub. `data` is null on any failure (deleted PR, lost
36
+ // access, network) so callers degrade gracefully to the stored metadata. `samlBlocked` is true
37
+ // when the failure was GitHub's SAML-SSO wall — the token isn't authorized for the repo owner's
38
+ // org — which the callers surface to the SPA (it's an authorization gap, not a bug).
37
39
  async function fetchGhPrText(owner, name, number, accountId) {
38
40
  let resp;
41
+ let samlBlocked = false;
39
42
  try {
40
43
  const token = await getAccessToken(accountId);
41
44
  const client = getGraphqlClientFor(token);
42
45
  // Tolerate partial errors: if the token is FORBIDDEN one sub-field (e.g. `statusCheckRollup`
43
46
  // check runs on a private repo it can't reach), that used to throw away the ENTIRE hydration
44
47
  // (PR body, comments, diff hunks, commit messages) even though all of those came back fine.
45
- // Now we keep them; only the forbidden field (CI checks) stays empty (and we log why).
46
- resp = await graphqlTolerant(client, PR_DETAIL_QUERY, { owner, name, number }, (errors) => console.warn(`[hydrate] partial GraphQL for ${owner}/${name}#${number} continuing with available fields${graphqlChecksHint(errors)}. ${summarizeGraphqlErrors(errors)}`));
48
+ // Now we keep them; only the forbidden field (CI checks) stays empty (and we log why). A SAML
49
+ // block forbids the whole `repository` node, so `data` ends up null but we flag it.
50
+ resp = await graphqlTolerant(client, PR_DETAIL_QUERY, { owner, name, number }, (errors) => {
51
+ if (isSamlBlock(errors))
52
+ samlBlocked = true;
53
+ console.warn(`[hydrate] partial GraphQL for ${owner}/${name}#${number} — continuing with available fields${graphqlChecksHint(errors)}. ${summarizeGraphqlErrors(errors)}`);
54
+ });
47
55
  }
48
56
  catch (err) {
49
57
  console.error(`[hydrate] PR detail fetch failed for ${owner}/${name}#${number}; returning stored metadata. ${err instanceof Error ? err.message : String(err)}`);
50
- return null;
58
+ return { data: null, samlBlocked: false };
51
59
  }
52
60
  const pr = resp.repository?.pullRequest;
53
61
  if (!pr)
54
- return null;
62
+ return { data: null, samlBlocked };
55
63
  const reviewBodyByNode = new Map();
56
64
  for (const r of pr.reviews.nodes)
57
65
  reviewBodyByNode.set(r.id, r.body);
@@ -68,20 +76,23 @@ async function fetchGhPrText(owner, name, number, accountId) {
68
76
  for (const c of pr.commits.nodes)
69
77
  commitMessageBySha.set(c.commit.oid, c.commit.message);
70
78
  return {
71
- prBody: pr.body,
72
- checkRuns: checkRunsFrom(pr.headCommit?.nodes[0]?.commit),
73
- reviewBodyByNode,
74
- reviewCommentByNode,
75
- prCommentBodyByNode,
76
- commitMessageBySha,
77
- additions: pr.additions ?? 0,
78
- deletions: pr.deletions ?? 0,
79
- changedFiles: pr.changedFiles ?? 0,
80
- files: (pr.files?.nodes ?? []).map((f) => ({
81
- path: f.path,
82
- additions: f.additions ?? 0,
83
- deletions: f.deletions ?? 0,
84
- })),
79
+ data: {
80
+ prBody: pr.body,
81
+ checkRuns: checkRunsFrom(pr.headCommit?.nodes[0]?.commit),
82
+ reviewBodyByNode,
83
+ reviewCommentByNode,
84
+ prCommentBodyByNode,
85
+ commitMessageBySha,
86
+ additions: pr.additions ?? 0,
87
+ deletions: pr.deletions ?? 0,
88
+ changedFiles: pr.changedFiles ?? 0,
89
+ files: (pr.files?.nodes ?? []).map((f) => ({
90
+ path: f.path,
91
+ additions: f.additions ?? 0,
92
+ deletions: f.deletions ?? 0,
93
+ })),
94
+ },
95
+ samlBlocked,
85
96
  };
86
97
  }
87
98
  // localId -> githubNodeId for a set of rows of the given table on this PR.
@@ -102,9 +113,14 @@ export async function hydratePrDetail(detail, accountId) {
102
113
  if (config.persistBodies)
103
114
  return detail;
104
115
  const { owner, name } = splitRepoFullName(detail.repoFullName);
105
- const gh = await fetchGhPrText(owner, name, detail.number, accountId);
116
+ const { data: gh, samlBlocked } = await fetchGhPrText(owner, name, detail.number, accountId);
117
+ const authNotice = samlBlocked
118
+ ? { kind: 'saml_sso', org: owner }
119
+ : null;
120
+ // Blocked or otherwise unfetchable → keep the stored metadata, but tell the SPA WHY the
121
+ // description/checks are blank when it was an org authorization wall (so it can guide the fix).
106
122
  if (!gh)
107
- return detail;
123
+ return authNotice ? { ...detail, authNotice } : detail;
108
124
  const [reviewNodes, reviewCommentNodes, prCommentNodes] = await Promise.all([
109
125
  nodeIdMap(reviews, detail.id),
110
126
  nodeIdMap(reviewComments, detail.id),
@@ -154,6 +170,7 @@ export async function hydratePrDetail(detail, accountId) {
154
170
  deletions: gh.deletions,
155
171
  changedFilesCount: gh.changedFiles,
156
172
  files: filesOut,
173
+ authNotice,
157
174
  };
158
175
  }
159
176
  /**
@@ -179,7 +196,7 @@ export async function hydrateThreadDetail(thread, accountId) {
179
196
  .execute())[0];
180
197
  if (!ctx)
181
198
  return thread;
182
- const gh = await fetchGhPrText(ctx.owner, ctx.name, ctx.number, accountId);
199
+ const { data: gh } = await fetchGhPrText(ctx.owner, ctx.name, ctx.number, accountId);
183
200
  if (!gh)
184
201
  return thread;
185
202
  const commentNodes = await nodeIdMap(reviewComments, thread.prId);
@@ -1,7 +1,8 @@
1
1
  import { performance } from 'node:perf_hooks';
2
2
  import { eq } from 'drizzle-orm';
3
3
  import { db, schema } from '../db/client.js';
4
- import { getGraphqlClientFor, graphqlChecksHint, graphqlTolerant, summarizeGraphqlErrors, } from '../github/client.js';
4
+ import { getGraphqlClientFor, graphqlChecksHint, graphqlTolerant, isSamlBlock, summarizeGraphqlErrors, } from '../github/client.js';
5
+ import { clearSamlBlock, recordSamlBlock } from './auth-notices.js';
5
6
  import { REPO_ACTIVITY_QUERY } from '../github/queries.js';
6
7
  import { ensureCommitFiles } from './commit-files.js';
7
8
  import { createUserResolver, persistPr, upsertRepo } from './upsert.js';
@@ -20,6 +21,9 @@ export async function syncRepo(opts) {
20
21
  const log = opts.log ?? consoleLogger;
21
22
  const client = getGraphqlClientFor(opts.token);
22
23
  const resolver = createUserResolver();
24
+ // Set if any page's fetch is SAML-forbidden (token not authorized for the owner's org) — the
25
+ // owner org is then flagged for the "Reconnect GitHub" banner (see sync/auth-notices.ts).
26
+ let samlBlocked = false;
23
27
  let cursor = opts.startCursor ?? null;
24
28
  // The `after` value used to fetch the page currently being processed. When we
25
29
  // stop at the `since` cutoff mid-page, this (not the page's endCursor) is what a
@@ -66,7 +70,11 @@ export async function syncRepo(opts) {
66
70
  // a private repo the token can't reach, or a token minted before its scope covered checks)
67
71
  // doesn't abort the whole sync — the PRs, reviews, comments and review REQUESTS still
68
72
  // persist; only CI check detail is dropped (the `ciStatus` rollup, when readable, is kept).
69
- const resp = await graphqlTolerant(client, REPO_ACTIVITY_QUERY, { owner, name, cursor }, (errors) => log.warn(`sync ${owner}/${name}: partial GraphQL — continuing without forbidden fields${graphqlChecksHint(errors)}. ${summarizeGraphqlErrors(errors)}`));
73
+ const resp = await graphqlTolerant(client, REPO_ACTIVITY_QUERY, { owner, name, cursor }, (errors) => {
74
+ if (isSamlBlock(errors))
75
+ samlBlocked = true;
76
+ log.warn(`sync ${owner}/${name}: partial GraphQL — continuing without forbidden fields${graphqlChecksHint(errors)}. ${summarizeGraphqlErrors(errors)}`);
77
+ });
70
78
  graphqlMs += performance.now() - tPage;
71
79
  pages += 1;
72
80
  // `rateLimit` is a top-level sibling of `repository`, so it survives a partial
@@ -76,10 +84,21 @@ export async function syncRepo(opts) {
76
84
  totalCost += resp.rateLimit?.cost ?? 0;
77
85
  lastRemaining = resp.rateLimit?.remaining ?? lastRemaining;
78
86
  if (!resp.repository) {
87
+ // A SAML wall forbids the whole `repository` node → flag the owner's org for the
88
+ // "Reconnect GitHub" banner (an authorization gap the user can self-fix), then fail
89
+ // this repo's sync as usual.
90
+ if (samlBlocked)
91
+ recordSamlBlock(accountId, owner);
79
92
  const err = new Error(`Repository ${owner}/${name} not found or inaccessible`);
80
93
  err.statusCode = 404;
81
94
  throw err;
82
95
  }
96
+ // Repository read cleanly (no SAML error anywhere in this response) → the token IS
97
+ // authorized for this owner's org; clear any prior flag so the "Reconnect" banner
98
+ // self-dismisses on recovery. Guard on !samlBlocked so a partial SAML error can't
99
+ // erroneously self-dismiss. Idempotent per page.
100
+ if (!samlBlocked)
101
+ clearSamlBlock(accountId, owner);
83
102
  if (repoId == null) {
84
103
  repoId = await upsertRepo(owner, name, resp.repository.id, resp.repository.defaultBranchRef?.name ?? null, accountId, resp.repository.viewerPermission ?? null);
85
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "Dashboard for tracking your team's GitHub PR activity across repos — local (SQLite + gh) or self-hosted multi-tenant cloud (Postgres + GitHub App).",
5
5
  "type": "module",
6
6
  "author": "Alex Wakeman",