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.
- package/dist/api/routes/auth.js +46 -3
- package/dist/api/routes/me.js +3 -0
- package/dist/github/client.js +20 -5
- package/dist/sync/auth-notices.js +36 -0
- package/dist/sync/hydrate-detail.js +41 -24
- package/dist/sync/sync-repo.js +21 -2
- package/package.json +1 -1
- package/public/assets/{index-Ba_ktPzW.js → index-BF0g4p9J.js} +30 -30
- package/public/assets/index-FnMZwCYx.css +10 -0
- package/public/index.html +2 -2
- package/public/assets/index-OVCYgUTe.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'
|
|
@@ -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
|
-
//
|
|
64
|
-
app
|
|
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) => {
|
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
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { graphql, GraphqlResponseError } from '@octokit/graphql';
|
|
2
2
|
import { getGithubToken } from './auth.js';
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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.
|
|
36
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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);
|
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",
|