@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.
@@ -284,7 +284,12 @@ export function githubGraphql(): { resolvers: Record<string, FieldResolver>; sto
284
284
  };
285
285
  return {
286
286
  resolvers: {
287
- 'Query.viewer': (_s, _args, c) => ({ __typename: 'User', login: 'octocat', name: state(c).userProfile.name ?? 'The Octocat' }),
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 ?? ''));
@@ -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 = login === 'octocat' ? g.state().userProfile : undefined;
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
  }
@@ -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
- // 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.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
- const login = decodeURIComponent(seg[1]!);
30
- return ctx.reply(toGithubUser(login === 'octocat' ? { ...seedUser('octocat'), ...state().userProfile, login } : seedUser(login)));
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().userEmails, query).map(toGithubUserEmail));
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().userEmails.filter((e) => e.visibility === 'public'), query).map(toGithubUserEmail));
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 { caller, state } = gh(ctx);
131
- if (caller) return ctx.reply(toGithubUser(seedUser(caller)));
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
- await put(ctx, { operation: 'user_profile.update', subjectType: 'user_profile', subjectId: 'user_profile:octocat', fields });
139
- return ctx.reply(toGithubUser({ ...seedUser('octocat'), ...state().userProfile, login: 'octocat' }));
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 user when the twin's user door minted it; else the deterministic octocat
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 first address a
223
- * user adds is primary and public. */
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 subjectId = `useremail:${email}`;
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 = st.userEmails.length === 0 && email === list[0];
235
- await put(ctx, { operation: 'user_email.upsert', subjectType: 'user_email', subjectId, fields: { email, primary: isFirst, verified: true, visibility: isFirst ? 'public' : 'private' } });
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().userEmails.map(toGithubUserEmail), 201);
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> {