@volter/twin-github 0.1.2 → 2.0.0
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/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +230 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +101 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +159 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +223 -95
- package/src/github-git-http.ts +47 -83
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +47 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +186 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// GITHUB PACKAGES' NPM REGISTRY — npm.pkg.github.com, where `npm publish` puts a package scoped to its owner
|
|
2
|
+
// (docs.github.com/packages/working-with-a-github-packages-registry/working-with-the-npm-registry). A publish is a PUT
|
|
3
|
+
// of the package document (its name, its versions and their tarballs as attachments) at /@owner/name; a first publish
|
|
4
|
+
// makes the package, a later one adds its version, and republishing a version is refused. The REST API reads and
|
|
5
|
+
// deletes what was published (semantics/packages.ts).
|
|
6
|
+
//
|
|
7
|
+
// Where the documentation stops and the twin decides: a published package is private, as GitHub makes a user's new
|
|
8
|
+
// package; the tarball's bytes are not kept.
|
|
9
|
+
import { applyTwinWrite } from '@volter/world-core';
|
|
10
|
+
import { GITHUB_WORLD_USER, githubState, nextSeq } from "./github-twin.js";
|
|
11
|
+
const SERVICE = 'github';
|
|
12
|
+
/** npm.pkg.github.com's publish, or undefined for any other request. */
|
|
13
|
+
export function githubNpmRegistry(flow) {
|
|
14
|
+
return async (request) => {
|
|
15
|
+
const m = /^\/@([^/]+)(?:\/|%2[fF])([^/]+)$/.exec(new URL(request.url).pathname);
|
|
16
|
+
return m && request.method === 'PUT' ? publish(request, decodeURIComponent(m[1]), decodeURIComponent(m[2]), flow) : undefined;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** `npm publish` of a package's versions, as npm.pkg.github.com answers it. */
|
|
20
|
+
async function publish(request, owner, name, flow) {
|
|
21
|
+
if (owner !== GITHUB_WORLD_USER)
|
|
22
|
+
return Response.json({ error: `Permission denied: you cannot publish to @${owner}` }, { status: 403 });
|
|
23
|
+
let doc;
|
|
24
|
+
try {
|
|
25
|
+
doc = (await request.json());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return Response.json({ error: 'Invalid package document' }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
const versions = Object.keys(doc.versions ?? {});
|
|
31
|
+
if (!versions.length)
|
|
32
|
+
return Response.json({ error: 'No version to publish' }, { status: 400 });
|
|
33
|
+
const at = flow.now();
|
|
34
|
+
const st = githubState(flow.root);
|
|
35
|
+
const write = (w) => applyTwinWrite(SERVICE, { ...w, occurredAt: at, actor: { kind: 'human', id: owner } }, flow.root);
|
|
36
|
+
const published = st.packageVersions.filter((v) => v.owner === owner && v.package_type === 'npm' && v.package_name === name).map((v) => v.name);
|
|
37
|
+
const again = versions.find((v) => published.includes(v));
|
|
38
|
+
if (again)
|
|
39
|
+
return Response.json({ error: `You cannot publish over the previously published version ${again}.` }, { status: 409 });
|
|
40
|
+
if (!st.packages.some((p) => p.owner === owner && p.package_type === 'npm' && p.name === name)) {
|
|
41
|
+
await write({ operation: 'package.create', subjectType: 'package', subjectId: `package:${nextSeq('package', flow.root)}`, fields: { name, package_type: 'npm', owner, visibility: 'private', created_at: at, updated_at: at } });
|
|
42
|
+
}
|
|
43
|
+
for (const version of versions) {
|
|
44
|
+
await write({ operation: 'package_version.create', subjectType: 'package_version', subjectId: `packageversion:${nextSeq('packageversion', flow.root)}`, fields: { package_name: name, package_type: 'npm', owner, name: version, created_at: at, updated_at: at } });
|
|
45
|
+
}
|
|
46
|
+
return Response.json({ ok: true }, { status: 201 });
|
|
47
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A GITHUB APP'S INSTALLATION — a hosted flow (docs/contributing/architecture.md, "Screens"): the page at
|
|
3
|
+
// github.com/apps/{slug}/installations/new, held to the round trip GitHub documents
|
|
4
|
+
// (docs.github.com/apps/using-github-apps/installing-a-github-app-from-a-third-party and
|
|
5
|
+
// docs.github.com/apps/creating-github-apps/about-creating-github-apps/about-the-setup-url): an application sends
|
|
6
|
+
// the person who will install it to the page (with an optional `state`); the person picks the account, all of its
|
|
7
|
+
// repositories or only some, sees the permissions and events the app asks for, and installs. GitHub then records
|
|
8
|
+
// the installation, sends the app its `installation` webhook (action `created`), and redirects to the app's setup
|
|
9
|
+
// URL with `installation_id` and `setup_action=install` (and the `state` it was given). An app that asks to
|
|
10
|
+
// authorize its installer (request_oauth_on_install) gets a `code` on that redirect as well, redeemed at
|
|
11
|
+
// /login/oauth/access_token like a consent's (src/screens/oauth.tsx), and GitHub sends the person to its callback
|
|
12
|
+
// URL rather than its setup URL. With no setup URL the person lands on the app's page. The page is authored
|
|
13
|
+
// from @volter/world-ui's pieces under GitHub's skin (oauth.tsx's); nothing of GitHub's page is copied.
|
|
14
|
+
//
|
|
15
|
+
// Where the documentation stops and the twin decides: apps are registered through the twin's door
|
|
16
|
+
// (`POST /_twin/apps`; GitHub's settings UI has no API). The person is who signed in (src/screens/session.tsx); a
|
|
17
|
+
// visitor not signed in is sent to the sign-in first. The accounts offered are the person's own and the organizations they
|
|
18
|
+
// own (an admin membership: an organization's creator is its first admin). The repository picker lists the chosen account's repositories; choosing
|
|
19
|
+
// another account reloads the page for it (a plain form, no script). An account that already has the app is
|
|
20
|
+
// told so and not installed again (GitHub sends that person to the installation's settings page instead).
|
|
21
|
+
import { applyTwinWriteAtomic } from '@volter/world-core';
|
|
22
|
+
import { CONSENT_CSS, flowPage } from '@volter/world-ui';
|
|
23
|
+
import { githubApps, githubState, toGithubAppInstallation } from "../github-twin.js";
|
|
24
|
+
import { emitGithubAppEvent } from "../github-events.js";
|
|
25
|
+
import { errorPage, mintOAuthCode, redirect, SKIN } from "./oauth.js";
|
|
26
|
+
import { signedIn, toSignIn } from "./session.js";
|
|
27
|
+
const SERVICE = 'github';
|
|
28
|
+
const PAGE = /^\/apps\/([^/]+)\/installations\/new$/;
|
|
29
|
+
// The page's own layout over the consent's (a choice of account, a radio pair and a repository list).
|
|
30
|
+
const INSTALL_CSS = `
|
|
31
|
+
.install-section { padding: 12px 0; border-top: 1px solid #d1d9e0; }
|
|
32
|
+
.install-section:first-child { border-top: 0; }
|
|
33
|
+
.install-section h2 { font-size: 14px; margin: 0 0 8px; }
|
|
34
|
+
.install-choice { display: block; padding: 4px 0; }
|
|
35
|
+
.install-choice small { display: block; margin-left: 22px; color: #59636e; }
|
|
36
|
+
.install-repos { list-style: none; margin: 4px 0 0 22px; padding: 0; }
|
|
37
|
+
.install-account { display: flex; gap: 8px; align-items: center; }
|
|
38
|
+
.install-account select { flex: 1; font: inherit; padding: 4px; }
|
|
39
|
+
.install-account button { font: inherit; padding: 4px 12px; border-radius: 6px; border: 1px solid #d1d9e0; background: #f6f8fa; }
|
|
40
|
+
`;
|
|
41
|
+
// GitHub's names for an app's permissions, as its install page lists them (read / read and write / admin).
|
|
42
|
+
const ACCESS = { read: 'Read-only', write: 'Read and write', admin: 'Admin' };
|
|
43
|
+
const TITLES = {
|
|
44
|
+
contents: 'Contents', metadata: 'Metadata', issues: 'Issues', pull_requests: 'Pull requests', checks: 'Checks', statuses: 'Commit statuses',
|
|
45
|
+
deployments: 'Deployments', actions: 'Actions', workflows: 'Workflows', administration: 'Administration', members: 'Members', secrets: 'Secrets',
|
|
46
|
+
environments: 'Environments', pages: 'Pages', packages: 'Packages', repository_hooks: 'Webhooks', security_events: 'Code scanning alerts',
|
|
47
|
+
};
|
|
48
|
+
function permissionsOf(app) {
|
|
49
|
+
const out = Object.entries({ metadata: 'read', ...app.permissions }).map(([k, v]) => ({
|
|
50
|
+
title: TITLES[k] ?? k.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()), detail: `${ACCESS[v] ?? v} access`,
|
|
51
|
+
}));
|
|
52
|
+
if (app.events.length)
|
|
53
|
+
out.push({ title: 'Events', detail: app.events.join(', '), help: 'This app will be sent these webhook events.' });
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
async function paramsOf(request) {
|
|
57
|
+
const url = new URL(request.url);
|
|
58
|
+
const fields = Object.fromEntries(url.searchParams);
|
|
59
|
+
const repositories = url.searchParams.getAll('repositories');
|
|
60
|
+
if (request.method !== 'POST')
|
|
61
|
+
return { fields, repositories };
|
|
62
|
+
const text = await request.text();
|
|
63
|
+
if (!text)
|
|
64
|
+
return { fields, repositories };
|
|
65
|
+
if ((request.headers.get('content-type') ?? '').includes('json')) {
|
|
66
|
+
try {
|
|
67
|
+
for (const [k, v] of Object.entries(JSON.parse(text))) {
|
|
68
|
+
if (k === 'repositories' && Array.isArray(v))
|
|
69
|
+
repositories.push(...v.map(String));
|
|
70
|
+
else if (v !== undefined && v !== null)
|
|
71
|
+
fields[k] = String(v);
|
|
72
|
+
}
|
|
73
|
+
return { fields, repositories };
|
|
74
|
+
}
|
|
75
|
+
catch { /* read as form fields, as GitHub reads an unparseable body */ }
|
|
76
|
+
}
|
|
77
|
+
const form = new URLSearchParams(text);
|
|
78
|
+
for (const [k, v] of form)
|
|
79
|
+
if (k !== 'repositories')
|
|
80
|
+
fields[k] = v;
|
|
81
|
+
repositories.push(...form.getAll('repositories'));
|
|
82
|
+
return { fields, repositories };
|
|
83
|
+
}
|
|
84
|
+
/** The accounts the person may install on: their own, then the organizations they own. */
|
|
85
|
+
function accountsOf(login, root) {
|
|
86
|
+
const st = githubState(root);
|
|
87
|
+
const owned = st.orgs.filter((o) => st.orgMembers.some((m) => m.org === o.login && m.login === login && m.role === 'admin'));
|
|
88
|
+
return [{ login, type: 'User' }, ...owned.map((o) => ({ login: o.login, type: 'Organization' }))];
|
|
89
|
+
}
|
|
90
|
+
const reposOf = (account, root) => githubState(root).repos.filter((r) => r.owner === account);
|
|
91
|
+
const appOf = (slug, root) => githubApps(root).find((a) => a.slug === slug);
|
|
92
|
+
const installed = (app, account, root) => githubState(root).appInstallations.some((i) => i.app_slug === app.slug && i.account_login === account);
|
|
93
|
+
function page(app, p, flow, notice) {
|
|
94
|
+
const login = p.login;
|
|
95
|
+
const accounts = accountsOf(login, flow.root);
|
|
96
|
+
const account = accounts.find((a) => a.login === p.target_login) ?? accounts[0];
|
|
97
|
+
const repos = reposOf(account.login, flow.root);
|
|
98
|
+
const already = installed(app, account.login, flow.root);
|
|
99
|
+
const keep = { ...(p.state !== undefined ? { state: p.state } : {}) };
|
|
100
|
+
const verb = app.request_oauth_on_install ? 'Install & Authorize' : 'Install';
|
|
101
|
+
return flowPage({
|
|
102
|
+
title: `Install ${app.name}`,
|
|
103
|
+
status: notice?.status ?? 200,
|
|
104
|
+
css: [CONSENT_CSS, SKIN, INSTALL_CSS],
|
|
105
|
+
body: (_jsxs("main", { className: "consent", children: [_jsxs("div", { className: "consent-badges", "aria-hidden": "true", children: [_jsx("span", { className: "consent-badge", children: app.name.slice(0, 1).toUpperCase() }), _jsx("span", { className: "consent-link" }), _jsx("span", { className: "consent-badge consent-vendor" })] }), _jsx("h1", { className: "consent-heading", children: `Install ${app.name}` }), _jsxs("div", { className: "consent-box", children: [_jsxs("form", { method: "get", action: `/apps/${app.slug}/installations/new`, className: "install-section install-account", children: [Object.entries(keep).map(([name, value]) => _jsx("input", { type: "hidden", name: name, value: value }, name)), _jsx("label", { htmlFor: "target_login", children: "Install on" }), _jsx("select", { id: "target_login", name: "target_login", defaultValue: account.login, children: accounts.map((a) => _jsx("option", { value: a.login, children: a.login }, a.login)) }), _jsx("button", { type: "submit", children: "Switch account" })] }), notice ? _jsx("p", { className: "consent-note", role: "alert", children: notice.message }) : null, already ? (_jsx("p", { className: "install-section", children: `${app.name} is already installed on ${account.login}.` })) : (_jsxs("form", { method: "post", action: `/apps/${app.slug}/installations/new`, className: "consent-form", children: [Object.entries({ ...keep, target_login: account.login }).map(([name, value]) => _jsx("input", { type: "hidden", name: name, value: value }, name)), _jsxs("fieldset", { className: "install-section", children: [_jsx("legend", { children: _jsx("h2", { children: `for these repositories of ${account.login}:` }) }), _jsxs("label", { className: "install-choice", children: [_jsx("input", { type: "radio", name: "repository_selection", value: "all", defaultChecked: true }), " All repositories", _jsx("small", { children: "This applies to all current and future repositories owned by the resource owner." })] }), _jsxs("label", { className: "install-choice", children: [_jsx("input", { type: "radio", name: "repository_selection", value: "selected" }), " Only select repositories", _jsx("small", { children: "Select at least one repository." })] }), _jsx("ul", { className: "install-repos", "aria-label": "Select repositories", children: repos.map((r) => (_jsx("li", { children: _jsxs("label", { children: [_jsx("input", { type: "checkbox", name: "repositories", value: r.name }), " ", r.full_name] }) }, r.full_name))) })] }), _jsxs("div", { className: "install-section", children: [_jsx("h2", { children: "with these permissions:" }), _jsx("ul", { className: "consent-permissions", children: permissionsOf(app).map((perm) => (_jsxs("li", { className: "consent-permission", children: [_jsx("strong", { className: "consent-permission-title", children: perm.title }), " ", _jsx("small", { children: perm.detail })] }, perm.title))) })] }), _jsxs("div", { className: "consent-actions", children: [_jsx("button", { type: "submit", name: "install", value: "0", className: "consent-deny", children: "Cancel" }), _jsx("button", { type: "submit", name: "install", value: "1", className: "consent-allow", children: verb })] })] }))] })] })),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** The person's answer: an installation and the redirect GitHub sends, or back to the app's page. */
|
|
109
|
+
async function install(app, params, flow) {
|
|
110
|
+
const p = params.fields;
|
|
111
|
+
const home = `https://github.com/apps/${app.slug}`;
|
|
112
|
+
if (p.install !== '1')
|
|
113
|
+
return redirect(home, {});
|
|
114
|
+
const login = p.login;
|
|
115
|
+
const account = accountsOf(login, flow.root).find((a) => a.login === (p.target_login ?? login));
|
|
116
|
+
if (!account)
|
|
117
|
+
return errorPage(404, 'Not Found');
|
|
118
|
+
if (installed(app, account.login, flow.root))
|
|
119
|
+
return page(app, p, flow, { status: 422, message: `${app.name} is already installed on ${account.login}.` });
|
|
120
|
+
const selection = p.repository_selection === 'selected' ? 'selected' : 'all';
|
|
121
|
+
const owned = reposOf(account.login, flow.root);
|
|
122
|
+
const chosen = selection === 'selected' ? owned.filter((r) => params.repositories.includes(r.name) || params.repositories.includes(r.full_name)) : [];
|
|
123
|
+
if (selection === 'selected' && !chosen.length)
|
|
124
|
+
return page(app, p, flow, { status: 422, message: 'Select at least one repository.' });
|
|
125
|
+
const at = flow.now();
|
|
126
|
+
const fields = {
|
|
127
|
+
account_login: account.login, account_type: account.type, target_type: account.type, app_id: app.id, app_slug: app.slug,
|
|
128
|
+
repository_selection: selection, repositories: chosen.map((r) => r.full_name), permissions: { metadata: 'read', ...app.permissions }, events: app.events,
|
|
129
|
+
created_at: at, updated_at: at,
|
|
130
|
+
};
|
|
131
|
+
const { value: id } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
132
|
+
const n = resources.filter((r) => r.type === 'app_installation').reduce((m, r) => Math.max(m, Number(/^installation:(\d+)$/.exec(r.id)?.[1] ?? 0)), 0) + 1;
|
|
133
|
+
return { kind: 'write', value: n, write: { operation: 'app_installation.create', subjectType: 'app_installation', subjectId: `installation:${n}`, fields, occurredAt: at, actor: { kind: 'human', id: login } } };
|
|
134
|
+
}, flow.root);
|
|
135
|
+
const made = githubState(flow.root).appInstallations.find((i) => i.id === id);
|
|
136
|
+
try {
|
|
137
|
+
await emitGithubAppEvent(app, 'installation', {
|
|
138
|
+
action: 'created', installation: toGithubAppInstallation(made),
|
|
139
|
+
repositories: (selection === 'all' ? owned : chosen).map((r) => ({ id: r.id, name: r.name, full_name: r.full_name, private: r.private ?? false })),
|
|
140
|
+
sender: { login, type: 'User' },
|
|
141
|
+
}, { occurredAt: at, ...(flow.root !== undefined ? { root: flow.root } : {}) });
|
|
142
|
+
}
|
|
143
|
+
catch { /* a delivery failure never fails the installation, as on GitHub */ }
|
|
144
|
+
const oauth = app.request_oauth_on_install;
|
|
145
|
+
const target = (oauth ? app.callback_url ?? app.setup_url : app.setup_url) ?? home;
|
|
146
|
+
const code = oauth && target !== home ? await mintOAuthCode({ client_id: app.client_id, login, scope: '', redirect_uri: target }, flow) : undefined;
|
|
147
|
+
return target === home ? redirect(home, {}) : redirect(target, { code, installation_id: String(id), setup_action: 'install', state: p.state });
|
|
148
|
+
}
|
|
149
|
+
/** github.com/apps/{slug}/installations/new, or undefined for any other request. */
|
|
150
|
+
export function githubAppInstallationFlow(flow) {
|
|
151
|
+
return async (request) => {
|
|
152
|
+
const match = PAGE.exec(new URL(request.url).pathname.replace(/\/+$/, ''));
|
|
153
|
+
if (!match || (request.method !== 'GET' && request.method !== 'POST'))
|
|
154
|
+
return undefined;
|
|
155
|
+
const app = appOf(decodeURIComponent(match[1]), flow.root);
|
|
156
|
+
if (!app)
|
|
157
|
+
return errorPage(404, 'Not Found');
|
|
158
|
+
// the person is the session's, whatever the request names
|
|
159
|
+
const person = signedIn(request, flow.root);
|
|
160
|
+
if (!person)
|
|
161
|
+
return toSignIn(request);
|
|
162
|
+
const params = await paramsOf(request);
|
|
163
|
+
params.fields.login = person;
|
|
164
|
+
return request.method === 'GET' ? page(app, params.fields, flow) : install(app, params, flow);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A GITHUB APP FROM A MANIFEST — a hosted flow (docs/contributing/architecture.md, "Screens"): the page at
|
|
3
|
+
// github.com/settings/apps/new and github.com/organizations/{org}/settings/apps/new, held to the round trip GitHub
|
|
4
|
+
// documents (docs.github.com/apps/sharing-github-apps/registering-a-github-app-from-a-manifest): an application posts
|
|
5
|
+
// a form holding its `manifest` (JSON) to the page, with an optional `state`; the person sees the app it describes
|
|
6
|
+
// and creates it; GitHub registers the app, owned by the organization or the person, and redirects to the manifest's
|
|
7
|
+
// `redirect_url` with a temporary `code` (and the `state`), which the application exchanges within an hour, once, at
|
|
8
|
+
// POST /app-manifests/{code}/conversions (semantics/apps.ts) for the app's id, private key and secrets.
|
|
9
|
+
//
|
|
10
|
+
// Where the documentation stops and the twin decides: the person is who signed in (src/screens/session.tsx), and a
|
|
11
|
+
// visitor not signed in is sent to the sign-in first; only an owner (an admin) of the organization may register an app for it. The app's slug is
|
|
12
|
+
// its name, lower-cased with runs of other characters as hyphens. A manifest the page cannot read, or one with no name
|
|
13
|
+
// or url, is shown as an error rather than registered.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { applyTwinWrite } from '@volter/world-core';
|
|
16
|
+
import { CONSENT_CSS, flowPage } from '@volter/world-ui';
|
|
17
|
+
import { githubState, registerGithubApp } from "../github-twin.js";
|
|
18
|
+
import { errorPage, redirect, SKIN } from "./oauth.js";
|
|
19
|
+
import { signedIn, toSignIn } from "./session.js";
|
|
20
|
+
const SERVICE = 'github';
|
|
21
|
+
const PAGE = /^\/(?:organizations\/([^/]+)\/)?settings\/apps\/new$/;
|
|
22
|
+
async function fieldsOf(request) {
|
|
23
|
+
const fields = Object.fromEntries(new URL(request.url).searchParams);
|
|
24
|
+
if (request.method === 'POST')
|
|
25
|
+
for (const [k, v] of new URLSearchParams(await request.text()))
|
|
26
|
+
fields[k] = v;
|
|
27
|
+
return fields;
|
|
28
|
+
}
|
|
29
|
+
const slugOf = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
30
|
+
function confirm(owner, manifest, raw, fields, action) {
|
|
31
|
+
const permissions = Object.entries(manifest.default_permissions ?? {});
|
|
32
|
+
return flowPage({
|
|
33
|
+
title: 'Create GitHub App',
|
|
34
|
+
css: [CONSENT_CSS, SKIN],
|
|
35
|
+
body: (_jsxs("main", { className: "consent", children: [_jsxs("h1", { className: "consent-heading", children: ["Create GitHub App for ", _jsx("strong", { children: owner })] }), _jsxs("div", { className: "consent-box", children: [_jsxs("p", { children: [_jsx("strong", { children: manifest.name }), " ", manifest.url ? _jsx("small", { children: manifest.url }) : null] }), manifest.description ? _jsx("p", { className: "consent-note", children: manifest.description }) : null, _jsxs("ul", { className: "consent-permissions", children: [permissions.map(([k, v]) => _jsxs("li", { className: "consent-permission", children: [_jsx("strong", { className: "consent-permission-title", children: k.replace(/_/g, ' ') }), " ", _jsx("small", { children: v })] }, k)), manifest.default_events?.length ? _jsxs("li", { className: "consent-permission", children: [_jsx("strong", { className: "consent-permission-title", children: "Events" }), " ", _jsx("small", { children: manifest.default_events.join(', ') })] }) : null] }), _jsxs("form", { method: "post", action: action, children: [_jsx("input", { type: "hidden", name: "manifest", value: raw }), fields.state !== undefined ? _jsx("input", { type: "hidden", name: "state", value: fields.state }) : null, _jsx("div", { className: "consent-actions", children: _jsxs("button", { type: "submit", name: "create", value: "1", className: "consent-allow", children: ["Create GitHub App for ", owner] }) })] })] })] })),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** github.com's new-app-from-a-manifest page, or undefined for any other request. */
|
|
39
|
+
export function githubAppManifestFlow(flow) {
|
|
40
|
+
return async (request) => {
|
|
41
|
+
const path = new URL(request.url).pathname.replace(/\/+$/, '');
|
|
42
|
+
const match = PAGE.exec(path);
|
|
43
|
+
if (!match || request.method !== 'POST')
|
|
44
|
+
return undefined;
|
|
45
|
+
const login = signedIn(request, flow.root);
|
|
46
|
+
if (!login)
|
|
47
|
+
return toSignIn(request);
|
|
48
|
+
const fields = await fieldsOf(request);
|
|
49
|
+
const org = match[1] ? decodeURIComponent(match[1]) : undefined;
|
|
50
|
+
const st = githubState(flow.root);
|
|
51
|
+
if (org && !st.orgMembers.some((m) => m.org === org && m.login === login && m.role === 'admin' && m.state === 'active'))
|
|
52
|
+
return errorPage(404, 'Not Found');
|
|
53
|
+
let manifest;
|
|
54
|
+
try {
|
|
55
|
+
manifest = JSON.parse(fields.manifest ?? '');
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return errorPage(422, 'The manifest is not valid JSON.');
|
|
59
|
+
}
|
|
60
|
+
if (!manifest.name || !manifest.url)
|
|
61
|
+
return errorPage(422, 'A manifest needs a name and a url.');
|
|
62
|
+
if (fields.create !== '1')
|
|
63
|
+
return confirm(org ?? login, manifest, fields.manifest, fields, path);
|
|
64
|
+
const at = flow.now();
|
|
65
|
+
const slug = slugOf(manifest.name);
|
|
66
|
+
const secret = (what) => createHash('sha256').update(`app:${slug}:${what}`).digest('hex');
|
|
67
|
+
const made = await registerGithubApp({
|
|
68
|
+
slug, name: manifest.name, owner: org ?? login, description: manifest.description ?? null, url: manifest.url,
|
|
69
|
+
permissions: manifest.default_permissions ?? {}, events: manifest.default_events ?? [],
|
|
70
|
+
...(manifest.setup_url ? { setup_url: manifest.setup_url } : {}), ...(manifest.callback_urls?.[0] ? { callback_url: manifest.callback_urls[0] } : {}),
|
|
71
|
+
request_oauth_on_install: manifest.request_oauth_on_install === true,
|
|
72
|
+
...(manifest.hook_attributes?.url && manifest.hook_attributes.active !== false ? { hook_attributes: { url: manifest.hook_attributes.url }, webhook_secret: secret('webhook').slice(0, 40) } : {}),
|
|
73
|
+
}, at, flow.root);
|
|
74
|
+
if (!made.app)
|
|
75
|
+
return errorPage(422, `An app named ${manifest.name} already exists.`);
|
|
76
|
+
// the temporary code the application exchanges once, within an hour, for the app's credentials
|
|
77
|
+
const code = secret(`code:${at}`).slice(0, 20);
|
|
78
|
+
await applyTwinWrite(SERVICE, { operation: 'app_manifest_code.create', subjectType: 'app_manifest_code', subjectId: `manifestcode:${code}`, fields: { code, app_slug: slug, created_at: at, used: false }, occurredAt: at, actor: { kind: 'human', id: login } }, flow.root);
|
|
79
|
+
return manifest.redirect_url ? redirect(manifest.redirect_url, { code, state: fields.state }) : redirect(`https://github.com/apps/${slug}`, {});
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The scopes a request names, as GitHub keeps them: known, once each, sorted. */
|
|
2
|
+
export declare function normalizeScopes(raw: string | undefined): string[];
|
|
3
|
+
export declare const SKIN = "\nbody { background: #f6f8fa; color: #1f2328; }\n.consent-badge { background: #ffffff; border: 1px solid #d1d9e0; color: #59636e; }\n.consent-vendor { background: #1f2328; }\n.consent-link { border-color: #d1d9e0; }\n.consent-box { background: #ffffff; border-color: #d1d9e0; }\n.consent-who small, .consent-permission summary small, .consent-note { color: #59636e; }\n.consent-actions { border-color: #d1d9e0; }\n.consent-deny { background: #f6f8fa; border-color: #d1d9e0; color: #25292e; }\n.consent-allow { background: #1f883d; border-color: rgba(31,35,40,0.15); color: #ffffff; }\n";
|
|
4
|
+
export type Flow = {
|
|
5
|
+
root?: string;
|
|
6
|
+
now: () => string;
|
|
7
|
+
};
|
|
8
|
+
export declare function redirect(to: string, params: Record<string, string | undefined>): Response;
|
|
9
|
+
export declare function errorPage(status: number, message: string): Response;
|
|
10
|
+
/** A code for `client_id`, bound to `login`, the scopes and the redirect_uri, redeemable once at
|
|
11
|
+
* /login/oauth/access_token within ten minutes: what a consent issues, and what a GitHub App's
|
|
12
|
+
* installation issues when it asks to authorize its installer (src/screens/app-installation.tsx). */
|
|
13
|
+
export declare function mintOAuthCode(p: Record<string, string>, flow: Flow): Promise<string>;
|
|
14
|
+
/** github.com's OAuth paths, or undefined for any other request. */
|
|
15
|
+
export declare function githubOAuthFlow(flow: Flow): (request: Request) => Promise<Response | undefined>;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// GITHUB'S OAUTH WEB FLOW — a hosted flow (docs/contributing/architecture.md, "Screens"): the consent
|
|
3
|
+
// screen at github.com/login/oauth/authorize and the code exchange at github.com/login/oauth/access_token,
|
|
4
|
+
// held to the round trip GitHub documents (docs.github.com/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps):
|
|
5
|
+
// the application sends its user to the consent with client_id, redirect_uri, scope, state and an optional
|
|
6
|
+
// PKCE challenge; the user's answer redirects back with `code` and `state`, or `error=access_denied`; the
|
|
7
|
+
// application exchanges the code once, within ten minutes, for a token scoped as consented, answered in the
|
|
8
|
+
// format its Accept header asks for. The consent is authored from @volter/world-ui's piece under GitHub's
|
|
9
|
+
// skin; nothing of GitHub's page is copied. Consenting is a move no API call makes: the grant it leaves is
|
|
10
|
+
// the user's (writes with the token are theirs, GET /user names them).
|
|
11
|
+
//
|
|
12
|
+
// Where the documentation stops and the twin decides: the World has no OAuth app registry (apps are made in
|
|
13
|
+
// GitHub's settings UI), so any client_id is an app named by its id, and a redirect_uri is required rather
|
|
14
|
+
// than defaulting to a registered callback. The person is who signed in (src/screens/session.tsx); a visitor not
|
|
15
|
+
// signed in is sent to the sign-in first, with the `login` the application suggests prefilled there, as GitHub's
|
|
16
|
+
// `login` parameter does. A PKCE verifier that does not match answers bad_verification_code.
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from '@volter/world-core';
|
|
19
|
+
import { Consent, CONSENT_CSS, flowPage } from '@volter/world-ui';
|
|
20
|
+
import { signedIn, toSignIn } from "./session.js";
|
|
21
|
+
const SERVICE = 'github';
|
|
22
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
23
|
+
const DOCS = 'https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors';
|
|
24
|
+
const GROUPS = [
|
|
25
|
+
{ title: 'Repositories', levels: [
|
|
26
|
+
{ scope: 'repo', detail: 'Public and private', help: 'This application will be able to read and write all public and private repository data.' },
|
|
27
|
+
{ scope: 'public_repo', detail: 'Public repositories', help: 'This application will be able to read and write all public repository data.' },
|
|
28
|
+
{ scope: 'repo:status', detail: 'Commit statuses', help: 'This application will be able to read and write commit statuses.' },
|
|
29
|
+
{ scope: 'repo_deployment', detail: 'Deployment statuses', help: 'This application will be able to read and write deployment statuses.' },
|
|
30
|
+
{ scope: 'repo:invite', detail: 'Repository invitations', help: 'This application will be able to accept and decline repository invitations.' },
|
|
31
|
+
{ scope: 'security_events', detail: 'Security events', help: 'This application will be able to read and write security events.' },
|
|
32
|
+
] },
|
|
33
|
+
{ title: 'Workflow', levels: [{ scope: 'workflow', detail: 'Update GitHub Action workflow files', help: 'This application will be able to add and update GitHub Actions workflow files.' }] },
|
|
34
|
+
{ title: 'Delete repositories', levels: [{ scope: 'delete_repo', detail: 'Delete repositories', help: 'This application will be able to delete administrable repositories.' }] },
|
|
35
|
+
{ title: 'Organizations and teams', levels: [
|
|
36
|
+
{ scope: 'admin:org', detail: 'Admin access', help: 'This application will be able to fully manage your organizations, their teams and memberships.' },
|
|
37
|
+
{ scope: 'write:org', detail: 'Read and write access', help: 'This application will be able to read and write organization and team membership.' },
|
|
38
|
+
{ scope: 'read:org', detail: 'Read-only access', help: 'This application will be able to read your organization, team membership, and private project boards.' },
|
|
39
|
+
] },
|
|
40
|
+
{ title: 'Organization webhooks', levels: [{ scope: 'admin:org_hook', detail: 'Admin access', help: 'This application will be able to manage organization webhooks.' }] },
|
|
41
|
+
{ title: 'Repository webhooks and services', levels: [
|
|
42
|
+
{ scope: 'admin:repo_hook', detail: 'Admin access', help: 'This application will be able to manage repository webhooks and services.' },
|
|
43
|
+
{ scope: 'write:repo_hook', detail: 'Read and write access', help: 'This application will be able to read and write repository webhooks.' },
|
|
44
|
+
{ scope: 'read:repo_hook', detail: 'Read-only access', help: 'This application will be able to read repository webhooks.' },
|
|
45
|
+
] },
|
|
46
|
+
{ title: 'Public SSH keys', levels: [
|
|
47
|
+
{ scope: 'admin:public_key', detail: 'Admin access', help: 'This application will be able to fully manage your public SSH keys.' },
|
|
48
|
+
{ scope: 'write:public_key', detail: 'Read and write access', help: 'This application will be able to read and create public SSH keys.' },
|
|
49
|
+
{ scope: 'read:public_key', detail: 'Read-only access', help: 'This application will be able to read your public SSH keys.' },
|
|
50
|
+
] },
|
|
51
|
+
{ title: 'GPG keys', levels: [
|
|
52
|
+
{ scope: 'admin:gpg_key', detail: 'Admin access', help: 'This application will be able to fully manage your GPG keys.' },
|
|
53
|
+
{ scope: 'write:gpg_key', detail: 'Read and write access', help: 'This application will be able to read and create GPG keys.' },
|
|
54
|
+
{ scope: 'read:gpg_key', detail: 'Read-only access', help: 'This application will be able to read your GPG keys.' },
|
|
55
|
+
] },
|
|
56
|
+
{ title: 'Gists', levels: [{ scope: 'gist', detail: 'Read and write access', help: 'This application will be able to create gists.' }] },
|
|
57
|
+
{ title: 'Notifications', levels: [{ scope: 'notifications', detail: 'Read-only access', help: 'This application will be able to read your notifications.' }] },
|
|
58
|
+
{ title: 'Packages', levels: [
|
|
59
|
+
{ scope: 'delete:packages', detail: 'Delete access', help: 'This application will be able to delete packages.' },
|
|
60
|
+
{ scope: 'write:packages', detail: 'Read and write access', help: 'This application will be able to upload and publish packages.' },
|
|
61
|
+
{ scope: 'read:packages', detail: 'Read-only access', help: 'This application will be able to download and install packages.' },
|
|
62
|
+
] },
|
|
63
|
+
{ title: 'Projects', levels: [
|
|
64
|
+
{ scope: 'project', detail: 'Read and write access', help: 'This application will be able to read and write your projects.' },
|
|
65
|
+
{ scope: 'read:project', detail: 'Read-only access', help: 'This application will be able to read your projects.' },
|
|
66
|
+
] },
|
|
67
|
+
{ title: 'Codespaces', levels: [{ scope: 'codespace', detail: 'Full control', help: 'This application will be able to create and manage your codespaces.' }] },
|
|
68
|
+
];
|
|
69
|
+
// Personal user data combines what it is given rather than taking the strongest.
|
|
70
|
+
const USER_PARTS = [
|
|
71
|
+
{ scope: 'user:follow', words: 'Follow users' },
|
|
72
|
+
{ scope: 'read:user', words: 'Profile information (read-only)' },
|
|
73
|
+
{ scope: 'user:email', words: 'Email addresses (read-only)' },
|
|
74
|
+
];
|
|
75
|
+
const KNOWN = new Set(['user', ...USER_PARTS.map((p) => p.scope), ...GROUPS.flatMap((g) => g.levels.map((l) => l.scope))]);
|
|
76
|
+
/** The scopes a request names, as GitHub keeps them: known, once each, sorted. */
|
|
77
|
+
export function normalizeScopes(raw) {
|
|
78
|
+
return [...new Set((raw ?? '').split(/[\s,]+/).filter((s) => KNOWN.has(s)))].sort();
|
|
79
|
+
}
|
|
80
|
+
function permissionsFor(scopes) {
|
|
81
|
+
const out = [];
|
|
82
|
+
if (scopes.includes('user'))
|
|
83
|
+
out.push({ title: 'Personal user data', detail: 'Full access', help: 'This application will be able to read and write all user data.' });
|
|
84
|
+
else {
|
|
85
|
+
const parts = USER_PARTS.filter((p) => scopes.includes(p.scope));
|
|
86
|
+
if (parts.length)
|
|
87
|
+
out.push({ title: 'Personal user data', detail: parts.map((p) => p.words).join(', '), help: 'This application will be able to read the user data listed.' });
|
|
88
|
+
}
|
|
89
|
+
for (const g of GROUPS) {
|
|
90
|
+
const level = g.levels.find((l) => scopes.includes(l.scope));
|
|
91
|
+
if (level)
|
|
92
|
+
out.push({ title: g.title, detail: level.detail, help: level.help });
|
|
93
|
+
}
|
|
94
|
+
if (!out.length)
|
|
95
|
+
out.push({ title: 'Public data only', detail: 'Limited access to your public data', help: 'This application will be able to identify you and read public information.' });
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
// GitHub's skin over the consent: Primer's colours and its green primary button.
|
|
99
|
+
export const SKIN = `
|
|
100
|
+
body { background: #f6f8fa; color: #1f2328; }
|
|
101
|
+
.consent-badge { background: #ffffff; border: 1px solid #d1d9e0; color: #59636e; }
|
|
102
|
+
.consent-vendor { background: #1f2328; }
|
|
103
|
+
.consent-link { border-color: #d1d9e0; }
|
|
104
|
+
.consent-box { background: #ffffff; border-color: #d1d9e0; }
|
|
105
|
+
.consent-who small, .consent-permission summary small, .consent-note { color: #59636e; }
|
|
106
|
+
.consent-actions { border-color: #d1d9e0; }
|
|
107
|
+
.consent-deny { background: #f6f8fa; border-color: #d1d9e0; color: #25292e; }
|
|
108
|
+
.consent-allow { background: #1f883d; border-color: rgba(31,35,40,0.15); color: #ffffff; }
|
|
109
|
+
`;
|
|
110
|
+
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
111
|
+
const parses = (u) => { try {
|
|
112
|
+
return !!u && !!new URL(u);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
} };
|
|
117
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
118
|
+
async function paramsOf(request) {
|
|
119
|
+
const url = new URL(request.url);
|
|
120
|
+
const out = Object.fromEntries(url.searchParams);
|
|
121
|
+
if (request.method !== 'POST')
|
|
122
|
+
return out;
|
|
123
|
+
const text = await request.text();
|
|
124
|
+
if (!text)
|
|
125
|
+
return out;
|
|
126
|
+
if ((request.headers.get('content-type') ?? '').includes('json')) {
|
|
127
|
+
try {
|
|
128
|
+
for (const [k, v] of Object.entries(JSON.parse(text)))
|
|
129
|
+
if (v !== undefined && v !== null)
|
|
130
|
+
out[k] = String(v);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
catch { /* GitHub reads an unparseable body as form fields */ }
|
|
134
|
+
}
|
|
135
|
+
return { ...out, ...Object.fromEntries(new URLSearchParams(text)) };
|
|
136
|
+
}
|
|
137
|
+
export function redirect(to, params) {
|
|
138
|
+
const url = new URL(to);
|
|
139
|
+
for (const [k, v] of Object.entries(params))
|
|
140
|
+
if (v !== undefined)
|
|
141
|
+
url.searchParams.set(k, v);
|
|
142
|
+
return new Response(null, { status: 302, headers: { location: url.toString() } });
|
|
143
|
+
}
|
|
144
|
+
export function errorPage(status, message) {
|
|
145
|
+
return flowPage({ title: 'GitHub', status, css: [CONSENT_CSS, SKIN], body: _jsx("main", { className: "consent", children: _jsx("h1", { className: "consent-heading", children: message }) }) });
|
|
146
|
+
}
|
|
147
|
+
/** The token answer in the format the Accept header asks for: JSON, XML, or GitHub's default form encoding. */
|
|
148
|
+
function tokenAnswer(request, fields) {
|
|
149
|
+
const accept = request.headers.get('accept') ?? '';
|
|
150
|
+
if (accept.includes('application/json'))
|
|
151
|
+
return Response.json(fields);
|
|
152
|
+
if (accept.includes('xml'))
|
|
153
|
+
return new Response(`<OAuth>${Object.entries(fields).map(([k, v]) => `<${k}>${v.replace(/[<>&]/g, '')}</${k}>`).join('')}</OAuth>`, { headers: { 'content-type': 'application/xml; charset=utf-8' } });
|
|
154
|
+
return new Response(new URLSearchParams(fields).toString(), { headers: { 'content-type': 'application/x-www-form-urlencoded; charset=utf-8' } });
|
|
155
|
+
}
|
|
156
|
+
const tokenError = (request, error, description, anchor) => tokenAnswer(request, { error, error_description: description, error_uri: `${DOCS}#${anchor}` });
|
|
157
|
+
const grantsOf = (root) => twinResources(SERVICE, root).filter((r) => r.type === 'oauth_grant' && r.deleted !== true);
|
|
158
|
+
async function consent(request, flow) {
|
|
159
|
+
const p = await paramsOf(request);
|
|
160
|
+
if (!p.client_id)
|
|
161
|
+
return errorPage(404, 'Not Found');
|
|
162
|
+
if (!parses(p.redirect_uri))
|
|
163
|
+
return errorPage(400, 'The redirect_uri is not associated with this application.');
|
|
164
|
+
const login = signedIn(request, flow.root);
|
|
165
|
+
if (!login)
|
|
166
|
+
return toSignIn(request, p.login && LOGIN.test(p.login) ? p.login : undefined);
|
|
167
|
+
const scopes = normalizeScopes(p.scope);
|
|
168
|
+
const fields = { client_id: p.client_id, redirect_uri: p.redirect_uri, scope: scopes.join(','), ...(p.state !== undefined ? { state: p.state } : {}), ...(p.code_challenge ? { code_challenge: p.code_challenge, code_challenge_method: p.code_challenge_method ?? 'plain' } : {}) };
|
|
169
|
+
// GitHub asks once: a user who already granted this app every scope requested goes straight back with a code.
|
|
170
|
+
const held = grantsOf(flow.root).filter((g) => g.client_id === p.client_id && g.login === login).flatMap((g) => (Array.isArray(g.scopes) ? g.scopes : []));
|
|
171
|
+
if (held.length && scopes.every((s) => held.includes(s)))
|
|
172
|
+
return issue(fields, flow);
|
|
173
|
+
return flowPage({
|
|
174
|
+
title: `Authorize application`,
|
|
175
|
+
css: [CONSENT_CSS, SKIN],
|
|
176
|
+
body: (_jsx(Consent, { heading: `Authorize ${p.client_id}`, app: { name: p.client_id }, request: `wants to access your ${login} account`, permissions: permissionsFor(scopes), action: "/login/oauth/authorize", fields: fields, deny: { name: 'authorize', value: '0', label: 'Cancel' }, allow: { name: 'authorize', value: '1', label: `Authorize ${login}` }, note: `Authorizing will redirect to ${new URL(p.redirect_uri).origin}` })),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** The consenting person's answer: a code for the application, or its refusal. */
|
|
180
|
+
async function answer(request, flow) {
|
|
181
|
+
const p = await paramsOf(request);
|
|
182
|
+
if (!p.client_id || !parses(p.redirect_uri))
|
|
183
|
+
return errorPage(400, 'The redirect_uri is not associated with this application.');
|
|
184
|
+
if (p.authorize !== '1') {
|
|
185
|
+
return redirect(p.redirect_uri, { error: 'access_denied', error_description: 'The user has denied your application access.', error_uri: 'https://docs.github.com/apps/managing-oauth-apps/troubleshooting-authorization-request-errors/#access-denied', state: p.state });
|
|
186
|
+
}
|
|
187
|
+
const login = signedIn(request, flow.root);
|
|
188
|
+
if (!login)
|
|
189
|
+
return toSignIn(request);
|
|
190
|
+
return issue({ ...p, login, scope: normalizeScopes(p.scope).join(',') }, flow);
|
|
191
|
+
}
|
|
192
|
+
async function issue(p, flow) {
|
|
193
|
+
const code = await mintOAuthCode(p, flow);
|
|
194
|
+
return redirect(p.redirect_uri, { code, state: p.state });
|
|
195
|
+
}
|
|
196
|
+
/** A code for `client_id`, bound to `login`, the scopes and the redirect_uri, redeemable once at
|
|
197
|
+
* /login/oauth/access_token within ten minutes: what a consent issues, and what a GitHub App's
|
|
198
|
+
* installation issues when it asks to authorize its installer (src/screens/app-installation.tsx). */
|
|
199
|
+
export async function mintOAuthCode(p, flow) {
|
|
200
|
+
const at = flow.now();
|
|
201
|
+
const { value: code } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
202
|
+
const n = resources.filter((r) => r.type === 'oauth_code').length + 1;
|
|
203
|
+
const code = hex(`oauthcode:${p.client_id}:${p.login}:${at}:${n}`).slice(0, 20);
|
|
204
|
+
const fields = { code, client_id: p.client_id, login: p.login, scopes: p.scope ? p.scope.split(',') : [], redirect_uri: p.redirect_uri, created_at: at, ...(p.code_challenge ? { code_challenge: p.code_challenge, code_challenge_method: p.code_challenge_method ?? 'plain' } : {}) };
|
|
205
|
+
return { kind: 'write', value: code, write: { operation: 'oauth_code.create', subjectType: 'oauth_code', subjectId: `oauthcode:${code}`, fields, occurredAt: at, actor: { kind: 'human', id: p.login } } };
|
|
206
|
+
}, flow.root);
|
|
207
|
+
return code;
|
|
208
|
+
}
|
|
209
|
+
/** The application's exchange: the code, once, within ten minutes, from the app it was issued to. */
|
|
210
|
+
async function exchange(request, flow) {
|
|
211
|
+
const p = await paramsOf(request);
|
|
212
|
+
if (p.grant_type && p.grant_type !== 'authorization_code')
|
|
213
|
+
return tokenError(request, 'unsupported_grant_type', 'The grant type is not supported by the twin.', 'unsupported-grant-type');
|
|
214
|
+
if (!p.client_id)
|
|
215
|
+
return tokenError(request, 'incorrect_client_credentials', 'The client_id and/or client_secret passed are incorrect.', 'incorrect-client-credentials');
|
|
216
|
+
const at = flow.now();
|
|
217
|
+
const { value: outcome } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
218
|
+
const row = resources.find((r) => r.type === 'oauth_code' && r.code === p.code);
|
|
219
|
+
const bad = { kind: 'skip', value: { error: ['bad_verification_code', 'The code passed is incorrect or expired.', 'bad-verification-code'] } };
|
|
220
|
+
if (!p.code || !row || row.used || row.client_id !== p.client_id || Date.parse(at) - Date.parse(row.created_at) > CODE_TTL_MS)
|
|
221
|
+
return bad;
|
|
222
|
+
if (p.redirect_uri && p.redirect_uri !== row.redirect_uri)
|
|
223
|
+
return { kind: 'skip', value: { error: ['redirect_uri_mismatch', 'The redirect_uri MUST match the registered callback URL for this application.', 'redirect-uri-mismatch'] } };
|
|
224
|
+
if (row.code_challenge) {
|
|
225
|
+
const verifier = p.code_verifier ?? '';
|
|
226
|
+
const derived = row.code_challenge_method === 'S256' ? createHash('sha256').update(verifier).digest('base64url') : verifier;
|
|
227
|
+
if (!verifier || derived !== row.code_challenge)
|
|
228
|
+
return bad;
|
|
229
|
+
}
|
|
230
|
+
return { kind: 'write', value: { code: row }, write: { operation: 'oauth_code.redeem', subjectType: 'oauth_code', subjectId: `oauthcode:${row.code}`, fields: { ...row, used: true }, occurredAt: at, actor: { kind: 'agent' } } };
|
|
231
|
+
}, flow.root);
|
|
232
|
+
if ('error' in outcome)
|
|
233
|
+
return tokenError(request, ...outcome.error);
|
|
234
|
+
const { code } = outcome;
|
|
235
|
+
const token = `gho_${hex(`oauth:${code.client_id}:${code.code}`).slice(0, 36)}`;
|
|
236
|
+
await applyTwinWrite(SERVICE, { operation: 'oauth_grant.create', subjectType: 'oauth_grant', subjectId: `oauthgrant:${token}`, fields: { token, client_id: code.client_id, scopes: code.scopes, login: code.login, created_at: at }, occurredAt: at, actor: { kind: 'agent' } }, flow.root);
|
|
237
|
+
return tokenAnswer(request, { access_token: token, token_type: 'bearer', scope: code.scopes.join(',') });
|
|
238
|
+
}
|
|
239
|
+
/** github.com's OAuth paths, or undefined for any other request. */
|
|
240
|
+
export function githubOAuthFlow(flow) {
|
|
241
|
+
return async (request) => {
|
|
242
|
+
const path = new URL(request.url).pathname.replace(/\/+$/, '');
|
|
243
|
+
if (path === '/login/oauth/authorize')
|
|
244
|
+
return authorizePage(request, flow);
|
|
245
|
+
if (path === '/login/oauth/access_token' && request.method === 'POST')
|
|
246
|
+
return exchange(request, flow);
|
|
247
|
+
return undefined;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** The authorize page: the consent it shows (GET) and the person's answer (POST). */
|
|
251
|
+
async function authorizePage(request, flow) {
|
|
252
|
+
if (request.method === 'GET')
|
|
253
|
+
return consent(request, flow);
|
|
254
|
+
if (request.method === 'POST')
|
|
255
|
+
return answer(request, flow);
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Flow } from './oauth.js';
|
|
2
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
3
|
+
export declare function signedIn(request: Request, root: string | undefined): string | undefined;
|
|
4
|
+
/** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after (or to the page a
|
|
5
|
+
* door stands in for, `page`), with an account the application suggests prefilled. */
|
|
6
|
+
export declare function toSignIn(request: Request, suggested?: string, page?: string): Response;
|
|
7
|
+
/** github.com's sign-in paths, or undefined for any other request. */
|
|
8
|
+
export declare function githubSessionFlow(flow: Flow): (request: Request) => Promise<Response | undefined>;
|