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