@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.
@@ -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 authenticated-user EMAIL (list/add/delete + primary/visibility). LOCAL, email-keyed.
818
- export type GithubUserEmail = { email: string; primary: boolean; verified: boolean; visibility: string | null };
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
- const userProfile: Partial<GithubUser> = {};
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
- if (f.deleted === true) { deletedUserEmails.add(a.subject.id); continue; }
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) (userProfile as Record<string, unknown>)[k] = f[k];
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 and password pages are not modelled; the World keeps
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: '^/@' },
@@ -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 what the World gave them
17
- // (`POST /_twin/users/:login/password`; GitHub's sign-up and password pages are not modelled), an account is named by
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
- const returnTo = (raw: string | undefined): string => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
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({
@@ -284,7 +284,12 @@ export function githubGraphql(): { resolvers: Record<string, FieldResolver>; sto
284
284
  };
285
285
  return {
286
286
  resolvers: {
287
- 'Query.viewer': (_s, _args, c) => ({ __typename: 'User', login: 'octocat', name: state(c).userProfile.name ?? 'The Octocat' }),
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 ?? ''));
@@ -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 = login === 'octocat' ? g.state().userProfile : undefined;
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
  }
@@ -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
- // The twin models one follow actor, the authenticated `octocat`.
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
- const login = decodeURIComponent(seg[1]!);
30
- return ctx.reply(toGithubUser(login === 'octocat' ? { ...seedUser('octocat'), ...state().userProfile, login } : seedUser(login)));
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().userEmails, query).map(toGithubUserEmail));
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().userEmails.filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
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 { caller, state } = gh(ctx);
131
- if (caller) return ctx.reply(toGithubUser(seedUser(caller)));
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
- await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: 'user_profile:octocat', fields });
139
- return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
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 user when the twin's user door minted it; else the deterministic octocat
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 first address a
223
- * user adds is primary and public. */
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 subjectId = `useremail:${email}`;
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 = st.userEmails.length === 0 && email === list[0];
235
- await put(ctx, { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private' } });
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().userEmails.map(toGithubUserEmail), 201);
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> {