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.
- package/dist/api/routes/auth.js +37 -1
- package/dist/api/routes/me.js +3 -0
- package/dist/github/client.js +7 -1
- package/dist/sync/auth-notices.js +36 -0
- package/dist/sync/sync-repo.js +21 -2
- package/package.json +1 -1
- package/public/assets/{index-CYf1k_mA.js → index-BF0g4p9J.js} +29 -29
- package/public/assets/index-FnMZwCYx.css +10 -0
- package/public/index.html +2 -2
- package/public/assets/index-DkAnhqyo.css +0 -10
package/dist/api/routes/auth.js
CHANGED
|
@@ -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) => {
|
package/dist/api/routes/me.js
CHANGED
|
@@ -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)));
|
package/dist/github/client.js
CHANGED
|
@@ -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) =>
|
|
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
|
package/dist/sync/sync-repo.js
CHANGED
|
@@ -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) =>
|
|
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.
|
|
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",
|