@volter/twin-github 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/src/semantics/graphql.ts
CHANGED
|
@@ -284,7 +284,12 @@ export function githubGraphql(): { resolvers: Record<string, FieldResolver>; sto
|
|
|
284
284
|
};
|
|
285
285
|
return {
|
|
286
286
|
resolvers: {
|
|
287
|
-
'
|
|
287
|
+
// the token's own account; the World's shared token is octocat's, named The Octocat until its profile says otherwise
|
|
288
|
+
'Query.viewer': (_s, _args, c) => {
|
|
289
|
+
const login = gh(ctxOf(c)).caller ?? GITHUB_WORLD_USER;
|
|
290
|
+
const name = state(c).userProfiles[login.toLowerCase()]?.name;
|
|
291
|
+
return { __typename: 'User', login, name: name ?? (login === GITHUB_WORLD_USER ? 'The Octocat' : null) };
|
|
292
|
+
},
|
|
288
293
|
'Query.node': queryNode,
|
|
289
294
|
'Query.repository': (_s, args, c) => {
|
|
290
295
|
const repo = `${String(args.owner)}/${String(args.name)}`;
|
|
@@ -419,7 +424,7 @@ export function githubGraphql(): { resolvers: Record<string, FieldResolver>; sto
|
|
|
419
424
|
const login = String(args.login ?? '');
|
|
420
425
|
const st = state(c);
|
|
421
426
|
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);
|
|
422
|
-
return known ? { __typename: 'User', login, name: null, id: `U_${login}` } : fail(`Could not resolve to a User with the login of '${login}'.`, 'NOT_FOUND');
|
|
427
|
+
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');
|
|
423
428
|
},
|
|
424
429
|
'Query.organization': (_s, args, c) => {
|
|
425
430
|
const org = state(c).orgs.find((o) => o.login === String(args.login ?? ''));
|
package/src/semantics/shared.ts
CHANGED
|
@@ -176,6 +176,6 @@ export const writer = (ctx: SemanticsContext): GithubWrite => (w) => put(ctx, w)
|
|
|
176
176
|
* the noreply address GitHub gives an account that keeps its email private. */
|
|
177
177
|
export function identityOf(g: Gh): { name: string; email: string } {
|
|
178
178
|
const login = g.caller ?? 'octocat';
|
|
179
|
-
const profile =
|
|
179
|
+
const profile = g.state().userProfiles[login.toLowerCase()];
|
|
180
180
|
return { name: String(profile?.name ?? login), email: `${twinUserId(login)}+${login}@users.noreply.github.com` };
|
|
181
181
|
}
|
package/src/semantics/users.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
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.ts';
|
|
5
8
|
import { createHash } from 'node:crypto';
|
|
6
9
|
import type { Semantics } from '@volter/world-core';
|
|
7
10
|
import {
|
|
8
|
-
githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId,
|
|
11
|
+
accountOf, canonicalLogin, emailsOf, githubActivityEvents, githubNotFound, paginate, seedUser, toGithubOrg, toGithubRepo, toGithubUser, toGithubUserEmail, toGithubUserGpgKey, toGithubUserSshKey, toSimpleUser, twinUserId,
|
|
9
12
|
} from '../github-twin.ts';
|
|
10
|
-
import { asView, gh, nextSeq, put, send } from './shared.ts';
|
|
13
|
+
import { asView, atomic, gh, nextSeq, put, send } from './shared.ts';
|
|
14
|
+
|
|
15
|
+
/** An app's installation token speaks for the app, which has no profile or addresses of its own to change: GitHub
|
|
16
|
+
* answers its writes to the authenticated user 403 "Resource not accessible by integration". */
|
|
17
|
+
const asApp = (ctx: Parameters<Semantics>[0]): Response | undefined =>
|
|
18
|
+
(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;
|
|
11
19
|
|
|
12
20
|
/** GET /users/:login/events[/public] and /users/:login/received_events[/public]: events = activity the
|
|
13
21
|
* user PERFORMED (actor); received_events = activity in repos the user OWNS. Both filter the SAME
|
|
@@ -26,8 +34,9 @@ const userEvents = (received: boolean, only: 'public' | undefined): Semantics =>
|
|
|
26
34
|
export function usersSemantics(): Record<string, Semantics> {
|
|
27
35
|
const usersGetByUsername: Semantics = async (ctx) => {
|
|
28
36
|
const { seg, state } = gh(ctx);
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
// a login in another case names the same account, answered as it is spelled (and with its id)
|
|
38
|
+
const login = canonicalLogin(decodeURIComponent(seg[1]!), ctx.root);
|
|
39
|
+
return ctx.reply(toGithubUser(accountOf(state(), login)));
|
|
31
40
|
};
|
|
32
41
|
const reposListForAuthenticatedUser: Semantics = async (ctx) => {
|
|
33
42
|
const { caller, query, state } = gh(ctx);
|
|
@@ -94,11 +103,11 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
94
103
|
};
|
|
95
104
|
const usersListEmailsForAuthenticatedUser: Semantics = async (ctx) => {
|
|
96
105
|
const { query, state } = gh(ctx);
|
|
97
|
-
return ctx.reply(paginate(state()
|
|
106
|
+
return ctx.reply(paginate(emailsOf(state(), me(ctx)), query).map(toGithubUserEmail));
|
|
98
107
|
};
|
|
99
108
|
const usersListPublicEmailsForAuthenticatedUser: Semantics = async (ctx) => {
|
|
100
109
|
const { query, state } = gh(ctx);
|
|
101
|
-
return ctx.reply(paginate(state().
|
|
110
|
+
return ctx.reply(paginate(emailsOf(state(), me(ctx)).filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
|
|
102
111
|
};
|
|
103
112
|
const usersListPublicSshKeysForAuthenticatedUser: Semantics = async (ctx) => {
|
|
104
113
|
const { query, state } = gh(ctx);
|
|
@@ -127,16 +136,25 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
127
136
|
return send(ctx, state().blockedUsers.has(decodeURIComponent(seg[2]!)) ? { status: 204, body: undefined } : githubNotFound());
|
|
128
137
|
};
|
|
129
138
|
const usersGetAuthenticated: Semantics = async (ctx) => {
|
|
130
|
-
const {
|
|
131
|
-
|
|
132
|
-
return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
|
|
139
|
+
const { state } = gh(ctx);
|
|
140
|
+
return ctx.reply(toGithubUser(accountOf(state(), me(ctx))));
|
|
133
141
|
};
|
|
134
142
|
const usersUpdateAuthenticated: Semantics = async (ctx) => {
|
|
143
|
+
const refused = asApp(ctx);
|
|
144
|
+
if (refused) return refused;
|
|
135
145
|
const { body, state } = gh(ctx);
|
|
136
146
|
const fields: Record<string, unknown> = {};
|
|
137
147
|
for (const k of ['name', 'bio', 'company', 'blog', 'location', 'email'] as const) if (body[k] !== undefined) fields[k] = body[k];
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
const login = me(ctx);
|
|
149
|
+
// the public email is one of the account's own verified addresses (GitHub's profile settings offer only those; the
|
|
150
|
+
// REST docs name the field "The publicly visible email address of the user" and a 422 for a validation failure);
|
|
151
|
+
// empty or null shows none. The 422's wording is the twin's.
|
|
152
|
+
if (typeof fields.email === 'string' && fields.email !== '' && !emailsOf(state(), login).some((e) => e.verified && e.email.toLowerCase() === String(fields.email).toLowerCase())) {
|
|
153
|
+
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);
|
|
154
|
+
}
|
|
155
|
+
if (fields.email !== undefined && fields.email !== null && typeof fields.email !== 'string') return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'User', code: 'invalid', field: 'email' }] }, 422);
|
|
156
|
+
await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: `user_profile:${login.toLowerCase()}`, fields: { ...fields, login } });
|
|
157
|
+
return ctx.reply(toGithubUser(accountOf(state(), login)));
|
|
140
158
|
};
|
|
141
159
|
const usersCreatePublicSshKeyForAuthenticatedUser: Semantics = async (ctx) => {
|
|
142
160
|
const { body, occurredAt, state } = gh(ctx);
|
|
@@ -201,11 +219,12 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
201
219
|
'users/list-blocked-by-authenticated-user': usersListBlockedByAuthenticatedUser,
|
|
202
220
|
// "Check if a user is blocked by the authenticated user" → 204 blocked / 404 not.
|
|
203
221
|
'users/check-blocked': usersCheckBlocked,
|
|
204
|
-
// the token's own
|
|
222
|
+
// the token's own account (a personal token, an OAuth app's token for the person who consented) under the
|
|
223
|
+
// profile that person edited; the World's shared token is octocat's
|
|
205
224
|
'users/get-authenticated': usersGetAuthenticated,
|
|
206
225
|
'users/update-authenticated': usersUpdateAuthenticated,
|
|
207
|
-
'users/add-email-for-authenticated-user': async (ctx) => emails(ctx, false),
|
|
208
|
-
'users/delete-email-for-authenticated-user': async (ctx) => emails(ctx, true),
|
|
226
|
+
'users/add-email-for-authenticated-user': async (ctx) => asApp(ctx) ?? emails(ctx, false),
|
|
227
|
+
'users/delete-email-for-authenticated-user': async (ctx) => asApp(ctx) ?? emails(ctx, true),
|
|
209
228
|
'users/create-public-ssh-key-for-authenticated-user': usersCreatePublicSshKeyForAuthenticatedUser,
|
|
210
229
|
'users/delete-public-ssh-key-for-authenticated-user': usersDeletePublicSshKeyForAuthenticatedUser,
|
|
211
230
|
'users/create-gpg-key-for-authenticated-user': usersCreateGpgKeyForAuthenticatedUser,
|
|
@@ -219,24 +238,45 @@ export function usersSemantics(): Record<string, Semantics> {
|
|
|
219
238
|
};
|
|
220
239
|
}
|
|
221
240
|
|
|
222
|
-
/** POST /user/emails adds, DELETE removes; both take `{emails}` or a bare array. The
|
|
223
|
-
*
|
|
241
|
+
/** POST /user/emails adds, DELETE removes, on the caller's own account; both take `{emails}` or a bare array. The
|
|
242
|
+
* first address an account adds is primary and public. An address is one account's: adding one another account holds
|
|
243
|
+
* is refused (422, the twin's wording: GitHub's docs name only the status), each address checked and claimed in one
|
|
244
|
+
* atomic step, and removing one leaves every other account's addresses alone. The primary address is not removed
|
|
245
|
+
* (422): GitHub has its person make another address primary first (github.com/orgs/community/discussions/62557; the
|
|
246
|
+
* wording is the twin's). */
|
|
224
247
|
async function emails(ctx: Parameters<Semantics>[0], remove: boolean): Promise<Response> {
|
|
225
248
|
const { body, state } = gh(ctx);
|
|
249
|
+
const login = me(ctx);
|
|
226
250
|
const list: string[] = Array.isArray(body.emails) ? body.emails.map(String) : Array.isArray(body) ? (body as string[]).map(String) : [];
|
|
227
251
|
if (!list.length) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'Email', field: 'emails', code: 'missing_field' }] }, 422);
|
|
228
252
|
const st = state();
|
|
253
|
+
const mine = (e: { login: string }) => e.login.toLowerCase() === login.toLowerCase();
|
|
254
|
+
const held = (email: string) => st.userEmails.find((e) => e.email.toLowerCase() === email.toLowerCase());
|
|
255
|
+
if (!remove && list.some((email) => { const h = held(email); return h && !mine(h); })) {
|
|
256
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'email is already in use' }] }, 422);
|
|
257
|
+
}
|
|
258
|
+
if (remove && list.some((email) => { const h = held(email); return h && mine(h) && h.primary; })) {
|
|
259
|
+
return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'the primary email address cannot be removed' }] }, 422);
|
|
260
|
+
}
|
|
261
|
+
const hadNone = emailsOf(st, login).length === 0;
|
|
262
|
+
const claim = atomic(ctx);
|
|
229
263
|
for (const email of list) {
|
|
230
|
-
const
|
|
264
|
+
const h = held(email);
|
|
265
|
+
const subjectId = `useremail:${h?.email ?? email}`;
|
|
231
266
|
if (remove) {
|
|
232
|
-
await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email } });
|
|
233
|
-
} else {
|
|
234
|
-
const isFirst =
|
|
235
|
-
|
|
267
|
+
if (h && mine(h)) await put(ctx, { operation: 'user_email.delete', subjectType: 'user_email', subjectId, fields: { deleted: true, email: h.email } });
|
|
268
|
+
} else if (!h) {
|
|
269
|
+
const isFirst = hadNone && email === list[0];
|
|
270
|
+
const lost = await claim(() => {
|
|
271
|
+
const now = state().userEmails.find((e) => e.email.toLowerCase() === email.toLowerCase());
|
|
272
|
+
if (now) return { value: !mine(now) };
|
|
273
|
+
return { value: false, write: { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private', login } } };
|
|
274
|
+
});
|
|
275
|
+
if (lost) return ctx.reply({ message: 'Validation Failed', errors: [{ resource: 'UserEmail', code: 'custom', field: 'email', message: 'email is already in use' }] }, 422);
|
|
236
276
|
}
|
|
237
277
|
}
|
|
238
278
|
if (remove) return send(ctx, { status: 204, body: undefined });
|
|
239
|
-
return ctx.reply(state().
|
|
279
|
+
return ctx.reply(emailsOf(state(), login).map(toGithubUserEmail), 201);
|
|
240
280
|
}
|
|
241
281
|
|
|
242
282
|
async function block(ctx: Parameters<Semantics>[0], blocking: boolean): Promise<Response> {
|