@volter/twin-github 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.0",
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.0",
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.0"
66
+ "@volter/world-core": "2.0.2"
67
67
  },
68
68
  "devDependencies": {
69
- "@volter/world-core": "2.0.0",
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,9 +1,10 @@
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
+ import { appDestination, appFetch } from '@volter/world-core/app-route';
7
8
  import { worldEgressRefusal } from '@volter/world-core/network-policy';
8
9
 
9
10
  export type GithubWebhook = Record<string, unknown>;
@@ -23,10 +24,11 @@ export function signGithubPayload(secret: string, body: string): string {
23
24
  }
24
25
 
25
26
  const httpDelivery: GithubWebhookDelivery = async (url, _event, _payload, headers, body) => {
26
- // the World's egress rule: a refused hook URL fails as a transport failure, recorded on the delivery
27
- const refusal = worldEgressRefusal(url);
27
+ // the World's egress rule: a refused hook URL fails as a transport failure, recorded on the delivery; the
28
+ // application's own hostnames reach it inside the World. GitHub follows no redirect: the 3xx is the delivery's answer.
29
+ const refusal = appDestination(url) ? null : worldEgressRefusal(url);
28
30
  if (refusal !== null) throw new Error(refusal);
29
- const response = await fetch(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) });
30
32
  return { status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() };
31
33
  };
32
34
 
@@ -30,7 +30,7 @@
30
30
  //
31
31
  // Offline + deterministic: everything here is the kernel's git library over the World's
32
32
  // blob store (no network, no rate budget, no `git` binary).
33
- import { applyTwinWrite, git } from '@volter/world-core';
33
+ import { applyTwinWrite, git, worldNow } from '@volter/world-core';
34
34
  const { serveSmartHttp, serveSmartHttpPost } = git;
35
35
  import { ensureBareRepo, hasBareRepo, listGitPlaneRefs, planeRepo, type PlaneRef } from './github-git-plane.ts';
36
36
  import { closePrsForDeletedBranch, githubState, kernelAtomic, syncPrHeadsToBranchTip, type GithubWriteEvent } from './github-twin.ts';
@@ -161,7 +161,7 @@ export async function reconcileGitPlaneRefs(
161
161
  opts: { root?: string; occurredAt?: string } = {},
162
162
  ): Promise<GithubWriteEvent[]> {
163
163
  const root = opts.root;
164
- const occurredAt = opts.occurredAt ?? new Date().toISOString();
164
+ const occurredAt = opts.occurredAt ?? worldNow();
165
165
  const after = await listGitPlaneRefs(repo, root);
166
166
  const beforeBy = new Map(before.map((r) => [r.ref, r]));
167
167
  const afterBy = new Map(after.map((r) => [r.ref, r]));
@@ -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. */