@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.
@@ -1,10 +1,16 @@
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.js";
5
8
  import { createHash } from 'node:crypto';
6
- import { githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId, } from "../github-twin.js";
7
- import { asView, gh, nextSeq, put, send } from "./shared.js";
9
+ import { accountOf, canonicalLogin, emailsOf, githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId, } from "../github-twin.js";
10
+ import { asView, atomic, gh, nextSeq, put, send } from "./shared.js";
11
+ /** An app's installation token speaks for the app, which has no profile or addresses of its own to change: GitHub
12
+ * answers its writes to the authenticated user 403 "Resource not accessible by integration". */
13
+ const asApp = (ctx) => (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;
8
14
  /** GET /users/:login/events[/public] and /users/:login/received_events[/public]: events = activity the
9
15
  * user PERFORMED (actor); received_events = activity in repos the user OWNS. Both filter the SAME
10
16
  * modeled stream. */
@@ -22,8 +28,9 @@ const userEvents = (received, only) => async (ctx) => {
22
28
  export function usersSemantics() {
23
29
  const usersGetByUsername = async (ctx) => {
24
30
  const { seg, state } = gh(ctx);
25
- const login = decodeURIComponent(seg[1]);
26
- return ctx.reply(toGithubUser(login === 'octocat' ? { ...seedUser('octocat'), ...state().userProfile, login } : seedUser(login)));
31
+ // a login in another case names the same account, answered as it is spelled (and with its id)
32
+ const login = canonicalLogin(decodeURIComponent(seg[1]), ctx.root);
33
+ return ctx.reply(toGithubUser(accountOf(state(), login)));
27
34
  };
28
35
  const reposListForAuthenticatedUser = async (ctx) => {
29
36
  const { caller, query, state } = gh(ctx);
@@ -90,11 +97,11 @@ export function usersSemantics() {
90
97
  };
91
98
  const usersListEmailsForAuthenticatedUser = async (ctx) => {
92
99
  const { query, state } = gh(ctx);
93
- return ctx.reply(paginate(state().userEmails, query).map(toGithubUserEmail));
100
+ return ctx.reply(paginate(emailsOf(state(), me(ctx)), query).map(toGithubUserEmail));
94
101
  };
95
102
  const usersListPublicEmailsForAuthenticatedUser = async (ctx) => {
96
103
  const { query, state } = gh(ctx);
97
- return ctx.reply(paginate(state().userEmails.filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
104
+ return ctx.reply(paginate(emailsOf(state(), me(ctx)).filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
98
105
  };
99
106
  const usersListPublicSshKeysForAuthenticatedUser = async (ctx) => {
100
107
  const { query, state } = gh(ctx);
@@ -123,19 +130,29 @@ export function usersSemantics() {
123
130
  return send(ctx, state().blockedUsers.has(decodeURIComponent(seg[2])) ? { status: 204, body: undefined } : githubNotFound());
124
131
  };
125
132
  const usersGetAuthenticated = async (ctx) => {
126
- const { caller, state } = gh(ctx);
127
- if (caller)
128
- return ctx.reply(toGithubUser(seedUser(caller)));
129
- return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
133
+ const { state } = gh(ctx);
134
+ return ctx.reply(toGithubUser(accountOf(state(), me(ctx))));
130
135
  };
131
136
  const usersUpdateAuthenticated = async (ctx) => {
137
+ const refused = asApp(ctx);
138
+ if (refused)
139
+ return refused;
132
140
  const { body, state } = gh(ctx);
133
141
  const fields = {};
134
142
  for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email'])
135
143
  if (body[k] !== undefined)
136
144
  fields[k] = body[k];
137
- await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: 'user_profile:octocat', fields });
138
- return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
145
+ const login = me(ctx);
146
+ // the public email is one of the account's own verified addresses (GitHub's profile settings offer only those; the
147
+ // REST docs name the field "The publicly visible email address of the user" and a 422 for a validation failure);
148
+ // empty or null shows none. The 422's wording is the twin's.
149
+ if (typeof fields.email === 'string' && fields.email !== '' && !emailsOf(state(), login).some((e) => e.verified && e.email.toLowerCase() === String(fields.email).toLowerCase())) {
150
+ 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);
151
+ }
152
+ if (fields.email !== undefined && fields.email !== null && typeof fields.email !== 'string')
153
+ return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'User', code: 'invalid', field: 'email' }] }, 422);
154
+ await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: `user_profile:${login.toLowerCase()}`, fields: { ...fields, login } });
155
+ return ctx.reply(toGithubUser(accountOf(state(), login)));
139
156
  };
