@volter/twin-github 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/github-capabilities.js +114 -0
- package/dist/src/github-events.js +6 -4
- package/dist/src/github-git-http.js +2 -2
- package/dist/src/github-mirror-ui.js +3 -3
- package/dist/src/github-server.js +8 -6
- package/dist/src/github-twin.d.ts +15 -0
- package/dist/src/github-twin.js +116 -8
- package/dist/src/index.js +3 -0
- package/dist/src/screens/session.js +16 -4
- package/dist/src/semantics/graphql.js +7 -2
- package/dist/src/semantics/shared.js +1 -1
- package/dist/src/semantics/users.js +66 -24
- package/package.json +4 -4
- package/src/github-capabilities.ts +94 -0
- package/src/github-events.ts +6 -4
- package/src/github-git-http.ts +2 -2
- package/src/github-mirror-ui.ts +3 -3
- package/src/github-server.ts +8 -6
- package/src/github-twin.ts +96 -12
- package/src/index.ts +3 -0
- package/src/screens/session.tsx +12 -4
- package/src/semantics/graphql.ts +7 -2
- package/src/semantics/shared.ts +1 -1
- package/src/semantics/users.ts +63 -23
|
@@ -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
|
-
//
|
|
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
|
-
|
|
26
|
-
|
|
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()
|
|
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().
|
|
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 {
|
|
127
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
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
|
|
225
|
-
*
|
|
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
|
|
266
|
+
const h = held(email);
|
|
267
|
+
const subjectId = `useremail:${h?.email ?? email}`;
|
|
234
268
|
if (remove) {
|
|
235
|
-
|
|
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 =
|
|
239
|
-
|
|
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().
|
|
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.
|
|
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.
|
|
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.
|
|
66
|
+
"@volter/world-core": "2.0.2"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@volter/world-core": "2.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.
|
package/src/github-events.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
package/src/github-git-http.ts
CHANGED
|
@@ -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 ??
|
|
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]));
|
package/src/github-mirror-ui.ts
CHANGED
|
@@ -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)
|
|
16
|
-
const CLIENT_CSS = () => new URL('../client/github-mirror.css', import.meta.url)
|
|
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">
|
package/src/github-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. */
|