meta-sovereign 0.49.2 → 0.49.4

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.
Files changed (45) hide show
  1. package/js/src/server/aggregate.js +16 -2
  2. package/js/src/server/github-service.js +383 -0
  3. package/js/src/server/index.js +13 -9
  4. package/js/src/server/routes-github.js +98 -0
  5. package/js/src/server/routes-messages.js +8 -6
  6. package/js/src/server/routes-mutating.js +15 -32
  7. package/js/src/server/routes-pull.js +163 -0
  8. package/js/src/sources/auto-import/files.js +5 -1
  9. package/js/src/sources/auto-import/recipes.js +18 -1
  10. package/js/src/sources/github-actions.js +162 -0
  11. package/js/src/sources/github-http.js +266 -0
  12. package/js/src/sources/github-inbox.js +312 -0
  13. package/js/src/sources/github-ref.js +106 -0
  14. package/js/src/sources/github-sync.js +124 -0
  15. package/js/src/sources/github.js +79 -193
  16. package/js/src/sources/pull-runner.js +180 -0
  17. package/js/src/sources/vk-html-archive.js +268 -0
  18. package/js/src/sources/vk-limits.js +119 -0
  19. package/js/src/sources/vk-longpoll.js +333 -0
  20. package/js/src/sources/vk-sync.js +138 -0
  21. package/js/src/sources/vk.js +224 -71
  22. package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
  23. package/js/src/storage/migrations/0004-github-external-ids.js +115 -0
  24. package/js/src/storage/migrations/index.js +4 -2
  25. package/js/src/web/app.css +43 -0
  26. package/js/src/web/chat-media.js +3 -0
  27. package/js/src/web/chat-remote-media.js +111 -0
  28. package/js/src/web/connections/fetch-now.js +81 -0
  29. package/js/src/web/connections/github-inbox.js +236 -0
  30. package/js/src/web/connections/index.js +6 -0
  31. package/js/src/web/dom.js +17 -0
  32. package/js/src/web/locales/en-chat-account.js +17 -0
  33. package/js/src/web/locales/en-github.js +30 -0
  34. package/js/src/web/locales/en.js +2 -0
  35. package/js/src/web/locales/hi-chat-account.js +17 -0
  36. package/js/src/web/locales/hi-github.js +29 -0
  37. package/js/src/web/locales/hi.js +2 -0
  38. package/js/src/web/locales/ru-chat-account.js +17 -0
  39. package/js/src/web/locales/ru-github.js +30 -0
  40. package/js/src/web/locales/ru.js +2 -0
  41. package/js/src/web/locales/zh-chat-account.js +16 -0
  42. package/js/src/web/locales/zh-github.js +28 -0
  43. package/js/src/web/locales/zh.js +2 -0
  44. package/js/src/web/message-parts.js +34 -9
  45. package/package.json +5 -1
@@ -27,13 +27,27 @@ const blank = (id) => ({
27
27
  open: null,
28
28
  });
29
29
 