140
157
  const usersCreatePublicSshKeyForAuthenticatedUser = async (ctx) => {
141
158
  const { body, occurredAt, state } = gh(ctx);
@@ -204,11 +221,12 @@ export function usersSemantics() {
204
221
  'users/list-blocked-by-authenticated-user': usersListBlockedByAuthenticatedUser,
205
222
  // "Check if a user is blocked by the authenticated user" → 204 blocked / 404 not.
206
223
  'users/check-blocked': usersCheckBlocked,
207
- // the token's own user when the twin's user door minted it; else the deterministic octocat
224
+ // the token's own account (a personal token, an OAuth app's token for the person who consented) under the
225
+ // profile that person edited; the World's shared token is octocat's
208
226
  'users/get-authenticated': usersGetAuthenticated,
209
227
  'users/update-authenticated': usersUpdateAuthenticated,
210
- 'users/add-email-for-authenticated-user': async (ctx) => emails(ctx, false),
211
- 'users/delete-email-for-authenticated-user': async (ctx) => emails(ctx, true),
228
+ 'users/add-email-for-authenticated-user': async (ctx) => asApp(ctx) ?? emails(ctx, false),
229
+ 'users/delete-email-for-authenticated-user': async (ctx) => asApp(ctx) ?? emails(ctx, true),
212
230
  'users/create-public-ssh-key-for-authenticated-user': usersCreatePublicSshKeyForAuthenticatedUser,
213
231
  'users/delete-public-ssh-key-for-authenticated-user': usersDeletePublicSshKeyForAuthenticatedUser,
214
232
  'users/create-gpg-key-for-authenticated-user': usersCreateGpgKeyForAuthenticatedUser,
@@ -221,27 +239,51 @@ export function usersSemantics() {
221
239
  'users/unfollow': async (ctx) => follow(ctx, false),
222
240
  };
223
241
  }
224
- /** POST /user/emails adds, DELETE removes; both take `{emails}` or a bare array. The first address a
225
- * user adds is primary and public. */
242
+ /** POST /user/emails adds, DELETE removes, on the caller's own account; both take `{emails}` or a bare array. The
243
+ * first address an account adds is primary and public. An address is one account's: adding one another account holds
244
+ * is refused (422, the twin's wording: GitHub's docs name only the status), each address checked and claimed in one
245
+ * atomic step, and removing one leaves every other account's addresses alone. The primary address is not removed
246
+ * (422): GitHub has its person make another address primary first (github.com/orgs/community/discussions/62557; the
247
+ * wording is the twin's). */
226
248
  async function emails(ctx, remove) {
227
249
  const { body, state } = gh(ctx);
250
+ const login = me(ctx);
228
251
  const list = Array.isArray(body.emails) ? body.emails.map(String) : Array.isArray(body) ? body.map(String) : [];
229
252
  if (!list.length)
230
253
  return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Email', field: 'emails', code: 'missing_field' }] }, 422);
231
254
  const st = state();
255
+ const mine = (e) => e.login.toLowerCase() === login.toLowerCase();
256
+ const held = (email) => st.userEmails.find((e) => e.email.toLowerCase() === email.toLowerCase());
257
+ if (!remove && list.some((email) => { const h = held(email); return h && !mine(h); })) {
258
+ return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'email is already in use' }] }, 422);
259
+ }
260
+ if (remove && list.some((email) => { const h = held(email); return h && mine(h) && h.primary; })) {
261
+ return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'the primary email address cannot be removed' }] }, 422);
262
+ }
263
+ const hadNone = emailsOf(st, login).length === 0;
264
+ const claim = atomic(ctx);
232
265
  for (const email of list) {
233
- const subjectId = `useremail:${email}`;
266
+ const h = held(email);
267
+ const subjectId = `useremail:${h?.email ?? email}`;
234
268
  if (remove) {
235
- await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email } });
269
+ if (h && mine(h))
270
+ await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email: h.email } });
236
271
  }
