pierre-review 0.1.66 → 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'
@@ -69,6 +87,24 @@ export async function authRoutes(app) {
69
87
  }
70
88
  return startLogin(defaultProvider, reply);
71
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
+ });
72
108
  // Verify state → exchange code for a token (against the state's provider) → fetch the user →
73
109
  // upsert account → set session → 302 to the app.
74
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)));
@@ -6,7 +6,13 @@ import { getGithubToken } from './auth.js';
6
6
  // token access to this organization." This gates PRs/checks even on PUBLIC repos of a SAML org.
7
7
  export function isSamlBlock(errors) {
8
8
  return (Array.isArray(errors) &&
9
- errors.some((e) => /saml enforcement|grant your oauth token access/i.test(e.message ?? '')));
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
+ }));
10
16
  }
11
17
  // A parenthetical hint appended to a partial-GraphQL log. SAML enforcement gets the actionable
12
18
  // re-auth hint; otherwise, ONLY when the forbidden field is a CI-checks field (so a NOT_FOUND —
@@ -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
@@ -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.66",
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",