@volter/twin-github 2.0.0 → 2.0.2
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/src/github-capabilities.js +114 -0
- package/dist/src/github-events.js +6 -4
- package/dist/src/github-git-http.js +2 -2
- package/dist/src/github-mirror-ui.js +3 -3
- package/dist/src/github-server.js +8 -6
- package/dist/src/github-twin.d.ts +15 -0
- package/dist/src/github-twin.js +116 -8
- package/dist/src/index.js +3 -0
- package/dist/src/screens/session.js +16 -4
- package/dist/src/semantics/graphql.js +7 -2
- package/dist/src/semantics/shared.js +1 -1
- package/dist/src/semantics/users.js +66 -24
- package/package.json +4 -4
- package/src/github-capabilities.ts +94 -0
- package/src/github-events.ts +6 -4
- package/src/github-git-http.ts +2 -2
- package/src/github-mirror-ui.ts +3 -3
- package/src/github-server.ts +8 -6
- package/src/github-twin.ts +96 -12
- package/src/index.ts +3 -0
- package/src/screens/session.tsx +12 -4
- package/src/semantics/graphql.ts +7 -2
- package/src/semantics/shared.ts +1 -1
- package/src/semantics/users.ts +63 -23
package/src/github-twin.ts
CHANGED
|
@@ -814,8 +814,9 @@ export type GithubRuleSuite = { id: number; repository: string; ref: string; act
|
|
|
814
814
|
rules: { ruleset_id: number; ruleset_name: string; rule_type: string; enforcement: string; result: 'pass' | 'fail'; details: string | null }[] };
|
|
815
815
|
|
|
816
816
|
// ── User niche families: emails / SSH+GPG keys / blocking ──────────────────────────────────
|
|
817
|
-
// An
|
|
818
|
-
|
|
817
|
+
// An account's EMAIL (list/add/delete + primary/visibility). LOCAL, email-keyed: an address belongs to one account
|
|
818
|
+
// (`login`; a row written before accounts held their own addresses is the World's user's, octocat's).
|
|
819
|
+
export type GithubUserEmail = { email: string; primary: boolean; verified: boolean; visibility: string | null; login: string };
|
|
819
820
|
// A user SSH public KEY (id-keyed, title + key text). LOCAL.
|
|
820
821
|
export type GithubUserSshKey = { id: number; key: string; title: string; created_at?: string };
|
|
821
822
|
// A user GPG KEY (id-keyed, key_id + armored public key). LOCAL.
|
|
@@ -914,7 +915,7 @@ function licenseOf(file: GithubContentFile | undefined): Record<string, unknown>
|
|
|
914
915
|
const edgesOf = (m: Map<string, boolean>): GithubEdge[] => [...m.entries()].filter(([, on]) => on).map(([k]) => { const i = k.indexOf('|'); return { from: k.slice(0, i), to: k.slice(i + 1) }; });
|
|
915
916
|
/** The World's user's side of the edges, as the set its own endpoints read. */
|
|
916
917
|
const pairsOf = (m: Map<string, boolean>, login: string): Set<string> => new Set(edgesOf(m).filter((e) => e.from === login).map((e) => e.to));
|
|
917
|
-
export type GithubState = { prs: GithubPr[]; issues: GithubIssue[]; reviews: GithubReview[]; comments: GithubComment[]; statuses: GithubStatus[]; checkRuns: GithubCheckRun[]; milestones: GithubMilestone[]; workflows: GithubWorkflow[]; workflowRuns: GithubWorkflowRun[]; jobs: GithubJob[]; releases: GithubRelease[]; releaseAssets: GithubReleaseAsset[]; tags: GithubTag[]; discussions: GithubDiscussion[]; discussionComments: GithubDiscussionComment[]; reactions: GithubReaction[]; labels: GithubLabel[]; repos: GithubRepo[]; branches: GithubBranch[]; collaborators: GithubCollaborator[]; webhooks: GithubWebhook[]; contents: GithubContentFile[]; gists: GithubGist[]; notifications: GithubNotification[]; gitRefs: GithubGitRef[]; gitCommits: GithubGitCommit[]; gitTrees: GithubGitTree[]; gitBlobs: GithubGitBlob[]; gitTagObjects: GithubGitTagObject[]; deployments: GithubDeployment[]; deploymentStatuses: GithubDeploymentStatus[]; environments: GithubEnvironment[]; actionsSecrets: GithubActionsSecret[]; actionsVariables: GithubActionsVariable[]; actionsCaches: GithubActionsCache[]; actionsArtifacts: GithubActionsArtifact[]; orgs: GithubOrg[]; orgMembers: GithubOrgMembership[]; outsideCollaborators: GithubOutsideCollaborator[]; orgWebhooks: GithubOrgWebhook[]; teams: GithubTeam[]; teamMembers: GithubTeamMembership[]; teamRepos: GithubTeamRepo[]; commitComments: GithubCommitComment[]; issueTypes: GithubIssueType[]; deployKeys: GithubDeployKey[]; autolinks: GithubAutolink[]; rulesets: GithubRuleset[]; ruleSuites: GithubRuleSuite[]; userEmails: GithubUserEmail[]; userSshKeys: GithubUserSshKey[]; userGpgKeys: GithubUserGpgKey[]; blockedUsers: Set<string>; userProfile: Partial<GithubUser>; projects: GithubProject[]; projectFields: GithubProjectField[]; projectItems: GithubProjectItem[]; projectViews: GithubProjectView[]; stars: Set<string>; watching: Set<string>; following: Set<string>; starEdges: GithubEdge[]; watchEdges: GithubEdge[]; followEdges: GithubEdge[]; codeScanningAlerts: GithubCodeScanningAlert[]; codeScanningAnalyses: GithubCodeScanningAnalysis[]; dependabotAlerts: GithubDependabotAlert[]; secretScanningAlerts: GithubSecretScanningAlert[]; appInstallations: GithubAppInstallation[]; oauthGrants: GithubOAuthGrant[]; packages: GithubPackage[]; packageVersions: GithubPackageVersion[]; pagesSites: GithubPagesSite[]; pagesBuilds: GithubPagesBuild[]; codespaces: GithubCodespace[] };
|
|
918
|
+
export type GithubState = { prs: GithubPr[]; issues: GithubIssue[]; reviews: GithubReview[]; comments: GithubComment[]; statuses: GithubStatus[]; checkRuns: GithubCheckRun[]; milestones: GithubMilestone[]; workflows: GithubWorkflow[]; workflowRuns: GithubWorkflowRun[]; jobs: GithubJob[]; releases: GithubRelease[]; releaseAssets: GithubReleaseAsset[]; tags: GithubTag[]; discussions: GithubDiscussion[]; discussionComments: GithubDiscussionComment[]; reactions: GithubReaction[]; labels: GithubLabel[]; repos: GithubRepo[]; branches: GithubBranch[]; collaborators: GithubCollaborator[]; webhooks: GithubWebhook[]; contents: GithubContentFile[]; gists: GithubGist[]; notifications: GithubNotification[]; gitRefs: GithubGitRef[]; gitCommits: GithubGitCommit[]; gitTrees: GithubGitTree[]; gitBlobs: GithubGitBlob[]; gitTagObjects: GithubGitTagObject[]; deployments: GithubDeployment[]; deploymentStatuses: GithubDeploymentStatus[]; environments: GithubEnvironment[]; actionsSecrets: GithubActionsSecret[]; actionsVariables: GithubActionsVariable[]; actionsCaches: GithubActionsCache[]; actionsArtifacts: GithubActionsArtifact[]; orgs: GithubOrg[]; orgMembers: GithubOrgMembership[]; outsideCollaborators: GithubOutsideCollaborator[]; orgWebhooks: GithubOrgWebhook[]; teams: GithubTeam[]; teamMembers: GithubTeamMembership[]; teamRepos: GithubTeamRepo[]; commitComments: GithubCommitComment[]; issueTypes: GithubIssueType[]; deployKeys: GithubDeployKey[]; autolinks: GithubAutolink[]; rulesets: GithubRuleset[]; ruleSuites: GithubRuleSuite[]; userEmails: GithubUserEmail[]; userSshKeys: GithubUserSshKey[]; userGpgKeys: GithubUserGpgKey[]; blockedUsers: Set<string>; userProfile: Partial<GithubUser>; userProfiles: Record<string, Partial<GithubUser>>; projects: GithubProject[]; projectFields: GithubProjectField[]; projectItems: GithubProjectItem[]; projectViews: GithubProjectView[]; stars: Set<string>; watching: Set<string>; following: Set<string>; starEdges: GithubEdge[]; watchEdges: GithubEdge[]; followEdges: GithubEdge[]; codeScanningAlerts: GithubCodeScanningAlert[]; codeScanningAnalyses: GithubCodeScanningAnalysis[]; dependabotAlerts: GithubDependabotAlert[]; secretScanningAlerts: GithubSecretScanningAlert[]; appInstallations: GithubAppInstallation[]; oauthGrants: GithubOAuthGrant[]; packages: GithubPackage[]; packageVersions: GithubPackageVersion[]; pagesSites: GithubPagesSite[]; pagesBuilds: GithubPagesBuild[]; codespaces: GithubCodespace[] };
|
|
918
919
|
|
|
919
920
|
// The default Discussion categories every repo starts with (mirrors GitHub's defaults).
|
|
920
921
|
// Only Q&A is answerable. Ids are deterministic (hash of repo+slug) — never random.
|
|
@@ -962,6 +963,7 @@ export function githubState(root?: string): GithubState {
|
|
|
962
963
|
const state = foldGithubState(root);
|
|
963
964
|
for (const value of Object.values(state)) if (Array.isArray(value)) for (const row of value) Object.freeze(row);
|
|
964
965
|
Object.freeze(state.userProfile);
|
|
966
|
+
for (const p of Object.values(state.userProfiles)) Object.freeze(p);
|
|
965
967
|
memos.set(root ?? '', { stamp, state });
|
|
966
968
|
return state;
|
|
967
969
|
}
|
|
@@ -1096,7 +1098,8 @@ function foldGithubState(root?: string): GithubState {
|
|
|
1096
1098
|
const userGpgKeysById = new Map<string, GithubUserGpgKey>();
|
|
1097
1099
|
const deletedUserGpgKeys = new Set<string>();
|
|
1098
1100
|
const blockedByLogin = new Map<string, boolean>();
|
|
1099
|
-
|
|
1101
|
+
// each account's profile as it edited it (PATCH /user), by lowercased login; octocat's is also `userProfile`
|
|
1102
|
+
const userProfiles: Record<string, Partial<GithubUser>> = {};
|
|
1100
1103
|
// Projects v2: projects / fields / items / views — LOCAL constructs keyed by subject id.
|
|
1101
1104
|
const projectsById = new Map<string, GithubProject>();
|
|
1102
1105
|
const deletedProjects = new Set<string>();
|
|
@@ -1778,12 +1781,15 @@ function foldGithubState(root?: string): GithubState {
|
|
|
1778
1781
|
before_sha: String(f.before_sha ?? ''), after_sha: String(f.after_sha ?? ''), pushed_at: String(f.pushed_at ?? ''), result: (f.result ?? 'pass') as GithubRuleSuite['result'],
|
|
1779
1782
|
rules: Array.isArray(f.rules) ? f.rules as GithubRuleSuite['rules'] : [] });
|
|
1780
1783
|
} else if (a.subject.type === 'user_email') {
|
|
1781
|
-
|
|
1784
|
+
// a removed address is gone; added again (by its account or another) it starts afresh
|
|
1785
|
+
if (f.deleted === true) { deletedUserEmails.add(a.subject.id); userEmailsByAddr.delete(a.subject.id); continue; }
|
|
1786
|
+
deletedUserEmails.delete(a.subject.id);
|
|
1782
1787
|
const prev = userEmailsByAddr.get(a.subject.id);
|
|
1783
1788
|
userEmailsByAddr.set(a.subject.id, {
|
|
1784
1789
|
email: String(f.email ?? prev?.email ?? ''), primary: f.primary !== undefined ? Boolean(f.primary) : (prev?.primary ?? false),
|
|
1785
1790
|
verified: f.verified !== undefined ? Boolean(f.verified) : (prev?.verified ?? true),
|
|
1786
1791
|
visibility: f.visibility !== undefined ? (f.visibility as string | null) : (prev?.visibility ?? null),
|
|
1792
|
+
login: typeof f.login === 'string' ? f.login : (prev?.login ?? GITHUB_WORLD_USER),
|
|
1787
1793
|
});
|
|
1788
1794
|
} else if (a.subject.type === 'user_ssh_key') {
|
|
1789
1795
|
if (f.deleted === true) { deletedUserSshKeys.add(a.subject.id); continue; }
|
|
@@ -1805,9 +1811,12 @@ function foldGithubState(root?: string): GithubState {
|
|
|
1805
1811
|
// PUT sets, DELETE clears — boolean per login (last write per subject id wins).
|
|
1806
1812
|
blockedByLogin.set(String(f.login ?? a.subject.id.replace(/^block:/, '')), f.deleted !== true);
|
|
1807
1813
|
} else if (a.subject.type === 'user_profile') {
|
|
1808
|
-
// PATCH /user — overlay the authenticated user's editable profile fields
|
|
1814
|
+
// PATCH /user — overlay the authenticated user's editable profile fields, on that user's own profile (a row
|
|
1815
|
+
// without a login is from before profiles were per account: the World's user's).
|
|
1816
|
+
const owner = (typeof f.login === 'string' ? f.login : a.subject.id === 'user_profile:octocat' ? GITHUB_WORLD_USER : a.subject.id.replace(/^user_profile:/, '')).toLowerCase();
|
|
1817
|
+
const profile = userProfiles[owner] ?? (userProfiles[owner] = {});
|
|
1809
1818
|
for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email'] as const) {
|
|
1810
|
-
if (f[k] !== undefined) (
|
|
1819
|
+
if (f[k] !== undefined) (profile as Record<string, unknown>)[k] = f[k];
|
|
1811
1820
|
}
|
|
1812
1821
|
} else if (a.subject.type === 'project_field' || a.subject.type === 'project_item' || a.subject.type === 'project_view') {
|
|
1813
1822
|
// a project's fields, items and views: the fields as written, the id the subject's
|
|
@@ -2244,7 +2253,8 @@ function foldGithubState(root?: string): GithubState {
|
|
|
2244
2253
|
userSshKeys,
|
|
2245
2254
|
userGpgKeys,
|
|
2246
2255
|
blockedUsers: new Set([...blockedByLogin.entries()].filter(([, on]) => on).map(([k]) => k)),
|
|
2247
|
-
userProfile,
|
|
2256
|
+
userProfile: userProfiles[GITHUB_WORLD_USER] ?? {},
|
|
2257
|
+
userProfiles,
|
|
2248
2258
|
projects,
|
|
2249
2259
|
projectFields,
|
|
2250
2260
|
projectItems,
|
|
@@ -3700,6 +3710,16 @@ export function toGithubRuleset(r: GithubRuleset): Record<string, unknown> {
|
|
|
3700
3710
|
}
|
|
3701
3711
|
|
|
3702
3712
|
// ── User niche serializers: emails / SSH keys / GPG keys ─────────────────────────────────
|
|
3713
|
+
/** An account as GET /user and GET /users/:login answer it: the login's deterministic account, under the profile its
|
|
3714
|
+
* person edited (PATCH /user). Nothing is invented for a person who has not filled it in: GitHub's `name` is null until
|
|
3715
|
+
* they set it. */
|
|
3716
|
+
export function accountOf(state: GithubState, login: string): GithubUser {
|
|
3717
|
+
return { ...seedUser(login), ...(state.userProfiles[login.toLowerCase()] ?? {}), login };
|
|
3718
|
+
}
|
|
3719
|
+
/** The addresses an account holds, primary first (GET /user/emails). */
|
|
3720
|
+
export function emailsOf(state: GithubState, login: string): GithubUserEmail[] {
|
|
3721
|
+
return state.userEmails.filter((e) => e.login.toLowerCase() === login.toLowerCase());
|
|
3722
|
+
}
|
|
3703
3723
|
export function toGithubUserEmail(e: GithubUserEmail): Record<string, unknown> {
|
|
3704
3724
|
return { email: e.email, primary: e.primary, verified: e.verified, visibility: e.visibility };
|
|
3705
3725
|
}
|
|
@@ -4174,6 +4194,61 @@ export async function registerGithubApp(body: Record<string, any>, occurredAt: s
|
|
|
4174
4194
|
return { response: { status: 201, body: toGithubApp(fields, 0, githubState(req.root)) }, app: fields };
|
|
4175
4195
|
}
|
|
4176
4196
|
|
|
4197
|
+
/** Every account login the World already knows, lowercased → as it was first written: the World's user, whoever holds a
|
|
4198
|
+
* token or a password or an address or a profile, owns, collaborates on or is invited to a repository, is an organization
|
|
4199
|
+
* or one of its members. A tombstoned row still names an account that existed. Sign-up refuses these; a request naming one in another
|
|
4200
|
+
* case answers the account as it is spelled. */
|
|
4201
|
+
export function accountLoginsOf(resources: readonly Record<string, unknown>[]): Map<string, string> {
|
|
4202
|
+
const out = new Map<string, string>([[GITHUB_WORLD_USER, GITHUB_WORLD_USER]]);
|
|
4203
|
+
const add = (v: unknown) => { if (typeof v === 'string' && v && !out.has(v.toLowerCase())) out.set(v.toLowerCase(), v); };
|
|
4204
|
+
for (const r of resources) {
|
|
4205
|
+
switch (r.type) {
|
|
4206
|
+
case 'user_credential': case 'user_email': case 'user_profile': case 'user_two_factor': case 'oauth_grant':
|
|
4207
|
+
case 'collaborator': case 'outside_collaborator': case 'org_membership': case 'team_membership': case 'organization':
|
|
4208
|
+
add(r.login); break;
|
|
4209
|
+
case 'repository': add(r.owner); break;
|
|
4210
|
+
// GitHub invites only an account that exists
|
|
4211
|
+
case 'repo_invitation': add(r.invitee); break;
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
return out;
|
|
4215
|
+
}
|
|
4216
|
+
/** A login as the account it names is spelled (GitHub's logins are case-insensitive); a login no account holds as asked. */
|
|
4217
|
+
export function canonicalLogin(login: string, root?: string): string {
|
|
4218
|
+
const store = getActiveWorldStore();
|
|
4219
|
+
const memos = accountMemos.get(store) ?? accountMemos.set(store, new Map()).get(store)!;
|
|
4220
|
+
const stamp = treeStamp(SERVICE, root);
|
|
4221
|
+
let held = memos.get(root ?? '');
|
|
4222
|
+
if (!held || held.stamp !== stamp) { held = { stamp, logins: accountLoginsOf(twinResources(SERVICE, root)) }; memos.set(root ?? '', held); }
|
|
4223
|
+
return held.logins.get(login.toLowerCase()) ?? login;
|
|
4224
|
+
}
|
|
4225
|
+
const accountMemos = new WeakMap<object, Map<string, { stamp: string; logins: Map<string, string> }>>();
|
|
4226
|
+
|
|
4227
|
+
/** A person signs up (POST /_twin/users, standing in for github.com/signup): the account's password and its primary
|
|
4228
|
+
* address, decided against the tree in one step so two sign-ups cannot take the same username or address. */
|
|
4229
|
+
async function signUp(body: Record<string, any>, occurredAt: string, root: string | undefined): Promise<{ response: GithubResponse }> {
|
|
4230
|
+
const login = typeof body.login === 'string' ? body.login : '';
|
|
4231
|
+
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
|
4232
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
4233
|
+
const invalid = (resource: string, field: string, code: string, message?: string) => ({ response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource, field, code, ...(message ? { message } : {}) }] } } });
|
|
4234
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) || /--|-$/.test(login)) return invalid('User', 'login', 'invalid');
|
|
4235
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return invalid('User', 'email', 'invalid');
|
|
4236
|
+
if (!(password.length >= 15 || (password.length >= 8 && /[0-9]/.test(password) && /[a-z]/.test(password)))) return invalid('User', 'password', 'invalid', 'Password is too short or needs a number and a lowercase letter');
|
|
4237
|
+
const lower = login.toLowerCase();
|
|
4238
|
+
// The account is made by its address: the one write that claims both the username (the row names it, so every later
|
|
4239
|
+
// sign-up sees it taken) and the address, decided against the tree it lands on, so of two sign-ups racing for either
|
|
4240
|
+
// only one is made. Its password follows.
|
|
4241
|
+
const { value: taken } = await applyTwinWriteAtomic<'login' | 'email' | undefined>(SERVICE, (resources) => {
|
|
4242
|
+
if (accountLoginsOf(resources).has(lower)) return { kind: 'skip', value: 'login' };
|
|
4243
|
+
if (resources.some((r) => r.deleted !== true && r.type === 'user_email' && String(r.email).toLowerCase() === email.toLowerCase())) return { kind: 'skip', value: 'email' };
|
|
4244
|
+
return { kind: 'write', value: undefined, write: { operation: 'user_email.upsert', subjectType: 'user_email', subjectId: `useremail:${email}`, fields: { email, primary: true, verified: true, visibility: 'private', login }, occurredAt, actor: { kind: 'human', id: login } } };
|
|
4245
|
+
}, root);
|
|
4246
|
+
if (taken === 'login') return invalid('User', 'login', 'already_exists', 'Username is not available');
|
|
4247
|
+
if (taken === 'email') return invalid('User', 'email', 'already_exists', 'Email is invalid or already taken');
|
|
4248
|
+
await applyTwinWrite(SERVICE, { operation: 'user_credential.set', subjectType: 'user_credential', subjectId: `credential:${lower}`, fields: { login, hash: passwordHash(login, password), set_at: occurredAt }, occurredAt, actor: { kind: 'human', id: login } }, root);
|
|
4249
|
+
return { response: { status: 201, body: { login, email } } };
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4177
4252
|
/** The twin's own doors under /_twin/: what stands in for an act GitHub does in its web UI or its runners, which have
|
|
4178
4253
|
* no API. A request to any other path is not a door's (undefined). */
|
|
4179
4254
|
export async function handleGithubDoor(request: Request, scope: { root?: string; now: () => string }): Promise<Response | undefined> {
|
|
@@ -4202,10 +4277,19 @@ export async function handleGithubDoor(request: Request, scope: { root?: string;
|
|
|
4202
4277
|
// {slug, name, owner, permissions, events, setup_url?, callback_url?, request_oauth_on_install?, hook_attributes?: {url},
|
|
4203
4278
|
// webhook_secret?}. It is installed through its page, github.com/apps/{slug}/installations/new (src/screens/app-installation.tsx).
|
|
4204
4279
|
if (seg[1] === 'apps' && !seg[2]) return registerGithubApp(body, occurredAt, req.root);
|
|
4280
|
+
// POST /_twin/users → {login, email, password}: GitHub's sign-up (github.com/signup; GitHub has no API to create an
|
|
4281
|
+
// account). As the page does, it takes a username, an email address and a password, and refuses a username or an
|
|
4282
|
+
// address another account holds and a password GitHub's rule refuses ("at least 15 characters OR at least 8
|
|
4283
|
+
// characters including a number and a lowercase letter", docs.github.com/authentication/keeping-your-account-and-
|
|
4284
|
+
// data-secure/creating-a-strong-password). The account's address is its primary one, verified (GitHub creates the
|
|
4285
|
+
// account only once the launch code it mails there is entered; that step is not modelled) and private, so
|
|
4286
|
+
// GET /user/emails lists it and GET /user's public `email` stays null. The person signs in at github.com/login;
|
|
4287
|
+
// their name and the rest of their profile are theirs to set (PATCH /user), empty until they do.
|
|
4288
|
+
if (seg[1] === 'users' && !seg[2]) return signUp(body, occurredAt, req.root);
|
|
4205
4289
|
// POST /_twin/users/:login/tokens → a personal access token for that user (GitHub mints these in its settings UI,
|
|
4206
4290
|
// which has no API). Requests carrying it are that user's: writes are attributed to them and GET /user names them.
|
|
4207
4291
|
if (seg[1] === 'users' && seg[2] && seg[3] === 'tokens' && !seg[4]) {
|
|
4208
|
-
const login = decodeURIComponent(seg[2]);
|
|
4292
|
+
const login = canonicalLogin(decodeURIComponent(seg[2]), req.root);
|
|
4209
4293
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login)) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: 'login', code: 'invalid' }] } } };
|
|
4210
4294
|
const token = `ghp_${createHash('sha256').update(`pat:${login}:${nextSeq('pat', req.root)}`).digest('hex').slice(0, 36)}`;
|
|
4211
4295
|
const scopes = Array.isArray(body.scopes) ? body.scopes.map(String) : ['repo', 'read:org'];
|
|
@@ -4213,10 +4297,10 @@ export async function handleGithubDoor(request: Request, scope: { root?: string;
|
|
|
4213
4297
|
return { response: { status: 201, body: { token, login, scopes } } };
|
|
4214
4298
|
}
|
|
4215
4299
|
// POST /_twin/users/:login/password → {password}: the password the World gives a person, with which they sign in at
|
|
4216
|
-
// github.com/login (src/screens/session.tsx). GitHub's sign-up
|
|
4217
|
-
// the password's hash, never the password.
|
|
4300
|
+
// github.com/login (src/screens/session.tsx). It stands in for GitHub's password page (sign-up is POST /_twin/users
|
|
4301
|
+
// above); the World keeps the password's hash, never the password.
|
|
4218
4302
|
if (seg[1] === 'users' && seg[2] && seg[3] === 'password' && !seg[4]) {
|
|
4219
|
-
const login = decodeURIComponent(seg[2]);
|
|
4303
|
+
const login = canonicalLogin(decodeURIComponent(seg[2]), req.root);
|
|
4220
4304
|
const password = typeof body.password === 'string' ? body.password : '';
|
|
4221
4305
|
if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) || password.length < 8) return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: password.length < 8 ? 'password' : 'login', code: 'invalid' }] } } };
|
|
4222
4306
|
await applyTwinWrite(SERVICE, { operation: 'user_credential.set', subjectType: 'user_credential', subjectId: `credential:${login.toLowerCase()}`, fields: { login, hash: passwordHash(login, password), set_at: occurredAt }, occurredAt, actor: { kind: 'agent' } }, req.root);
|
package/src/index.ts
CHANGED
|
@@ -118,6 +118,9 @@ export const pack: TwinPack = {
|
|
|
118
118
|
{ host: 'uploads.github.com', pathPattern: '^/repos/[^/]+/[^/]+/releases/\\d+/assets$' },
|
|
119
119
|
{ host: 'github.com', pathPattern: '^/[^/]+/[^/]+/(info/refs|git-upload-pack|git-receive-pack)$' },
|
|
120
120
|
{ host: 'github.com', pathPattern: '^/login/oauth/(authorize|access_token)$' },
|
|
121
|
+
// the sign-in the consent and the other pages send a visitor who is not signed in to, and its two-factor step
|
|
122
|
+
// (src/screens/session.tsx); without these a browser following the redirect leaves the World
|
|
123
|
+
{ host: 'github.com', pathPattern: '^/(login|session|sessions/two-factor(/app)?)$' },
|
|
121
124
|
{ host: 'github.com', pathPattern: '^/apps/[^/]+/installations/new$' },
|
|
122
125
|
{ host: 'github.com', pathPattern: '^/(organizations/[^/]+/)?settings/apps/new$' },
|
|
123
126
|
{ host: 'npm.pkg.github.com', pathPattern: '^/@' },
|
package/src/screens/session.tsx
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// (docs.github.com/authentication/securing-your-account-with-two-factor-authentication-2fa/about-mandatory-two-factor-
|
|
14
14
|
// authentication) is the person's to turn on in the World; the twin does not enrol anyone.
|
|
15
15
|
//
|
|
16
|
-
// Where GitHub's documentation stops and the twin decides: a person's password is
|
|
17
|
-
// (`POST /_twin/users
|
|
16
|
+
// Where GitHub's documentation stops and the twin decides: a person's password is the one they signed up with
|
|
17
|
+
// (`POST /_twin/users`, standing in for github.com/signup) or the World gave them (`POST /_twin/users/:login/password`;
|
|
18
|
+
// GitHub's password page is not modelled), an account is named by
|
|
18
19
|
// its login (not its email), a refused sign-in shows the form again with GitHub's message (answered 200), a code is
|
|
19
20
|
// taken for the current 30-second step or the one before (RFC 6238's one step of drift) and never twice, a pending
|
|
20
21
|
// two-factor sign-in does not expire, and a session does not end: no person of a World signs out yet, and signing out is built when one does.
|
|
@@ -53,8 +54,15 @@ export function toSignIn(request: Request, suggested?: string, page?: string): R
|
|
|
53
54
|
return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(page ?? url.pathname + url.search)}${suggested ? `&login=${encodeURIComponent(suggested)}` : ''}` } });
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
/** A return_to GitHub follows: a path on github.com, never another site.
|
|
57
|
-
|
|
57
|
+
/** A return_to GitHub follows: a path on github.com, never another site. Resolved as a browser resolves it (a backslash
|
|
58
|
+
* or a control character can turn `/\evil.test` into another origin), and kept only when it stays on github.com. */
|
|
59
|
+
function returnTo(raw: string | undefined): string {
|
|
60
|
+
if (!raw || !raw.startsWith('/') || /[\u0000-\u001f\\]/.test(raw)) return '/';
|
|
61
|
+
try {
|
|
62
|
+
const url = new URL(raw, 'https://github.com');
|
|
63
|
+
return url.origin === 'https://github.com' ? url.pathname + url.search : '/';
|
|
64
|
+
} catch { return '/'; }
|
|
65
|
+
}
|
|
58
66
|
|
|
59
67
|
function page(fields: { return_to: string; login?: string }, error?: string): Response {
|
|
60
68
|
return flowPage({
|
package/src/semantics/graphql.ts
CHANGED
|
@@ -284,7 +284,12 @@ export function githubGraphql(): { resolvers: Record<string, FieldResolver>; sto
|
|
|
284
284
|
};
|
|
285
285
|
return {
|
|
286
286
|
resolvers: {
|
|
287
|
-
'
|
|
287
|
+
// the token's own account; the World's shared token is octocat's, named The Octocat until its profile says otherwise
|
|
288
|
+
'Query.viewer': (_s, _args, c) => {
|
|
289
|
+
const login = gh(ctxOf(c)).caller ?? GITHUB_WORLD_USER;
|
|
290
|
+
const name = state(c).userProfiles[login.toLowerCase()]?.name;
|
|
291
|
+
return { __typename: 'User', login, name: name ?? (login === GITHUB_WORLD_USER ? 'The Octocat' : null) };
|
|
292
|
+
},
|
|
288
293
|
'Query.node': queryNode,
|
|
289
294
|
'Query.repository': (_s, args, c) => {
|
|
290
295
|
const repo = `${String(args.owner)}/${String(args.name)}`;
|
|
@@ -419,7 +424,7 @@ export function githubGraphql(): { resolvers: Record<string, FieldResolver>; sto
|
|
|
419
424
|
const login = String(args.login ?? '');
|
|
420
425
|
const st = state(c);
|
|
421
426
|
const known = login === GITHUB_WORLD_USER || st.oauthGrants.some((g) => g.login === login) || st.orgMembers.some((m) => m.login === login) || st.collaborators.some((x) => x.login === login) || st.repos.some((r) => r.owner === login);
|
|
422
|
-
return known ? { __typename: 'User', login, name: null, id: `U_${login}` } : fail(`Could not resolve to a User with the login of '${login}'.`, 'NOT_FOUND');
|
|
427
|
+
return known ? { __typename: 'User', login, name: st.userProfiles[login.toLowerCase()]?.name ?? null, id: `U_${login}` } : fail(`Could not resolve to a User with the login of '${login}'.`, 'NOT_FOUND');
|
|
423
428
|
},
|
|
424
429
|
'Query.organization': (_s, args, c) => {
|
|
425
430
|
const org = state(c).orgs.find((o) => o.login === String(args.login ?? ''));
|
package/src/semantics/shared.ts
CHANGED
|
@@ -176,6 +176,6 @@ export const writer = (ctx: SemanticsContext): GithubWrite => (w) => put(ctx, w)
|
|
|
176
176
|
* the noreply address GitHub gives an account that keeps its email private. */
|
|
177
177
|
export function identityOf(g: Gh): { name: string; email: string } {
|
|
178
178
|
const login = g.caller ?? 'octocat';
|
|
179
|
-
const profile =
|
|
179
|
+
const profile = g.state().userProfiles[login.toLowerCase()];
|
|
180
180
|
return { name: String(profile?.name ?? login), email: `${twinUserId(login)}+${login}@users.noreply.github.com` };
|
|
181
181
|
}
|
package/src/semantics/users.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
// GitHub's users family: a user by login or account id, the authenticated user and their profile,
|
|
2
2
|
// the follow graph, emails, SSH and GPG keys, blocks, and the activity streams scoped to a user.
|
|
3
|
-
//
|
|
3
|
+
// Each account's profile and email addresses are its own: GET /user, PATCH /user and /user/emails answer and change the
|
|
4
|
+
// account the request's token names (a personal token, or the token an OAuth app got from the person who consented,
|
|
5
|
+
// src/screens/oauth.tsx); the World's shared token is octocat's. The follow graph is per account too. SSH and GPG keys
|
|
6
|
+
// and blocks are still ONE list whoever asks (todo rows github.users.own_keys, github.users.own_blocks).
|
|
4
7
|
import { me } from './activity.ts';
|
|
5
8
|
import { createHash } from 'node:crypto';
|
|
6
9
|
import type { Semantics } from '@volter/world-core';
|
|
7
10
|
import {
|
|
8
|
-
githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId,
|
|
11
|
+
accountOf, canonicalLogin, emailsOf, githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId,
|
|
9
12
|
} from '../github-twin.ts';
|
|
10
|
-
import { asView, gh, nextSeq, put, send } from './shared.ts';
|
|
13
|
+
import { asView, atomic, gh, nextSeq, put, send } from './shared.ts';
|
|
14
|
+
|
|
15
|
+
/** An app's installation token speaks for the app, which has no profile or addresses of its own to change: GitHub
|
|
16
|
+
* answers its writes to the authenticated user 403 "Resource not accessible by integration". */
|
|
17
|
+
const asApp = (ctx: Parameters<Semantics>[0]): Response | undefined =>
|
|
18
|
+
(gh(ctx).caller ?? '').endsWith('[bot]') ? ctx.reply({ message: 'Resource not accessible by integration', documentation_url: 'https://docs.github.com/rest/users/users#update-the-authenticated-user' }, 403) : undefined;
|
|
11
19
|
|
|
12
20
|
/** GET /users/:login/events[/public] and /users/:login/received_events[/public]: events = activity the
|
|
13
21
|
* user PERFORMED (actor); received_events = activity in repos the user OWNS. Both filter the SAME
|
|
@@ -26,8 +34,9 @@ const userEvents = (received: boolean, only: 'public' | undefined): Semantics =>
|
|
|
26
34
|
export function usersSemantics(): Record<string, Semantics> {
|
|
27
35
|
const usersGetByUsername: Semantics = async (ctx) => {
|
|
28
36
|
const { seg, state } = gh(ctx);
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
// a login in another case names the same account, answered as it is spelled (and with its id)
|
|
38
|
+
const login = canonicalLogin(decodeURIComponent(seg[1]!), ctx.root);
|
|
39
|
+
return ctx.reply(toGithubUser(accountOf(state(), login)));
|
|
31
40
|
};
|
|
32
41
|
const reposListForAuthenticatedUser: Semantics = async (ctx) => {
|
|
33
42
|
const { caller, query, state } = gh(ctx);
|
|
@@ -94,11 +103,11 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
94
103
|
};
|
|
95
104
|
const usersListEmailsForAuthenticatedUser: Semantics = async (ctx) => {
|
|
96
105
|
const { query, state } = gh(ctx);
|
|
97
|
-
return ctx.reply(paginate(state()
|
|
106
|
+
return ctx.reply(paginate(emailsOf(state(), me(ctx)), query).map(toGithubUserEmail));
|
|
98
107
|
};
|
|
99
108
|
const usersListPublicEmailsForAuthenticatedUser: Semantics = async (ctx) => {
|
|
100
109
|
const { query, state } = gh(ctx);
|
|
101
|
-
return ctx.reply(paginate(state().
|
|
110
|
+
return ctx.reply(paginate(emailsOf(state(), me(ctx)).filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
|
|
102
111
|
};
|
|
103
112
|
const usersListPublicSshKeysForAuthenticatedUser: Semantics = async (ctx) => {
|
|
104
113
|
const { query, state } = gh(ctx);
|
|
@@ -127,16 +136,25 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
127
136
|
return send(ctx, state().blockedUsers.has(decodeURIComponent(seg[2]!)) ? { status: 204, body: undefined } : githubNotFound());
|
|
128
137
|
};
|
|
129
138
|
const usersGetAuthenticated: Semantics = async (ctx) => {
|
|
130
|
-
const {
|
|
131
|
-
|
|
132
|
-
return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
|
|
139
|
+
const { state } = gh(ctx);
|
|
140
|
+
return ctx.reply(toGithubUser(accountOf(state(), me(ctx))));
|
|
133
141
|
};
|
|
134
142
|
const usersUpdateAuthenticated: Semantics = async (ctx) => {
|
|
143
|
+
const refused = asApp(ctx);
|
|
144
|
+
if (refused) return refused;
|
|
135
145
|
const { body, state } = gh(ctx);
|
|
136
146
|
const fields: Record<string, unknown> = {};
|
|
137
147
|
for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email'] as const) if (body[k] !== undefined) fields[k] = body[k];
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
const login = me(ctx);
|
|
149
|
+
// the public email is one of the account's own verified addresses (GitHub's profile settings offer only those; the
|
|
150
|
+
// REST docs name the field "The publicly visible email address of the user" and a 422 for a validation failure);
|
|
151
|
+
// empty or null shows none. The 422's wording is the twin's.
|
|
152
|
+
if (typeof fields.email === 'string' && fields.email !== '' && !emailsOf(state(), login).some((e) => e.verified && e.email.toLowerCase() === String(fields.email).toLowerCase())) {
|
|
153
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'User', code: 'custom', field: 'email', message: 'email must be one of your verified email addresses' }], documentation_url: 'https://docs.github.com/rest/users/users#update-the-authenticated-user' }, 422);
|
|
154
|
+
}
|
|
155
|
+
if (fields.email !== undefined && fields.email !== null && typeof fields.email !== 'string') return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'User', code: 'invalid', field: 'email' }] }, 422);
|
|
156
|
+
await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: `user_profile:${login.toLowerCase()}`, fields: { ...fields, login } });
|
|
157
|
+
return ctx.reply(toGithubUser(accountOf(state(), login)));
|
|
140
158
|
};
|
|
141
159
|
const usersCreatePublicSshKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
142
160
|
const { body, occurredAt, state } = gh(ctx);
|
|
@@ -201,11 +219,12 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
201
219
|
'users/list-blocked-by-authenticated-user': usersListBlockedByAuthenticatedUser,
|
|
202
220
|
// "Check if a user is blocked by the authenticated user" → 204 blocked / 404 not.
|
|
203
221
|
'users/check-blocked': usersCheckBlocked,
|
|
204
|
-
// the token's own
|
|
222
|
+
// the token's own account (a personal token, an OAuth app's token for the person who consented) under the
|
|
223
|
+
// profile that person edited; the World's shared token is octocat's
|
|
205
224
|
'users/get-authenticated': usersGetAuthenticated,
|
|
206
225
|
'users/update-authenticated': usersUpdateAuthenticated,
|
|
207
|
-
'users/add-email-for-authenticated-user': async (ctx) => emails(ctx, false),
|
|
208
|
-
'users/delete-email-for-authenticated-user': async (ctx) => emails(ctx, true),
|
|
226
|
+
'users/add-email-for-authenticated-user': async (ctx) => asApp(ctx) ?? emails(ctx, false),
|
|
227
|
+
'users/delete-email-for-authenticated-user': async (ctx) => asApp(ctx) ?? emails(ctx, true),
|
|
209
228
|
'users/create-public-ssh-key-for-authenticated-user': usersCreatePublicSshKeyForAuthenticatedUser,
|
|
210
229
|
'users/delete-public-ssh-key-for-authenticated-user': usersDeletePublicSshKeyForAuthenticatedUser,
|
|
211
230
|
'users/create-gpg-key-for-authenticated-user': usersCreateGpgKeyForAuthenticatedUser,
|
|
@@ -219,24 +238,45 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
219
238
|
};
|
|
220
239
|
}
|
|
221
240
|
|
|
222
|
-
/** POST /user/emails adds, DELETE removes; both take `{emails}` or a bare array. The
|
|
223
|
-
*
|
|
241
|
+
/** POST /user/emails adds, DELETE removes, on the caller's own account; both take `{emails}` or a bare array. The
|
|
242
|
+
* first address an account adds is primary and public. An address is one account's: adding one another account holds
|
|
243
|
+
* is refused (422, the twin's wording: GitHub's docs name only the status), each address checked and claimed in one
|
|
244
|
+
* atomic step, and removing one leaves every other account's addresses alone. The primary address is not removed
|
|
245
|
+
* (422): GitHub has its person make another address primary first (github.com/orgs/community/discussions/62557; the
|
|
246
|
+
* wording is the twin's). */
|
|
224
247
|
async function emails(ctx: Parameters<Semantics>[0], remove: boolean): Promise<Response> {
|
|
225
248
|
const { body, state } = gh(ctx);
|
|
249
|
+
const login = me(ctx);
|
|
226
250
|
const list: string[] = Array.isArray(body.emails) ? body.emails.map(String) : Array.isArray(body) ? (body as string[]).map(String) : [];
|
|
227
251
|
if (!list.length) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Email', field: 'emails', code: 'missing_field' }] }, 422);
|
|
228
252
|
const st = state();
|
|
253
|
+
const mine = (e: { login: string }) => e.login.toLowerCase() === login.toLowerCase();
|
|
254
|
+
const held = (email: string) => st.userEmails.find((e) => e.email.toLowerCase() === email.toLowerCase());
|
|
255
|
+
if (!remove && list.some((email) => { const h = held(email); return h && !mine(h); })) {
|
|
256
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'email is already in use' }] }, 422);
|
|
257
|
+
}
|
|
258
|
+
if (remove && list.some((email) => { const h = held(email); return h && mine(h) && h.primary; })) {
|
|
259
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'the primary email address cannot be removed' }] }, 422);
|
|
260
|
+
}
|
|
261
|
+
const hadNone = emailsOf(st, login).length === 0;
|
|
262
|
+
const claim = atomic(ctx);
|
|
229
263
|
for (const email of list) {
|
|
230
|
-
const
|
|
264
|
+
const h = held(email);
|
|
265
|
+
const subjectId = `useremail:${h?.email ?? email}`;
|
|
231
266
|
if (remove) {
|
|
232
|
-
await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email } });
|
|
233
|
-
} else {
|
|
234
|
-
const isFirst =
|
|
235
|
-
|
|
267
|
+
if (h && mine(h)) await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email: h.email } });
|
|
268
|
+
} else if (!h) {
|
|
269
|
+
const isFirst = hadNone && email === list[0];
|
|
270
|
+
const lost = await claim(() => {
|
|
271
|
+
const now = state().userEmails.find((e) => e.email.toLowerCase() === email.toLowerCase());
|
|
272
|
+
if (now) return { value: !mine(now) };
|
|
273
|
+
return { value: false, write: { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private', login } } };
|
|
274
|
+
});
|
|
275
|
+
if (lost) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'email is already in use' }] }, 422);
|
|
236
276
|
}
|
|
237
277
|
}
|
|
238
278
|
if (remove) return send(ctx, { status: 204, body: undefined });
|
|
239
|
-
return ctx.reply(state().
|
|
279
|
+
return ctx.reply(emailsOf(state(), login).map(toGithubUserEmail), 201);
|
|
240
280
|
}
|
|
241
281
|
|
|
242
282
|
async function block(ctx: Parameters<Semantics>[0], blocking: boolean): Promise<Response> {
|