237
- else {
238
- const isFirst = st.userEmails.length === 0 && email === list[0];
239
- await put(ctx, { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private' } });
272
+ else if (!h) {
273
+ const isFirst = hadNone && email === list[0];
274
+ const lost = await claim(() => {
275
+ const now = state().userEmails.find((e) => e.email.toLowerCase() === email.toLowerCase());
276
+ if (now)
277
+ return { value: !mine(now) };
278
+ return { value: false, write: { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private', login } } };
279
+ });
280
+ if (lost)
281
+ return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'email is already in use' }] }, 422);
240
282
  }
241
283
  }
242
284
  if (remove)
243
285
  return send(ctx, { status: 204, body: undefined });
244
- return ctx.reply(state().userEmails.map(toGithubUserEmail), 201);
286
+ return ctx.reply(emailsOf(state(), login).map(toGithubUserEmail), 201);
245
287
  }
246
288
  async function block(ctx, blocking) {
247
289
  const login = decodeURIComponent(gh(ctx).seg[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-github",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Local GitHub twin — PR-evidence REST; your real `@octokit/rest` talks to it unmodified. Mirror, simulate, and fork. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
@@ -57,16 +57,16 @@
57
57
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
58
58
  },
59
59
  "dependencies": {
60
- "@volter/world-ui": "0.1.1",
60
+ "@volter/world-ui": "0.1.2",
61
61
  "react": "^19.2.7",
62
62
  "react-dom": "^19.2.7",
63
63
  "graphql": "16.14.2"
64
64
  },
65
65
  "peerDependencies": {
66
- "@volter/world-core": "2.0.1"
66
+ "@volter/world-core": "2.0.2"
67
67
  },
68
68
  "devDependencies": {
69
- "@volter/world-core": "2.0.1",
69
+ "@volter/world-core": "2.0.2",
70
70
  "@volter/world-tooling": "0.1.0",
71
71
  "@octokit/rest": "^22.0.1",
72
72
  "playwright": "^1.61.0",
@@ -2469,6 +2469,100 @@ export const GITHUB_CAPABILITIES: CapabilitySpec[] = [
2469
2469
  const after = await handleGithubRequest({ method: 'GET', path: `/user/emails`, root });
2470
2470
  return del.response.status === 204 && isArr(after.body) && after.body.length === 1;
2471
2471
  })),
2472
+ 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', () =>
2473
+ withRoot(async (root) => {
2474
+ const signUp = async (body: Record<string, string>) => (await applyGithubWrite({ method: 'POST', path: '/_twin/users', body: JSON.stringify(body), root })).response;
2475
+ const made = await signUp({ login: 'sam-friends', email: 'sam@friends.test', password: 'correct-horse-1' });
2476
+ if (!(made.status === 201 && (made.body as { login?: string }).login === 'sam-friends')) return false;
2477
+ for (const refused of [
2478
+ { login: 'Sam-Friends', email: 'other@friends.test', password: 'correct-horse-1' }, // the username, any case
2479
+ { login: 'octocat', email: 'o@friends.test', password: 'correct-horse-1' }, // the World's user
2480
+ { login: 'ada', email: 'SAM@friends.test', password: 'correct-horse-1' }, // the address, any case
2481
+ { login: 'ada', email: 'ada@friends.test', password: 'short1' }, // under 8
2482
+ { login: 'ada', email: 'ada@friends.test', password: 'NOLOWERCASE123' }, // 8 to 14 without a lowercase letter
2483
+ { login: 'ada', email: 'not-an-address', password: 'correct-horse-1' },
2484
+ { login: '-ada', email: 'ada@friends.test', password: 'correct-horse-1' },
2485
+ ]) if ((await signUp(refused)).status !== 422) return false;
2486
+ if ((await signUp({ login: 'ada', email: 'ada@friends.test', password: 'fifteen chars ok' })).status !== 201) return false;
2487
+ // a username the World already knows is taken, whatever named it: a token holder, a repository's owner or collaborator
2488
+ await mintToken(root, 'grace');
2489
+ await applyGithubWrite({ method: 'POST', path: '/user/repos', body: JSON.stringify({ name: 'shared' }), root });
2490
+ await applyGithubWrite({ method: 'PUT', path: '/repos/octocat/shared/collaborators/rui', body: '{}', root });
2491
+ for (const login of ['Grace', 'rui']) if ((await signUp({ login, email: `${login}@friends.test`, password: 'correct-horse-1' })).status !== 422) return false;
2492
+ // two sign-ups racing for one address: one account is made, the other refused, and the address is the made one's
2493
+ 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' })]);
2494
+ const winner = raced.find((r) => r.status === 201)?.body as { login?: string } | undefined;
2495
+ if (raced.filter((r) => r.status === 201).length !== 1 || raced.filter((r) => r.status === 422).length !== 1) return false;
2496
+ const holders = twinResources('github', root).filter((r) => r.type === 'user_email' && r.email === 'shared@friends.test' && r.deleted !== true);
2497
+ if (!(holders.length === 1 && holders[0]!.login === winner?.login)) return false;
2498
+ // the password signs its person in; a wrong one does not
2499
+ const form = (login: string, password: string) => 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() }));
2500
+ const wrong = await form('sam-friends', 'wrong-horse-1');
2501
+ const right = await form('sam-friends', 'correct-horse-1');
2502
+ return wrong?.status === 200 && right?.status === 302 && (right.headers.getSetCookie() ?? []).some((c) => c.startsWith('dotcom_user=sam-friends;'));
2503
+ })),
2504
+ 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', () =>
2505
+ withRoot(async (root) => {
2506
+ const at = '2026-01-01T00:00:00.000Z';
2507
+ // octocat's own address, from before anyone signed up
2508
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['octocat@github.test'] }), root });
2509
+ await applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ name: 'The Octocat' }), root });
2510
+ const tokenFor = async (login: string, email: string): Promise<string> => {
2511
+ await applyGithubWrite({ method: 'POST', path: '/_twin/users', body: JSON.stringify({ login, email, password: 'correct-horse-1' }), root });
2512
+ 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() }));
2513
+ const cookie = (signed?.headers.getSetCookie() ?? []).map((c) => c.split(';')[0]).join('; ');
2514
+ const consent = { client_id: 'librechat', redirect_uri: 'http://chat.local/oauth/github/callback', scope: 'user:email', state: login };
2515
+ if ((await applyGithubWrite({ method: 'POST', path: '/login/oauth/authorize', body: JSON.stringify({ ...consent, authorize: '1' }), root, cookie })).response.status !== 302) return '';
2516
+ const code = String(twinResources('github', root).find((r) => r.type === 'oauth_code' && r.login === login)?.code ?? '');
2517
+ const ex = await applyGithubWrite({ method: 'POST', path: '/login/oauth/access_token', body: JSON.stringify({ client_id: 'librechat', code }), root });
2518
+ return new URLSearchParams(String(ex.response.body)).get('access_token') ?? '';
2519
+ };
2520
+ const sam = await tokenFor('sam-friends', 'sam@friends.test');
2521
+ const ada = await tokenFor('ada', 'ada@friends.test');
2522
+ if (!sam || !ada) return false;
2523
+ type U = { login?: string; name?: string | null; avatar_url?: string; id?: number };
2524
+ const user = async (token?: string) => (await handleGithubRequest({ method: 'GET', path: '/user', root, ...(token ? { token } : {}) })).body as U;
2525
+ const emails = async (token?: string) => (await handleGithubRequest({ method: 'GET', path: '/user/emails', root, ...(token ? { token } : {}) })).body as Array<{ email: string; primary: boolean; verified: boolean }>;
2526
+ // before he names himself, GitHub has no name for him
2527
+ const before = await user(sam);
2528
+ if (!(before.login === 'sam-friends' && before.name === null && before.avatar_url === `https://avatars.githubusercontent.com/u/${before.id}?v=4`)) return false;
2529
+ if ((await applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ name: 'Sam Friends' }), root, token: sam })).response.status !== 200) return false;
2530
+ const [s, a, o] = [await user(sam), await user(ada), await user()];
2531
+ if (!(s.login === 'sam-friends' && s.name === 'Sam Friends' && a.login === 'ada' && a.name === null && o.login === 'octocat' && o.name === 'The Octocat')) return false;
2532
+ if (((await handleGithubRequest({ method: 'GET', path: '/users/sam-friends', root })).body as U).name !== 'Sam Friends') return false;
2533
+ // a login in another case is the same account, as it is spelled
2534
+ const upper = (await handleGithubRequest({ method: 'GET', path: '/users/SAM-FRIENDS', root })).body as U;
2535
+ if (!(upper.login === 'sam-friends' && upper.id === s.id && upper.name === 'Sam Friends')) return false;
2536
+ // his public email is one of his own verified addresses, never another's
2537
+ const pub = (email: string, token: string) => applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ email }), root, token });
2538
+ if ((await pub('ada@friends.test', sam)).response.status !== 422 || (await pub('nobody@friends.test', sam)).response.status !== 422) return false;
2539
+ if (!((await pub('sam@friends.test', sam)).response.status === 200 && ((await user(sam)) as { email?: string }).email === 'sam@friends.test')) return false;
2540
+ // his primary address is not removed while it is primary
2541
+ if ((await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['sam@friends.test'] }), root, token: sam })).response.status !== 422) return false;
2542
+ const [se, oe] = [await emails(sam), await emails()];
2543
+ if (!(se.length === 1 && se[0]!.email === 'sam@friends.test' && se[0]!.primary && se[0]!.verified)) return false;
2544
+ if (!(oe.length === 1 && oe[0]!.email === 'octocat@github.test')) return false;
2545
+ // an address is one account's: Ada cannot add Sam's, and removing it as Ada leaves it his
2546
+ if ((await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['sam@friends.test'] }), root, token: ada })).response.status !== 422) return false;
2547
+ await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['sam@friends.test'] }), root, token: ada });
2548
+ if ((await emails(sam)).length !== 1) return false;
2549
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['sam2@friends.test'] }), root, token: sam });
2550
+ // Sam removes his second address; Ada adds it afresh, and it is hers alone (not primary: her first address is)
2551
+ await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['sam2@friends.test'] }), root, token: sam });
2552
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['sam2@friends.test'] }), root, token: ada });
2553
+ const [se2, ae2] = [await emails(sam), await emails(ada)];
2554
+ 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))) return false;
2555
+ // an app's installation token has no profile or addresses to change
2556
+ const app = await ciToken(root, HISTORY_REPO);
2557
+ const byApp = [
2558
+ await applyGithubWrite({ method: 'PATCH', path: '/user', body: JSON.stringify({ name: 'bot' }), root, token: app }),
2559
+ await applyGithubWrite({ method: 'POST', path: '/user/emails', body: JSON.stringify({ emails: ['bot@friends.test'] }), root, token: app }),
2560
+ await applyGithubWrite({ method: 'DELETE', path: '/user/emails', body: JSON.stringify({ emails: ['octocat@github.test'] }), root, token: app }),
2561
+ ];
2562
+ return byApp.every((r) => r.response.status === 403 && (r.response.body as { message?: string }).message === 'Resource not accessible by integration') && (await emails()).length === 1;
2563
+ })),
2564
+ todo('github.users.own_keys', 'users', 'Users: each account its own SSH and GPG keys (today one list, whoever asks)', 'api', 'niche'),
2565
+ todo('github.users.own_blocks', 'users', 'Users: each account its own blocked users (today one list, whoever asks)', 'api', 'niche'),
2472
2566
  done('github.user.keys', 'users', 'User: SSH + GPG keys CRUD', 'api', 'niche', () =>
2473
2567
  withRoot(async (root) => {
2474
2568
  // SSH: missing key 422s; create → 201; get/list; delete → 404. GPG: armored key required.
@@ -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.ts';
6
6
  import type { GithubWebhook as Hook, GithubWriteEvent } from './github-twin.ts';
7
7
  import { appDestination, appFetch } from '@volter/world-core/app-route';
@@ -28,7 +28,7 @@ const httpDelivery: GithubWebhookDelivery = async (url, _event, _payload, header
28
28
  // application's own hostnames reach it inside the World. GitHub follows no redirect: the 3xx is the delivery's answer.
29
29
  const refusal = appDestination(url) ? null : worldEgressRefusal(url);
30
30
  if (refusal !== null) throw new Error(refusal);
31
- const response = await appFetch(url, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
31
+ const response = await appFetch(url, { method: 'POST', headers: { ...headers, ...deliveryTraceHeaders() }, body, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
32
32
  return { status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() };
33
33
  };
34
34
 
@@ -8,12 +8,12 @@
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.ts';
14
14
 
15
- 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)
16
- 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)
15
+ 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)
16
+ 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)
17
17
 
18
18
  const APP_SHELL = `<!doctype html>
19
19
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -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.ts';
12
12
  import { githubSemantics } from './semantics/index.ts';
@@ -14,7 +14,7 @@ import { githubGraphql } from './semantics/graphql.ts';
14
14
  import { createGraphqlWire } from './graphql-wire.ts';
15
15
  import { githubState, handleGithubDoor, revokedToken } from './github-twin.ts';
16
16
  import { announce } from './semantics/shared.ts';
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.tsx';
@@ -106,7 +106,8 @@ export function createGithubTwinFetch(options: GithubTwinOptions): (request: Req
106
106
  query: url.searchParams,
107
107
  ...(request.method === 'POST' ? { body: new Uint8Array(await request.arrayBuffer()) } : {}),
108
108
  ...(options.root !== undefined ? { root: options.root } : {}),
109
- readOnly,
109
+ // a read-only request pushes nothing: git refuses it as it refuses a push to a read-only twin
110
+ readOnly: readOnly || isReadOnlyRequest(request),
110
111
  contentEncoding: request.headers.get('content-encoding') ?? '',
111
112
  gitProtocol: request.headers.get('git-protocol') ?? '',
112
113
  occurredAt,
@@ -204,8 +205,9 @@ export function createGithubTwinFetch(options: GithubTwinOptions): (request: Req
204
205
  // dated by the host's clock contradicts the World's time) and an x-github-request-id in GitHub's colon-separated hex
205
206
  // form, derived from the instant, the request and a counter so the same request twice gets two ids.
206
207
  let answered = 0;
207
- // which part serves each spec operation (the derived dispatch's owners), for the pack's report
208
- return Object.assign(async (request: Request): Promise<Response> => {
208
+ // which part serves each spec operation (the derived dispatch's owners), for the pack's report; a read-only request
209
+ // (x-volter-read-only) writes nothing — a GraphQL mutation or a git push included — answered with GitHub's read-only error
210
+ return derivedRequestScopes(manifest, Object.assign(async (request: Request): Promise<Response> => {
209
211
  const token = (request.headers.get('authorization') ?? '').replace(/^(?:bearer|token)\s+/i, '').trim() || undefined;
210
212
  const res = revokedToken(token, options.root) ? vendorError(manifest, { status: 401, message: 'Bad credentials' }) : await vended(request);
211
213
  const at = now();
@@ -216,7 +218,7 @@ export function createGithubTwinFetch(options: GithubTwinOptions): (request: Req
216
218
  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)}`);
217
219
  }
218
220
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
219
- }, { owners: inner.owners });
221
+ }, { owners: inner.owners }));
220
222
  }
221
223
 
222
224
  /** The GraphQL wire's requests, as the handler interface names them. */
@@ -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);
@@ -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.