30
+ // A message time as an ISO string. VK and WhatsApp keep unix seconds (a
31
+ // number), which used to break the contact sort and `/api/status` (R-Z8).
32
+ const seenAt = (value) => {
33
+ if (value === null || value === undefined || value === '') {
34
+ return null;
35
+ }
36
+ const n = Number(value);
37
+ if (Number.isFinite(n) && !/[-:T]/.test(String(value))) {
38
+ return new Date(n < 1e12 ? n * 1000 : n).toISOString();
39
+ }
40
+ return String(value);
41
+ };
42
+
30
43
  const addMessage = (entry, m) => {
31
44
  entry.networks.add(m.source);
32
45
  entry.chats.add(m.chat);
33
46
  entry.messageCount += 1;
34
- const latest = !entry.lastSeen || (m.timestamp ?? '') > entry.lastSeen;
47
+ const at = seenAt(m.timestamp);
48
+ const latest = !entry.lastSeen || (at ?? '') > entry.lastSeen;
35
49
  if (latest) {
36
- entry.lastSeen = m.timestamp ?? null;
50
+ entry.lastSeen = at;
37
51
  entry.name = m.senderName ?? entry.name;
38
52
  } else {
39
53
  entry.name ??= m.senderName ?? null;
@@ -0,0 +1,383 @@
1
+ /**
2
+ * GitHub as a full inbox, server side (R-Z11).
3
+ *
4
+ * Which repositories to pull and how often is a saved setting
5
+ * (`settings:github`); where each pull stopped is a cursor link
6
+ * (`cursor:github:<owner/repo>`) written only after the pulled links were
7
+ * stored (R-Z14), so a failed write repeats the same window. A repository
8
+ * that fails does not stop the others; a rate limit stops the run and says
9
+ * when to come back. Notifications are fetched into `notification:github:*`
10
+ * links and marked read only by an explicit call.
11
+ *
12
+ * There is no timer here: `pullJob()` is the job the shared pull runner
13
+ * (../sources/pull-runner.js, R-Z8) registers as `github`, so "Fetch now" and
14
+ * the schedule are the runner's. The schedule is off until the user picks
15
+ * repositories and an interval.
16
+ */
17
+
18
+ import { createGithubLive } from '../sources/github.js';
19
+ import { stampSourceLink } from '../sources/index.js';
20
+ import { loadPullState, savePullState } from '../sources/pull-runner.js';
21
+ import { safeMessage } from './redact.js';
22
+
23
+ const SETTINGS_ID = 'settings:github';
24
+ const cursorId = (key) => `cursor:github:${key}`;
25
+ const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
26
+ const MAX_REPOS = 200;
27
+ const MIN_INTERVAL_MINUTES = 5;
28
+ const MAX_INTERVAL_MINUTES = 24 * 60;
29
+
30
+ export const DEFAULT_SETTINGS = Object.freeze({
31
+ repos: [],
32
+ intervalMinutes: 0,
33
+ notifications: false,
34
+ discussions: false,
35
+ });
36
+
37
+ const secretValue = (link) =>
38
+ link?.token ?? link?.value ?? link?.body ?? link?.text ?? null;
39
+
40
+ export const readGithubToken = async (store, { secretId } = {}) => {
41
+ const ids = [
42
+ secretId,
43
+ 'secret:github:access-token',
44
+ 'secret:github:token',
45
+ ].filter(Boolean);
46
+ for (const id of ids) {
47
+ const token = secretValue(await store.get(id));
48
+ if (token) {
49
+ return token;
50
+ }
51
+ }
52
+ return null;
53
+ };
54
+
55
+ export const githubLive = async (store, body, ctx) => {
56
+ const token = body.token ?? (await readGithubToken(store, body));
57
+ const factory = ctx?.githubLiveFactory ?? createGithubLive;
58
+ const live = factory({
59
+ token,
60
+ owner: body.owner ?? null,
61
+ repo: body.repo ?? null,
62
+ });
63
+ return { token, live };
64
+ };
65
+
66
+ const invalid = (message) =>
67
+ Object.assign(new Error(message), { code: 'bad-request' });
68
+
69
+ /** The settings a request may save, or a `bad-request` error. */
70
+ export const checkSettings = (input = {}) => {
71
+ const repos = input.repos ?? [];
72
+ if (
73
+ !Array.isArray(repos) ||
74
+ repos.length > MAX_REPOS ||
75
+ !repos.every((r) => typeof r === 'string' && REPO.test(r))
76
+ ) {
77
+ throw invalid('repos must be a list of owner/repo names');
78
+ }
79
+ const interval = Number(input.intervalMinutes ?? 0);
80
+ if (
81
+ !Number.isInteger(interval) ||
82
+ (interval !== 0 &&
83
+ (interval < MIN_INTERVAL_MINUTES || interval > MAX_INTERVAL_MINUTES))
84
+ ) {
85
+ throw invalid(
86
+ `intervalMinutes must be 0 (off) or ${MIN_INTERVAL_MINUTES}..${MAX_INTERVAL_MINUTES}`
87
+ );
88
+ }
89
+ return {
90
+ repos: [...new Set(repos)],
91
+ intervalMinutes: interval,
92
+ notifications: input.notifications === true,
93
+ discussions: input.discussions === true,
94
+ };
95
+ };
96
+
97
+ const errorEntry = (error) => ({
98
+ error: safeMessage(error),
99
+ code: error.code ?? 'github-error',
100
+ ...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
101
+ });
102
+
103
+ const putAll = async (store, links) => {
104
+ for (const link of links) {
105
+ await store.put(stampSourceLink(link, 'github'));
106
+ }
107
+ };
108
+
109
+ // One repository: pull, store, then (only then) move its cursor.
110
+ const fetchRepo = async (env, live, token, key, options) => {
111
+ const { store, now } = env;
112
+ const [owner, repo] = key.split('/');
113
+ const cursor = (await store.get(cursorId(key))) ?? {};
114
+ const result = await live.pullMessages({
115
+ owner,
116
+ repo,
117
+ token,
118
+ since: cursor.since ?? null,
119
+ etags: cursor.etags ?? {},
120
+ });
121
+ const found = options.discussions
122
+ ? await fetchDiscussionsOf(env, live, { owner, repo, token, cursor })
123
+ : { links: [], since: cursor.discussionsSince ?? null, note: null };
124
+ await putAll(store, [...result.links, ...found.links]);
125
+ // After the store write: a failed write above leaves the cursor as it was.
126
+ await store.put({
127
+ id: cursorId(key),
128
+ source: 'github',
129
+ since: result.since,
130
+ etags: result.etags,
131
+ discussionsSince: found.since,
132
+ fetchedAt: new Date(now()).toISOString(),
133
+ });
134
+ return {
135
+ repo: key,
136
+ imported: result.links.length + found.links.length,
137
+ rawCount: result.rawCount,
138
+ notModified: result.notModified,
139
+ discussions: options.discussions ? found.links.length : null,
140
+ ...(found.note ? { discussionsNote: found.note } : {}),
141
+ };
142
+ };
143
+
144
+ // Discussions need a token with access; without it the repository still
145
+ // pulls and the reason is reported (a rate limit still stops the run).
146
+ const fetchDiscussionsOf = async (
147
+ env,
148
+ live,
149
+ { owner, repo, token, cursor }
150
+ ) => {
151
+ const startedAt = new Date(env.now()).toISOString();
152
+ try {
153
+ const found = await live.fetchDiscussions({
154
+ owner,
155
+ repo,
156
+ token,
157
+ since: cursor.discussionsSince ?? null,
158
+ });
159
+ return { links: found.links, since: startedAt, note: null };
160
+ } catch (error) {
161
+ if (error.code === 'rate-limited') {
162
+ throw error;
163
+ }
164
+ return {
165
+ links: [],
166
+ since: cursor.discussionsSince ?? null,
167
+ note: errorEntry(error),
168
+ };
169
+ }
170
+ };
171
+
172
+ // The notification inbox, never faster than the interval GitHub asked for.
173
+ const fetchNotes = async ({ store, now }, live, token) => {
174
+ const id = cursorId('notifications');
175
+ const cursor = (await store.get(id)) ?? {};
176
+ const next = Number(cursor.nextPollAt ?? 0);
177
+ if (next > now()) {
178
+ return { skipped: 'poll-interval', nextPollAt: next };
179
+ }
180
+ const startedAt = new Date(now()).toISOString();
181
+ const found = await live.fetchNotifications({
182
+ token,
183
+ since: cursor.since ?? null,
184
+ lastModified: cursor.lastModified ?? null,
185
+ });
186
+ await putAll(store, found.links);
187
+ const pollInterval = found.pollInterval ?? cursor.pollInterval ?? 60;
188
+ await store.put({
189
+ id,
190
+ source: 'github',
191
+ since: found.notModified ? (cursor.since ?? startedAt) : startedAt,
192
+ lastModified: found.lastModified,
193
+ pollInterval,
194
+ nextPollAt: now() + pollInterval * 1000,
195
+ });
196
+ return { imported: found.links.length, notModified: found.notModified };
197
+ };
198
+
199
+ const storedNotifications = async (store) => {
200
+ const all = await store.query(
201
+ (l) => typeof l?.id === 'string' && l.id.startsWith('notification:github:')
202
+ );
203
+ return all
204
+ .filter((l) => !l.deleted)
205
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
206
+ };
207
+
208
+ // A repository that fails does not stop the others; a rate limit does.
209
+ const runAll = async (env, saved, wanted, { token, live }, log) => {
210
+ const out = { repos: [], notifications: null, rateLimited: null };
211
+ const limited = (error) => {
212
+ if (error.code === 'rate-limited') {
213
+ out.rateLimited = { retryAfter: error.retryAfter };
214
+ }
215
+ };
216
+ for (const key of wanted) {
217
+ if (out.rateLimited) {
218
+ out.repos.push({ repo: key, skipped: 'rate-limited' });
219
+ continue;
220
+ }
221
+ try {
222
+ out.repos.push(await fetchRepo(env, live, token, key, saved));
223
+ } catch (error) {
224
+ out.repos.push({ repo: key, ...errorEntry(error) });
225
+ limited(error);
226
+ log?.({ level: 'warn', event: 'github_fetch_failed', repo: key });
227
+ }
228
+ }
229
+ if (saved.notifications && !out.rateLimited) {
230
+ out.notifications = await fetchNotes(env, live, token).catch((error) => {
231
+ limited(error);
232
+ return errorEntry(error);
233
+ });
234
+ }
235
+ out.imported = out.repos.reduce((n, r) => n + (r.imported ?? 0), 0);
236
+ return out;
237
+ };
238
+
239
+ /**
240
+ * @param {object} options
241
+ * @param {object} options.store
242
+ * @param {object} [options.ctx] server context (`githubLiveFactory`)
243
+ * @param {() => number} [options.now]
244
+ * @param {Function} [options.log]
245
+ */
246
+ export const createGithubService = ({
247
+ store,
248
+ ctx = {},
249
+ now = () => Date.now(),
250
+ log = null,
251
+ }) => {
252
+ const env = { store, now };
253
+ let running = null;
254
+
255
+ const settings = async () => {
256
+ const { repos, intervalMinutes, notifications, discussions } = {
257
+ ...DEFAULT_SETTINGS,
258
+ ...((await store.get(SETTINGS_ID)) ?? {}),
259
+ };
260
+ return { repos, intervalMinutes, notifications, discussions };
261
+ };
262
+
263
+ const saveSettings = async (input) => {
264
+ const next = checkSettings(input);
265
+ await store.put({ id: SETTINGS_ID, source: 'github', ...next });
266
+ return next;
267
+ };
268
+
269
+ const withLive = async () => {
270
+ const { token, live } = await githubLive(store, {}, ctx);
271
+ if (!token) {
272
+ throw Object.assign(new Error('save a GitHub access token first'), {
273
+ code: 'no-token',
274
+ });
275
+ }
276
+ return { token, live };
277
+ };
278
+
279
+ const listRepos = async () => {
280
+ const { token, live } = await withLive();
281
+ const repos = await live.listRepos({ token });
282
+ const chosen = new Set((await settings()).repos);
283
+ return repos.map((r) => ({
284
+ fullName: r.full_name,
285
+ private: Boolean(r.private),
286
+ description: r.description ?? '',
287
+ updatedAt: r.pushed_at ?? r.updated_at ?? null,
288
+ selected: chosen.has(r.full_name),
289
+ }));
290
+ };
291
+
292
+ const run = async (options = {}) => {
293
+ const saved = await settings();
294
+ const access = await withLive();
295
+ const wanted = options.repos?.length ? options.repos : saved.repos;
296
+ return runAll(env, saved, wanted, access, log);
297
+ };
298
+
299
+ // One run at a time: a second request waits for and shares the first.
300
+ const fetchNow = (options) => {
301
+ running ??= run(options).finally(() => {
302
+ running = null;
303
+ });
304
+ return running;
305
+ };
306
+
307
+ const notifications = () => storedNotifications(store);
308
+
309
+ // Marking read changes GitHub: only on an explicit call (never by fetching).
310
+ const markRead = async ({ threadId = null, all = false } = {}) => {
311
+ const { token, live } = await withLive();
312
+ if (all) {
313
+ await live.markAllRead({ token });
314
+ for (const link of await notifications()) {
315
+ await store.put({ ...link, unread: false });
316
+ }
317
+ return { ok: true, all: true };
318
+ }
319
+ await live.markThreadRead(threadId, { token });
320
+ const link = await store.get(`notification:github:${threadId}`);
321
+ if (link) {
322
+ await store.put({ ...link, unread: false });
323
+ }
324
+ return { ok: true, threadId: String(threadId) };
325
+ };
326
+
327
+ // The runner's job. A rate limit is remembered (`notBefore` in the pull
328
+ // state), so neither the schedule nor a click asks GitHub again before it
329
+ // allows, and the wait is reported instead of looped on.
330
+ const waitLeft = async () =>
331
+ Math.ceil(
332
+ (Number((await loadPullState(store, 'github')).notBefore ?? 0) - now()) /
333
+ 1000
334
+ );
335
+ const limitError = (seconds) =>
336
+ Object.assign(new Error('GitHub rate limit reached; try again later'), {
337
+ code: 'rate-limited',
338
+ retryAfter: Math.max(1, seconds),
339
+ });
340
+ const pullJob = () => ({
341
+ ready: async () => Boolean(await readGithubToken(store)),
342
+ intervalMs: 60_000,
343
+ firstDelayMs: 30_000,
344
+ due: async () => {
345
+ const saved = await settings();
346
+ if (!saved.intervalMinutes || saved.repos.length === 0) {
347
+ return false;
348
+ }
349
+ const state = await loadPullState(store, 'github');
350
+ const last = Date.parse(state.lastOkAt ?? '') || 0;
351
+ return (
352
+ now() >=
353
+ Math.max(last + saved.intervalMinutes * 60_000, state.notBefore ?? 0)
354
+ );
355
+ },
356
+ run: async () => {
357
+ const left = await waitLeft();
358
+ if (left > 0) {
359
+ throw limitError(left);
360
+ }
361
+ const out = await fetchNow({});
362
+ if (out.rateLimited) {
363
+ const seconds = out.rateLimited.retryAfter ?? 60;
364
+ await savePullState(store, 'github', {
365
+ notBefore: now() + seconds * 1000,
366
+ });
367
+ throw limitError(seconds);
368
+ }
369
+ await savePullState(store, 'github', { notBefore: 0 });
370
+ return { imported: out.imported };
371
+ },
372
+ });
373
+
374
+ return {
375
+ settings,
376
+ saveSettings,
377
+ listRepos,
378
+ fetchNow,
379
+ notifications,
380
+ markRead,
381
+ pullJob,
382
+ };
383
+ };
@@ -104,6 +104,7 @@ import { createGitHubCheck } from '../sources/github-check.js';
104
104
  import { handleMetrics } from './metrics.js';
105
105
  import { jsonLog } from './log.js';
106
106
  import { listenLocal } from './local-tls.js';
107
+ import { handlePullRoutes, pullContext } from './routes-pull.js';
107
108
  import { createPeer, attachSyncWebSocket } from '../sync/index.js';
108
109
  import { attachSignaling } from '../sync/webrtc-signaling.js';
109
110
  import { registerDefaultHandlers } from './handlers-bootstrap.js';
@@ -148,7 +149,9 @@ export const CSP =
148
149
  "default-src 'self'; " +
149
150
  "script-src 'self' 'wasm-unsafe-eval'; " +
150
151
  "style-src 'self'; " +
151
- "img-src 'self' data: blob: https://vk.com; " +
152
+ "img-src 'self' data: blob: https://vk.com " +
153
+ // VK's picture CDNs, for photos and stickers in VK chats (R-Z8).
154
+ 'https://*.userapi.com https://*.vk-cdn.net https://*.vkuserphoto.ru; ' +
152
155
  'frame-src https:; ' +
153
156
  "connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
154
157
  "worker-src 'self'; " +
@@ -576,6 +579,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
576
579
  () => ctx.clientLog?.(req, res, p, ctx) ?? false,
577
580
  () => handleAutoImportRoutes(store, req, res, p, ctx),
578
581
  () => handleWhatsAppRoutes(store, req, res, p, ctx),
582
+ () => handlePullRoutes(store, req, res, p, ctx),
579
583
  () => handleTelegramRoutes(req, res, p, ctx),
580
584
  () => handleMessageRoutes(store, req, res, p, url, ctx),
581
585
  () => handleEmailGets(store, req, res, p, url, ctx),
@@ -787,15 +791,14 @@ export const startServer = async ({
787
791
  telegramExtraBuild,
788
792
  // Tests and demos pass a fake, so GitHub is never asked (R-Y53).
789
793
  githubCheck,
790
- // The hosted app and whether this server also serves its own copy
791
- // (./hosted-ui.js, R-Y58 and R-Y59).
794
+ // The hosted app, and whether this serves its own copy (./hosted-ui.js).
792
795
  uiUrl,
793
796
  localUi,
794
- // HTTPS on the same port for Safari (./local-tls.js, R-Y64); off
795
- // unless asked for, `serve` turns it on.
797
+ // HTTPS on the same port for Safari (./local-tls.js, R-Y64); `serve` sets it.
796
798
  tls,
797
799
  // Tests: a fake `(dir) => bytes|null` for the free disk space (R-Z14).
798
800
  diskFree,
801
+ pulls,
799
802
  } = {}) => {
800
803
  const { store, resolvedArchiveDir, flush, storeHealth } = await initStore({
801
804
  providedStore,
@@ -853,13 +856,13 @@ export const startServer = async ({
853
856
  later
854
857
  ),
855
858
  chatIndex,
859
+ ...pullContext(store, log, pulls, later),
856
860
  };
857
861
  const server = createHttpServer(store, ctx, log);
858
862
  const sockets = trackSockets(server);
859
- let bus = null;
860
- if (enableHandlers) {
861
- bus = registerDefaultHandlers(store, { browser: () => ctx.send });
862
- }
863
+ const bus = enableHandlers
864
+ ? registerDefaultHandlers(store, { browser: () => ctx.send })
865
+ : null;
863
866
  const { wsHandle, signaling } = attachSockets(server, ctx, store, {
864
867
  enableSync,
865
868
  node,
@@ -903,6 +906,7 @@ export const startServer = async ({
903
906
  ctx.closeAutomations().catch(() => {});
904
907
  ctx.autoImport?.close();
905
908
  ctx.whatsappSignIn?.stop().catch(() => {});
909
+ ctx.stopPulls?.();
906
910
  ctx.chatIndex.close();
907
911
  stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
908
912
  wsHandle?.close();
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The GitHub inbox routes (R-Z11), all under /api/github/:
3
+ *
4
+ * GET repos the user's repositories, `selected` marked
5
+ * GET settings { repos, intervalMinutes, notifications,
6
+ * discussions }
7
+ * POST settings the same, saved
8
+ * GET notifications the stored notification inbox
9
+ * POST notifications/read { threadId } | { all: true } -- the only
10
+ * thing that marks anything read on GitHub
11
+ *
12
+ * "Fetch now" and the schedule are the shared pull runner's: POST /api/pull
13
+ * {source: "github"} (./routes-pull.js). Pull, clone and post-comment stay in
14
+ * ./routes-mutating.js. Answers are
15
+ * `{ ok, ... }` or the uniform `{ ok: false, error, code }`.
16
+ */
17
+
18
+ import { fail, json, readBody } from './util.js';
19
+ import { isTrustedMutation } from './routes-auto-import.js';
20
+ import { safeMessage } from './redact.js';
21
+
22
+ const STATUS = {
23
+ 'bad-request': 400,
24
+ 'no-token': 400,
25
+ 'rate-limited': 429,
26
+ 'discussions-unavailable': 403,
27
+ };
28
+
29
+ const failure = (res, error) => {
30
+ const code = error.code ?? 'github-error';
31
+ const status = STATUS[code] ?? (error.status === 401 ? 401 : 502);
32
+ return (
33
+ json(res, status, {
34
+ ok: false,
35
+ error: safeMessage(error),
36
+ code,
37
+ ...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
38
+ }) ?? true
39
+ );
40
+ };
41
+
42
+ const guarded = async (res, work) => {
43
+ try {
44
+ return (await work()) ?? true;
45
+ } catch (error) {
46
+ return failure(res, error);
47
+ }
48
+ };
49
+
50
+ const mutation = async (req, res, ctx, work) => {
51
+ if (!isTrustedMutation(req, ctx.allowOrigins)) {
52
+ return fail(
53
+ res,
54
+ 403,
55
+ 'forbidden',
56
+ 'GitHub settings must be changed from the meta-sovereign app'
57
+ );
58
+ }
59
+ return guarded(res, async () => work(await readBody(req)));
60
+ };
61
+
62
+ const READ = {
63
+ repos: async (service) => ({ repos: await service.listRepos() }),
64
+ settings: async (service) => ({ settings: await service.settings() }),
65
+ notifications: async (service) => ({
66
+ notifications: await service.notifications(),
67
+ }),
68
+ };
69
+
70
+ const WRITE = {
71
+ settings: async (service, body) => ({
72
+ settings: await service.saveSettings(body),
73
+ }),
74
+ 'notifications/read': (service, body) =>
75
+ service.markRead({
76
+ threadId: body.threadId ?? null,
77
+ all: body.all === true,
78
+ }),
79
+ };
80
+
81
+ export const handleGithubRoutes = async (req, res, p, ctx) => {
82
+ const prefix = '/api/github/';
83
+ if (!p.startsWith(prefix) || !ctx.github) {
84
+ return false;
85
+ }
86
+ const name = p.slice(prefix.length);
87
+ if (req.method === 'GET' && Object.hasOwn(READ, name)) {
88
+ return guarded(res, async () =>
89
+ json(res, 200, { ok: true, ...(await READ[name](ctx.github)) })
90
+ );
91
+ }
92
+ if (req.method === 'POST' && Object.hasOwn(WRITE, name)) {
93
+ return mutation(req, res, ctx, async (body) =>
94
+ json(res, 200, { ok: true, ...(await WRITE[name](ctx.github, body)) })
95
+ );
96
+ }
97
+ return false;
98
+ };
@@ -170,6 +170,13 @@ const ACTIONS = {
170
170
  },
171
171
  };
172
172
 
173
+ // An item pulled before its network id was kept is refused (R-Z11).
174
+ const FAILURE_STATUS = {
175
+ 'flood-wait': 429,
176
+ 'unsupported-action': 501,
177
+ 'no-external-id': 409,
178
+ };
179
+
173
180
  const handleAction = async (store, req, res, ctx, name) => {
174
181
  const action = ACTIONS[name];
175
182
  const body = await readBody(req);
@@ -197,12 +204,7 @@ const handleAction = async (store, req, res, ctx, name) => {
197
204
  source: target.source,
198
205
  code: error.code ?? null,
199
206
  });
200
- const status =
201
- error.code === 'flood-wait'
202
- ? 429
203
- : error.code === 'unsupported-action'
204
- ? 501
205
- : 502;
207
+ const status = FAILURE_STATUS[error.code] ?? 502;
206
208
  return (
207
209
  json(res, status, {
208
210
  error: error.message,
@@ -17,7 +17,8 @@ import {
17
17
  stampSourceLink,
18
18
  } from '../sources/index.js';
19
19
  import { emailFailure, handleEmailPull, sendEmail } from './routes-email.js';
20
- import { createGithubLive } from '../sources/github.js';
20
+ import { githubLive } from './github-service.js';
21
+ import { handleGithubRoutes } from './routes-github.js';
21
22
  import { createUpworkLive } from '../sources/upwork.js';
22
23
  import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
23
24
  import {
@@ -523,33 +524,6 @@ const handleEmail = async (store, req, res, p, ctx) => {
523
524
  return false;
524
525
  };
525
526
 
526
- const readGithubToken = async (store, { secretId } = {}) => {
527
- const ids = [
528
- secretId,
529
- 'secret:github:access-token',
530
- 'secret:github:token',
531
- ].filter(Boolean);
532
- for (const id of ids) {
533
- const link = await store.get(id);
534
- const token = link?.token ?? link?.value ?? link?.body ?? link?.text;
535
- if (token) {
536
- return token;
537
- }
538
- }
539
- return null;
540
- };
541
-
542
- const githubLive = async (store, body, ctx) => {
543
- const token = body.token ?? (await readGithubToken(store, body));
544
- const factory = ctx?.githubLiveFactory ?? createGithubLive;
545
- const live = factory({
546
- token,
547
- owner: body.owner ?? null,
548
- repo: body.repo ?? null,
549
- });
550
- return { token, live };
551
- };
552
-
553
527
  const pullGithub = async (store, body, ctx) => {
554
528
  const { token, live } = await githubLive(store, body, ctx);
555
529
  const result = await live.pullMessages({ ...body, token });
@@ -584,10 +558,18 @@ const cloneGithub = async (store, body, ctx) => {
584
558
 
585
559
  const postGithubComment = async (store, body, ctx) => {
586
560
  const { token, live } = await githubLive(store, body, ctx);
587
- const result = await live.post(body.content ?? body.message ?? body, {
588
- ...body,
589
- token,
590
- });
561
+ let content = body.content ?? body.message ?? body;
562
+ // A reply names the message it answers; its saved GitHub id picks the
563
+ // endpoint (a review comment has its own reply route) (R-Z11).
564
+ if (typeof body.replyTo === 'string' && body.replyTo.startsWith('msg:')) {
565
+ const replyToLink = await store.get(body.replyTo);
566
+ content = {
567
+ ...(typeof content === 'string' ? { text: content } : content),
568
+ chat: body.chat ?? replyToLink?.chat,
569
+ replyToLink,
570
+ };
571
+ }
572
+ const result = await live.post(content, { ...body, token });
591
573
  return { source: 'github', result };
592
574
  };
593
575
 
@@ -805,6 +787,7 @@ export const handleMutatingRoutes = async (store, req, res, p, url, ctx) =>
805
787
  (await handleOutreach(store, req, res, p, ctx)) ||
806
788
  (await handleEmail(store, req, res, p, ctx)) ||
807
789
  (await handleGithub(store, req, res, p, ctx)) ||
790
+ (await handleGithubRoutes(req, res, p, ctx)) ||
808
791
  (await handleUpwork(store, req, res, p, ctx)) ||
809
792
  (await handlePeoplePerHour(store, req, res, p, ctx)) ||
810
793
  (await handleHardening(store, req, res, p, ctx));