@volter/twin-github 2.0.1 → 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.
@@ -2723,6 +2723,120 @@ export const GITHUB_CAPABILITIES = [
2723
2723
  const after = await handleGithubRequest({ method: 'GET', path: `/user/emails`, root });
2724
2724
  return del.response.status === 204 && isArr(after.body) && after.body.length === 1;
2725
2725
  })),
2726
+ done('github.users.sign_up', 'users', 'Users: a person signs up (username, email, password) and signs in; a username or address another account holds, or a password GitHub\'s rule refuses, is refused', 'api', 'common', () => withRoot(async (root) => {
2727
+ const signUp = async (body) => (await applyGithubWrite({ method: 'POST', path: '/_twin/users', body: JSON.stringify(body), root })).response;
2728
+ const made = await signUp({ login: 'sam-friends', email: 'sam@friends.test', password: 'correct-horse-1' });
2729
+ if (!(made.status === 201 && made.body.login === 'sam-friends'))
2730
+ return false;
2731
+ for (const refused of [
2732
+ { login: 'Sam-Friends', email: 'other@friends.test', password: 'correct-horse-1' }, // the username, any case
2733
+ { login: 'octocat', email: 'o@friends.test', password: 'correct-horse-1' }, // the World's user
2734
+ { login: 'ada', email: 'SAM@friends.test', password: 'correct-horse-1' }, // the address, any case
2735
+ { login: 'ada', email: 'ada@friends.test', password: 'short1' }, // under 8
2736
+ { login: 'ada', email: 'ada@friends.test', password: 'NOLOWERCASE123' }, // 8 to 14 without a lowercase letter
2737
+ { login: 'ada', email: 'not-an-address', password: 'correct-horse-1' },
2738
+ { login: '-ada', email: 'ada@friends.test', password: 'correct-horse-1' },
2739
+ ])
2740
+ if ((await signUp(refused)).status !== 422)
2741
+ return false;
2742
+ if ((await signUp({ login: 'ada', email: 'ada@friends.test', password: 'fifteen chars ok' })).status !== 201)
2743
+ return false;
2744
+ // a username the World already knows is taken, whatever named it: a token holder, a repository's owner or collaborator
2745
+ await mintToken(root, 'grace');
2746
+ await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'shared' }), root });
2747
+ await applyGithubWrite({ method: 'PUT', path: '/repos/octocat/shared/collaborators/rui', body: '{}', root });
2748
+ for (const login of ['Grace', 'rui'])
2749
+ if ((await signUp({ login, email: `${login}@friends.test`, password: 'correct-horse-1' })).status !== 422)
2750
+ return false;
2751
+ // two sign-ups racing for one address: one account is made, the other refused, and the address is the made one's
2752
+ const raced = await Promise.all([signUp({ login: 'kim', email: 'shared@friends.test', password: 'correct-horse-1' }), signUp({ login: 'lee', email: 'shared@friends.test', password: 'correct-horse-1' })]);
2753
+ const winner = raced.find((r) => r.status === 201)?.body;
2754
+ if (raced.filter((r) => r.status === 201).length !== 1 || raced.filter((r) => r.status === 422).length !== 1)
2755
+ return false;
2756
+ const holders = twinResources('github', root).filter((r) => r.type === 'user_email' && r.email === 'shared@friends.test' && r.deleted !== true);
2757
+ if (!(holders.length === 1 && holders[0].login === winner?.login))
2758
+ return false;
2759
+ // the password signs its person in; a wrong one does not
2760
+ const form = (login, password) => githubSessionFlow({ root, now: () => '2026-01-01T00:00:00.000Z' })(new Request('https://github.com/session', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ login, password, return_to: '/' }).toString() }));
2761
+ const wrong = await form('sam-friends', 'wrong-horse-1');
2762
+ const right = await form('sam-friends', 'correct-horse-1');
2763
+ return wrong?.status === 200 && right?.status === 302 && (right.headers.getSetCookie() ?? []).some((c) => c.startsWith('dotcom_user=sam-friends;'));
2764
+ })),
2765
+ done('github.users.oauth_person', 'users', 'Users: the token an OAuth app gets from the person who signed in and consented answers that person\'s account: GET /user their login, name and avatar, GET /user/emails their own addresses; the World\'s shared token stays octocat\'s', 'api', 'common', () => withRoot(async (root) => {
2766
+ const at = '2026-01-01T00:00:00.000Z';
2767
+ // octocat's own address, from before anyone signed up
2768
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['octocat@github.test'] }), root });
2769
+ await applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ name: 'The Octocat' }), root });
2770
+ const tokenFor = async (login, email) => {
2771
+ await applyGithubWrite({ method: 'POST', path: '/_twin/users', body: JSON.stringify({ login, email, password: 'correct-horse-1' }), root });
2772
+ const signed = await githubSessionFlow({ root, now: () => at })(new Request('https://github.com/session', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ login, password: 'correct-horse-1', return_to: '/' }).toString() }));
2773
+ const cookie = (signed?.headers.getSetCookie() ?? []).map((c) => c.split(';')[0]).join('; ');
2774
+ const consent = { client_id: 'librechat', redirect_uri: 'http://chat.local/oauth/github/callback', scope: 'user:email', state: login };
2775
+ if ((await applyGithubWrite({ method: 'POST', path: '/login/oauth/authorize', body: JSON.stringify({ ...consent, authorize: '1' }), root, cookie })).response.status !== 302)
2776
+ return '';
2777
+ const code = String(twinResources('github', root).find((r) => r.type === 'oauth_code' && r.login === login)?.code ?? '');
2778
+ const ex = await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'librechat', code }), root });
2779
+ return new URLSearchParams(String(ex.response.body)).get('access_token') ?? '';
2780
+ };
2781
+ const sam = await tokenFor('sam-friends', 'sam@friends.test');
2782
+ const ada = await tokenFor('ada', 'ada@friends.test');
2783
+ if (!sam || !ada)
2784
+ return false;
2785
+ const user = async (token) => (await handleGithubRequest({ method: 'GET', path: '/user', root, ...(token ? { token } : {}) })).body;
2786
+ const emails = async (token) => (await handleGithubRequest({ method: 'GET', path: '/user/emails', root, ...(token ? { token } : {}) })).body;
2787
+ // before he names himself, GitHub has no name for him
2788
+ const before = await user(sam);
2789
+ if (!(before.login === 'sam-friends' && before.name === null && before.avatar_url === `https://avatars.githubusercontent.com/u/${before.id}?v=4`))
2790
+ return false;
2791
+ if ((await applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ name: 'Sam Friends' }), root, token: sam })).response.status !== 200)
2792
+ return false;
2793
+ const [s, a, o] = [await user(sam), await user(ada), await user()];
2794
+ if (!(s.login === 'sam-friends' && s.name === 'Sam Friends' && a.login === 'ada' && a.name === null && o.login === 'octocat' && o.name === 'The Octocat'))
2795
+ return false;
2796
+ if ((await handleGithubRequest({ method: 'GET', path: '/users/sam-friends', root })).body.name !== 'Sam Friends')
2797
+ return false;
2798
+ // a login in another case is the same account, as it is spelled
2799
+ const upper = (await handleGithubRequest({ method: 'GET', path: '/users/SAM-FRIENDS', root })).body;
2800
+ if (!(upper.login === 'sam-friends' && upper.id === s.id && upper.name === 'Sam Friends'))
2801
+ return false;
2802
+ // his public email is one of his own verified addresses, never another's
2803
+ const pub = (email, token) => applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ email }), root, token });
2804
+ if ((await pub('ada@friends.test', sam)).response.status !== 422 || (await pub('nobody@friends.test', sam)).response.status !== 422)
2805
+ return false;
2806
+ if (!((await pub('sam@friends.test', sam)).response.status === 200 && (await user(sam)).email === 'sam@friends.test'))
2807
+ return false;
2808
+ // his primary address is not removed while it is primary
2809
+ if ((await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['sam@friends.test'] }), root, token: sam })).response.status !== 422)
2810
+ return false;
2811
+ const [se, oe] = [await emails(sam), await emails()];
2812
+ if (!(se.length === 1 && se[0].email === 'sam@friends.test' && se[0].primary && se[0].verified))
2813
+ return false;
2814
+ if (!(oe.length === 1 && oe[0].email === 'octocat@github.test'))
2815
+ return false;
2816
+ // an address is one account's: Ada cannot add Sam's, and removing it as Ada leaves it his
2817
+ if ((await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['sam@friends.test'] }), root, token: ada })).response.status !== 422)
2818
+ return false;
2819
+ await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['sam@friends.test'] }), root, token: ada });
2820
+ if ((await emails(sam)).length !== 1)
2821
+ return false;
2822
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['sam2@friends.test'] }), root, token: sam });
2823
+ // Sam removes his second address; Ada adds it afresh, and it is hers alone (not primary: her first address is)
2824
+ await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['sam2@friends.test'] }), root, token: sam });
2825
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['sam2@friends.test'] }), root, token: ada });
2826
+ const [se2, ae2] = [await emails(sam), await emails(ada)];
2827
+ if (!(se2.length === 1 && ae2.length === 2 && ae2.some((e) => e.email === 'sam2@friends.test' && !e.primary) && ae2.some((e) => e.email === 'ada@friends.test' && e.primary)))
2828
+ return false;
2829
+ // an app's installation token has no profile or addresses to change
2830
+ const app = await ciToken(root, HISTORY_REPO);
2831
+ const byApp = [
2832
+ await applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ name: 'bot' }), root, token: app }),
2833
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['bot@friends.test'] }), root, token: app }),
2834
+ await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['octocat@github.test'] }), root, token: app }),
2835
+ ];
2836
+ return byApp.every((r) => r.response.status === 403 && r.response.body.message === 'Resource not accessible by integration') && (await emails()).length === 1;
2837
+ })),
2838
+ todo('github.users.own_keys', 'users', 'Users: each account its own SSH and GPG keys (today one list, whoever asks)', 'api', 'niche'),
2839
+ todo('github.users.own_blocks', 'users', 'Users: each account its own blocked users (today one list, whoever asks)', 'api', 'niche'),
2726
2840
  done('github.user.keys', 'users', 'User: SSH + GPG keys CRUD', 'api', 'niche', () => withRoot(async (root) => {
2727
2841
  // SSH: missing key 422s; create → 201; get/list; delete → 404. GPG: armored key required.
2728
2842
  if ((await applyGithubWrite({ method: 'POST', path: `/user/keys`, body: JSON.stringify({ title: 'x' }), root })).response.status !== 422)
@@ -1,7 +1,7 @@
1
1
  // Native repository hooks and delivery history live in the World's existing state.
2
2
  // Callback HTTP is the caller's webhook sink, never a credentialed GitHub API call.
3
3
  import { createHash, createHmac } from 'node:crypto';
4
- import { applyTwinWrite, applyTwinWriteAtomic, ownFields, twinResources, worldNow } from '@volter/world-core';
4
+ import { applyTwinWrite, applyTwinWriteAtomic, ownFields, twinResources, worldNow, deliveryTraceHeaders } from '@volter/world-core';
5
5
  import { GITHUB_WORLD_USER, githubApps, githubState, handleGithubRequest, toGithubWebhook } from "./github-twin.js";
6
6
  import { appDestination, appFetch } from '@volter/world-core/app-route';
7
7
  import { worldEgressRefusal } from '@volter/world-core/network-policy';
@@ -15,7 +15,7 @@ const httpDelivery = async (url, _event, _payload, headers, body) => {
15
15
  const refusal = appDestination(url) ? null : worldEgressRefusal(url);
16
16
  if (refusal !== null)
17
17
  throw new Error(refusal);
18
- const response = await appFetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
18
+ const response = await appFetch(url, { method: 'POST', headers: { ...headers, ...deliveryTraceHeaders() }, body, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
19
19
  return { status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() };
20
20
  };
21
21
  async function nativeObject(path, root) {
@@ -8,11 +8,11 @@
8
8
  // real GitHub client speaks, so the mirror renders a twin or a real account unchanged,
9
9
  // pointed at any origin by configuration. The client is the shared world UI-mirror design.
10
10
  import { readFile } from 'node:fs/promises';
11
- import { bundleClient, fileResponse } from '@volter/world-core';
11
+ import { bundleClient, fileResponse, filePathOf } from '@volter/world-core';
12
12
  import { serveHttp } from '@volter/world-core';
13
13
  import { createGithubTwinFetch } from "./github-server.js";
14
- const CLIENT_ENTRY = () => new URL('../client/github-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
15
- const CLIENT_CSS = () => new URL('../client/github-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
14
+ const CLIENT_ENTRY = () => filePathOf(new URL('../client/github-mirror.tsx', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
15
+ const CLIENT_CSS = () => filePathOf(new URL('../client/github-mirror.css', import.meta.url)); // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
16
16
  const APP_SHELL = `<!doctype html>
17
17
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
18
18
  <base href="/"><title>GitHub UI mirror (twin)</title><link rel="stylesheet" href="assets/styles.css"></head>
@@ -6,7 +6,7 @@
6
6
  // FIRST — its routes (/:owner/:repo/info/refs, git-upload-pack, git-receive-pack) are
7
7
  // binary pkt-line streams over the kernel's git library (github-git-http.ts); a push is
8
8
  // announced as an API commit's is (semantics/shared.ts announce).
9
- import { bindSemantics, compileSurface, coreFor, createDerivedFetch, crossCutting, matchOperation, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
9
+ import { bindSemantics, compileSurface, coreFor, createDerivedFetch, crossCutting, derivedRequestScopes, matchOperation, semanticsContext, serveHttp, vendorError } from '@volter/world-core';
10
10
  import surface from './generated/surface.gen.json' with { type: 'json' };
11
11
  import { manifest, spanning } from "./manifest.js";
12
12
  import { githubSemantics } from "./semantics/index.js";
@@ -14,7 +14,7 @@ import { githubGraphql } from "./semantics/graphql.js";
14
14
  import { createGraphqlWire } from "./graphql-wire.js";
15
15
  import { githubState, handleGithubDoor, revokedToken } from "./github-twin.js";
16
16
  import { announce } from "./semantics/shared.js";
17
- import { answerVendorErrors, RefusedWriteError } from '@volter/world-core';
17
+ import { answerVendorErrors, isReadOnlyRequest, RefusedWriteError } from '@volter/world-core';
18
18
  import { worldNow, statefulTwinManifest } from '@volter/world-core';
19
19
  import { createHash } from 'node:crypto';
20
20
  import { githubOAuthFlow } from "./screens/oauth.js";
@@ -99,7 +99,8 @@ export function createGithubTwinFetch(options) {
99
99
  query: url.searchParams,
100
100
  ...(request.method === 'POST' ? { body: new Uint8Array(await request.arrayBuffer()) } : {}),
101
101
  ...(options.root !== undefined ? { root: options.root } : {}),
102
- readOnly,
102
+ // a read-only request pushes nothing: git refuses it as it refuses a push to a read-only twin
103
+ readOnly: readOnly || isReadOnlyRequest(request),
103
104
  contentEncoding: request.headers.get('content-encoding') ?? '',
104
105
  gitProtocol: request.headers.get('git-protocol') ?? '',
105
106
  occurredAt,
@@ -210,8 +211,9 @@ export function createGithubTwinFetch(options) {
210
211
  // dated by the host's clock contradicts the World's time) and an x-github-request-id in GitHub's colon-separated hex
211
212
  // form, derived from the instant, the request and a counter so the same request twice gets two ids.
212
213
  let answered = 0;
213
- // which part serves each spec operation (the derived dispatch's owners), for the pack's report
214
- return Object.assign(async (request) => {
214
+ // which part serves each spec operation (the derived dispatch's owners), for the pack's report; a read-only request
215
+ // (x-volter-read-only) writes nothing — a GraphQL mutation or a git push included — answered with GitHub's read-only error
216
+ return derivedRequestScopes(manifest, Object.assign(async (request) => {
215
217
  const token = (request.headers.get('authorization') ?? '').replace(/^(?:bearer|token)\s+/i, '').trim() || undefined;
216
218
  const res = revokedToken(token, options.root) ? vendorError(manifest, { status: 401, message: 'Bad credentials' }) : await vended(request);
217
219
  const at = now();
@@ -222,7 +224,7 @@ export function createGithubTwinFetch(options) {
222
224
  headers.set('x-github-request-id', `${h.slice(0, 4)}:${h.slice(4, 9)}:${h.slice(9, 15)}:${h.slice(15, 21)}:${h.slice(21, 29)}`);
223
225
  }
224
226
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
225
- }, { owners: inner.owners });
227
+ }, { owners: inner.owners }));
226
228
  }
227
229
  /** The GraphQL wire's requests, as the handler interface names them. */
228
230
  const GRAPHQL_OPERATION = { id: 'graphql', method: 'POST', path: '/graphql', class: 'action' };
@@ -756,6 +756,7 @@ export type GithubUserEmail = {
756
756
  primary: boolean;
757
757
  verified: boolean;
758
758
  visibility: string | null;
759
+ login: string;
759
760
  };
760
761
  export type GithubUserSshKey = {
761
762
  id: number;
@@ -1075,6 +1076,7 @@ export type GithubState = {
1075
1076
  userGpgKeys: GithubUserGpgKey[];
1076
1077
  blockedUsers: Set<string>;
1077
1078
  userProfile: Partial<GithubUser>;
1079
+ userProfiles: Record<string, Partial<GithubUser>>;
1078
1080
  projects: GithubProject[];
1079
1081
  projectFields: GithubProjectField[];
1080
1082
  projectItems: GithubProjectItem[];
@@ -1274,6 +1276,12 @@ export declare function toGithubEnvironment(e: GithubEnvironment): Record<string
1274
1276
  export declare function toGithubDeployKey(k: GithubDeployKey): Record<string, unknown>;
1275
1277
  export declare function toGithubAutolink(a: GithubAutolink): Record<string, unknown>;
1276
1278
  export declare function toGithubRuleset(r: GithubRuleset): Record<string, unknown>;
1279
+ /** An account as GET /user and GET /users/:login answer it: the login's deterministic account, under the profile its
1280
+ * person edited (PATCH /user). Nothing is invented for a person who has not filled it in: GitHub's `name` is null until
1281
+ * they set it. */
1282
+ export declare function accountOf(state: GithubState, login: string): GithubUser;
1283
+ /** The addresses an account holds, primary first (GET /user/emails). */
1284
+ export declare function emailsOf(state: GithubState, login: string): GithubUserEmail[];
1277
1285
  export declare function toGithubUserEmail(e: GithubUserEmail): Record<string, unknown>;
1278
1286
  export declare function toGithubUserSshKey(k: GithubUserSshKey): Record<string, unknown>;
1279
1287
  export declare function toGithubUserGpgKey(k: GithubUserGpgKey): Record<string, unknown>;
@@ -1373,6 +1381,13 @@ export declare function registerGithubApp(body: Record<string, any>, occurredAt:
1373
1381
  response: GithubResponse;
1374
1382
  app?: GithubAppRegistration;
1375
1383
  }>;
1384
+ /** Every account login the World already knows, lowercased → as it was first written: the World's user, whoever holds a
1385
+ * token or a password or an address or a profile, owns, collaborates on or is invited to a repository, is an organization
1386
+ * or one of its members. A tombstoned row still names an account that existed. Sign-up refuses these; a request naming one in another
1387
+ * case answers the account as it is spelled. */
1388
+ export declare function accountLoginsOf(resources: readonly Record<string, unknown>[]): Map<string, string>;
1389
+ /** A login as the account it names is spelled (GitHub's logins are case-insensitive); a login no account holds as asked. */
1390
+ export declare function canonicalLogin(login: string, root?: string): string;
1376
1391
  /** The twin's own doors under /_twin/: what stands in for an act GitHub does in its web UI or its runners, which have
1377
1392
  * no API. A request to any other path is not a door's (undefined). */
1378
1393
  export declare function handleGithubDoor(request: Request, scope: {
@@ -285,6 +285,8 @@ export function githubState(root) {
285
285
  for (const row of value)
286
286
  Object.freeze(row);
287
287
  Object.freeze(state.userProfile);
288
+ for (const p of Object.values(state.userProfiles))
289
+ Object.freeze(p);
288
290
  memos.set(root ?? '', { stamp, state });
289
291
  return state;
290
292
  }
@@ -420,7 +422,8 @@ function foldGithubState(root) {
420
422
  const userGpgKeysById = new Map();
421
423
  const deletedUserGpgKeys = new Set();
422
424
  const blockedByLogin = new Map();
423
- const userProfile = {};
425
+ // each account's profile as it edited it (PATCH /user), by lowercased login; octocat's is also `userProfile`
426
+ const userProfiles = {};
424
427
  // Projects v2: projects / fields / items / views — LOCAL constructs keyed by subject id.
425
428
  const projectsById = new Map();
426
429
  const deletedProjects = new Set();
@@ -1293,15 +1296,19 @@ function foldGithubState(root) {
1293
1296
  rules: Array.isArray(f.rules) ? f.rules : [] });
1294
1297
  }
1295
1298
  else if (a.subject.type === 'user_email') {
1299
+ // a removed address is gone; added again (by its account or another) it starts afresh
1296
1300
  if (f.deleted === true) {
1297
1301
  deletedUserEmails.add(a.subject.id);
1302
+ userEmailsByAddr.delete(a.subject.id);
1298
1303
  continue;
1299
1304
  }
1305
+ deletedUserEmails.delete(a.subject.id);
1300
1306
  const prev = userEmailsByAddr.get(a.subject.id);
1301
1307
  userEmailsByAddr.set(a.subject.id, {
1302
1308
  email: String(f.email ?? prev?.email ?? ''), primary: f.primary !== undefined ? Boolean(f.primary) : (prev?.primary ?? false),
1303
1309
  verified: f.verified !== undefined ? Boolean(f.verified) : (prev?.verified ?? true),
1304
1310
  visibility: f.visibility !== undefined ? f.visibility : (prev?.visibility ?? null),
1311
+ login: typeof f.login === 'string' ? f.login : (prev?.login ?? GITHUB_WORLD_USER),
1305
1312
  });
1306
1313
  }
1307
1314
  else if (a.subject.type === 'user_ssh_key') {
@@ -1333,10 +1340,13 @@ function foldGithubState(root) {
1333
1340
  blockedByLogin.set(String(f.login ?? a.subject.id.replace(/^block:/, '')), f.deleted !== true);
1334
1341
  }
1335
1342
  else if (a.subject.type === 'user_profile') {
1336
- // PATCH /user — overlay the authenticated user's editable profile fields.
1343
+ // PATCH /user — overlay the authenticated user's editable profile fields, on that user's own profile (a row
1344
+ // without a login is from before profiles were per account: the World's user's).
1345
+ const owner = (typeof f.login === 'string' ? f.login : a.subject.id === 'user_profile:octocat' ? GITHUB_WORLD_USER : a.subject.id.replace(/^user_profile:/, '')).toLowerCase();
1346
+ const profile = userProfiles[owner] ?? (userProfiles[owner] = {});
1337
1347
  for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email']) {
1338
1348
  if (f[k] !== undefined)
1339
- userProfile[k] = f[k];
1349
+ profile[k] = f[k];
1340
1350
  }
1341
1351
  }
1342
1352
  else if (a.subject.type === 'project_field' || a.subject.type === 'project_item' || a.subject.type === 'project_view') {
@@ -1868,7 +1878,8 @@ function foldGithubState(root) {
1868
1878
  userSshKeys,
1869
1879
  userGpgKeys,
1870
1880
  blockedUsers: new Set([...blockedByLogin.entries()].filter(([, on]) => on).map(([k]) => k)),
1871
- userProfile,
1881
+ userProfile: userProfiles[GITHUB_WORLD_USER] ?? {},
1882
+ userProfiles,
1872
1883
  projects,
1873
1884
  projectFields,
1874
1885
  projectItems,
@@ -3381,6 +3392,16 @@ export function toGithubRuleset(r) {
3381
3392
  };
3382
3393
  }
3383
3394
  // ── User niche serializers: emails / SSH keys / GPG keys ─────────────────────────────────
3395
+ /** An account as GET /user and GET /users/:login answer it: the login's deterministic account, under the profile its
3396
+ * person edited (PATCH /user). Nothing is invented for a person who has not filled it in: GitHub's `name` is null until
3397
+ * they set it. */
3398
+ export function accountOf(state, login) {
3399
+ return { ...seedUser(login), ...(state.userProfiles[login.toLowerCase()] ?? {}), login };
3400
+ }
3401
+ /** The addresses an account holds, primary first (GET /user/emails). */
3402
+ export function emailsOf(state, login) {
3403
+ return state.userEmails.filter((e) => e.login.toLowerCase() === login.toLowerCase());
3404
+ }
3384
3405
  export function toGithubUserEmail(e) {
3385
3406
  return { email: e.email, primary: e.primary, verified: e.verified, visibility: e.visibility };
3386
3407
  }
@@ -3873,6 +3894,83 @@ export async function registerGithubApp(body, occurredAt, root) {
3873
3894
  await applyTwinWrite(SERVICE, { operation: 'app_registration.create', subjectType: 'app_registration', subjectId: `app:${slug}`, fields, occurredAt, actor: { kind: 'agent' } }, req.root);
3874
3895
  return { response: { status: 201, body: toGithubApp(fields, 0, githubState(req.root)) }, app: fields };
3875
3896
  }
3897
+ /** Every account login the World already knows, lowercased → as it was first written: the World's user, whoever holds a
3898
+ * token or a password or an address or a profile, owns, collaborates on or is invited to a repository, is an organization
3899
+ * or one of its members. A tombstoned row still names an account that existed. Sign-up refuses these; a request naming one in another
3900
+ * case answers the account as it is spelled. */
3901
+ export function accountLoginsOf(resources) {
3902
+ const out = new Map([[GITHUB_WORLD_USER, GITHUB_WORLD_USER]]);
3903
+ const add = (v) => { if (typeof v === 'string' && v && !out.has(v.toLowerCase()))
3904
+ out.set(v.toLowerCase(), v); };
3905
+ for (const r of resources) {
3906
+ switch (r.type) {
3907
+ case 'user_credential':
3908
+ case 'user_email':
3909
+ case 'user_profile':
3910
+ case 'user_two_factor':
3911
+ case 'oauth_grant':
3912
+ case 'collaborator':
3913
+ case 'outside_collaborator':
3914
+ case 'org_membership':
3915
+ case 'team_membership':
3916
+ case 'organization':
3917
+ add(r.login);
3918
+ break;
3919
+ case 'repository':
3920
+ add(r.owner);
3921
+ break;
3922
+ // GitHub invites only an account that exists
3923
+ case 'repo_invitation':
3924
+ add(r.invitee);
3925
+ break;
3926
+ }
3927
+ }
3928
+ return out;
3929
+ }
3930
+ /** A login as the account it names is spelled (GitHub's logins are case-insensitive); a login no account holds as asked. */
3931
+ export function canonicalLogin(login, root) {
3932
+ const store = getActiveWorldStore();
3933
+ const memos = accountMemos.get(store) ?? accountMemos.set(store, new Map()).get(store);
3934
+ const stamp = treeStamp(SERVICE, root);
3935
+ let held = memos.get(root ?? '');
3936
+ if (!held || held.stamp !== stamp) {
3937
+ held = { stamp, logins: accountLoginsOf(twinResources(SERVICE, root)) };
3938
+ memos.set(root ?? '', held);
3939
+ }
3940
+ return held.logins.get(login.toLowerCase()) ?? login;
3941
+ }
3942
+ const accountMemos = new WeakMap();
3943
+ /** A person signs up (POST /_twin/users, standing in for github.com/signup): the account's password and its primary
3944
+ * address, decided against the tree in one step so two sign-ups cannot take the same username or address. */
3945
+ async function signUp(body, occurredAt, root) {
3946
+ const login = typeof body.login === 'string' ? body.login : '';
3947
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
3948
+ const password = typeof body.password === 'string' ? body.password : '';
3949
+ const invalid = (resource, field, code, message) => ({ response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource, field, code, ...(message ? { message } : {}) }] } } });
3950
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) || /--|-$/.test(login))
3951
+ return invalid('User', 'login', 'invalid');
3952
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
3953
+ return invalid('User', 'email', 'invalid');
3954
+ if (!(password.length >= 15 || (password.length >= 8 && /[0-9]/.test(password) && /[a-z]/.test(password))))
3955
+ return invalid('User', 'password', 'invalid', 'Password is too short or needs a number and a lowercase letter');
3956
+ const lower = login.toLowerCase();
3957
+ // The account is made by its address: the one write that claims both the username (the row names it, so every later
3958
+ // sign-up sees it taken) and the address, decided against the tree it lands on, so of two sign-ups racing for either
3959
+ // only one is made. Its password follows.
3960
+ const { value: taken } = await applyTwinWriteAtomic(SERVICE, (resources) => {
3961
+ if (accountLoginsOf(resources).has(lower))
3962
+ return { kind: 'skip', value: 'login' };
3963
+ if (resources.some((r) => r.deleted !== true && r.type === 'user_email' && String(r.email).toLowerCase() === email.toLowerCase()))
3964
+ return { kind: 'skip', value: 'email' };
3965
+ 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 } } };
3966
+ }, root);
3967
+ if (taken === 'login')
3968
+ return invalid('User', 'login', 'already_exists', 'Username is not available');
3969
+ if (taken === 'email')
3970
+ return invalid('User', 'email', 'already_exists', 'Email is invalid or already taken');
3971
+ 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);
3972
+ return { response: { status: 201, body: { login, email } } };
3973
+ }
3876
3974
  /** The twin's own doors under /_twin/: what stands in for an act GitHub does in its web UI or its runners, which have
3877
3975
  * no API. A request to any other path is not a door's (undefined). */
3878
3976
  export async function handleGithubDoor(request, scope) {
@@ -3911,10 +4009,20 @@ export async function handleGithubDoor(request, scope) {
3911
4009
  // webhook_secret?}. It is installed through its page, github.com/apps/{slug}/installations/new (src/screens/app-installation.tsx).
3912
4010
  if (seg[1] === 'apps' && !seg[2])
3913
4011
  return registerGithubApp(body, occurredAt, req.root);
4012
+ // POST /_twin/users → {login, email, password}: GitHub's sign-up (github.com/signup; GitHub has no API to create an
4013
+ // account). As the page does, it takes a username, an email address and a password, and refuses a username or an
4014
+ // address another account holds and a password GitHub's rule refuses ("at least 15 characters OR at least 8
4015
+ // characters including a number and a lowercase letter", docs.github.com/authentication/keeping-your-account-and-
4016
+ // data-secure/creating-a-strong-password). The account's address is its primary one, verified (GitHub creates the
4017
+ // account only once the launch code it mails there is entered; that step is not modelled) and private, so
4018
+ // GET /user/emails lists it and GET /user's public `email` stays null. The person signs in at github.com/login;
4019
+ // their name and the rest of their profile are theirs to set (PATCH /user), empty until they do.
4020
+ if (seg[1] === 'users' && !seg[2])
4021
+ return signUp(body, occurredAt, req.root);
3914
4022
  // POST /_twin/users/:login/tokens → a personal access token for that user (GitHub mints these in its settings UI,
3915
4023
  // which has no API). Requests carrying it are that user's: writes are attributed to them and GET /user names them.
3916
4024
  if (seg[1] === 'users' && seg[2] && seg[3] === 'tokens' && !seg[4]) {
3917
- const login = decodeURIComponent(seg[2]);
4025
+ const login = canonicalLogin(decodeURIComponent(seg[2]), req.root);
3918
4026
  if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login))
3919
4027
  return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: 'login', code: 'invalid' }] } } };
3920
4028
  const token = `ghp_${createHash('sha256').update(`pat:${login}:${nextSeq('pat', req.root)}`).digest('hex').slice(0, 36)}`;
@@ -3923,10 +4031,10 @@ export async function handleGithubDoor(request, scope) {
3923
4031
  return { response: { status: 201, body: { token, login, scopes } } };
3924
4032
  }
3925
4033
  // POST /_twin/users/:login/password → {password}: the password the World gives a person, with which they sign in at
3926
- // github.com/login (src/screens/session.tsx). GitHub's sign-up and password pages are not modelled; the World keeps
3927
- // the password's hash, never the password.
4034
+ // github.com/login (src/screens/session.tsx). It stands in for GitHub's password page (sign-up is POST /_twin/users
4035
+ // above); the World keeps the password's hash, never the password.
3928
4036
  if (seg[1] === 'users' && seg[2] && seg[3] === 'password' && !seg[4]) {
3929
- const login = decodeURIComponent(seg[2]);
4037
+ const login = canonicalLogin(decodeURIComponent(seg[2]), req.root);
3930
4038
  const password = typeof body.password === 'string' ? body.password : '';
3931
4039
  if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login) || password.length < 8)
3932
4040
  return { response: { status: 422, body: { message: 'Validation Failed', errors: [{ resource: 'User', field: password.length < 8 ? 'password' : 'login', code: 'invalid' }] } } };
@@ -14,8 +14,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
14
14
  // (docs.github.com/authentication/securing-your-account-with-two-factor-authentication-2fa/about-mandatory-two-factor-
15
15
  // authentication) is the person's to turn on in the World; the twin does not enrol anyone.
16
16
  //
17
- // Where GitHub's documentation stops and the twin decides: a person's password is what the World gave them
18
- // (`POST /_twin/users/:login/password`; GitHub's sign-up and password pages are not modelled), an account is named by
17
+ // Where GitHub's documentation stops and the twin decides: a person's password is the one they signed up with
18
+ // (`POST /_twin/users`, standing in for github.com/signup) or the World gave them (`POST /_twin/users/:login/password`;
19
+ // GitHub's password page is not modelled), an account is named by
19
20
  // its login (not its email), a refused sign-in shows the form again with GitHub's message (answered 200), a code is
20
21
  // taken for the current 30-second step or the one before (RFC 6238's one step of drift) and never twice, a pending
21
22
  // 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.
@@ -275,7 +275,12 @@ export function githubGraphql() {
275
275
  };
276
276
  return {
277
277
  resolvers: {
278
- 'Query.viewer': (_s, _args, c) => ({ __typename: 'User', login: 'octocat', name: state(c).userProfile.name ?? 'The Octocat' }),
278
+ // the token's own account; the World's shared token is octocat's, named The Octocat until its profile says otherwise
279
+ 'Query.viewer': (_s, _args, c) => {
280
+ const login = gh(ctxOf(c)).caller ?? GITHUB_WORLD_USER;
281
+ const name = state(c).userProfiles[login.toLowerCase()]?.name;
282
+ return { __typename: 'User', login, name: name ?? (login === GITHUB_WORLD_USER ? 'The Octocat' : null) };
283
+ },
279
284
  'Query.node': queryNode,
280
285
  'Query.repository': (_s, args, c) => {
281
286
  const repo = `${String(args.owner)}/${String(args.name)}`;
@@ -420,7 +425,7 @@ export function githubGraphql() {
420
425
  const login = String(args.login ?? '');
421
426
  const st = state(c);
422
427
  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);
423
- return known ? { __typename: 'User', login, name: null, id: `U_${login}` } : fail(`Could not resolve to a User with the login of '${login}'.`, 'NOT_FOUND');
428
+ 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');
424
429
  },
425
430
  'Query.organization': (_s, args, c) => {
426
431
  const org = state(c).orgs.find((o) => o.login === String(args.login ?? ''));
@@ -146,6 +146,6 @@ export const writer = (ctx) => (w) => put(ctx, w);
146
146
  * the noreply address GitHub gives an account that keeps its email private. */
147
147
  export function identityOf(g) {
148
148
  const login = g.caller ?? 'octocat';
149
- const profile = login === 'octocat' ? g.state().userProfile : undefined;
149
+ const profile = g.state().userProfiles[login.toLowerCase()];
150
150
  return { name: String(profile?.name ?? login), email: `${twinUserId(login)}+${login}@users.noreply.github.com` };
151
151
  }