ccdeck 3.36.0 → 3.36.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.
Files changed (40) hide show
  1. package/LICENSING.md +17 -3
  2. package/README.md +6 -1
  3. package/dist/web/apple-touch-icon.png +0 -0
  4. package/dist/web/assets/{BrowserWatchModal-BnNwmF9s.js → BrowserWatchModal-BJKk2x-P.js} +1 -1
  5. package/dist/web/assets/{UsageHistoryModal-CAsrCgeA.js → UsageHistoryModal-ClT1-0hN.js} +1 -1
  6. package/dist/web/assets/index-1U-Iz_x2.css +1 -0
  7. package/dist/web/assets/index-Dj4JHRcG.js +306 -0
  8. package/dist/web/brand/ccdeck-mark-gradient-small-optical.svg +1 -0
  9. package/dist/web/brand/ccdeck-mark-mono-dark-small-optical.svg +1 -0
  10. package/dist/web/favicon.ico +0 -0
  11. package/dist/web/favicon.svg +1 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-maskable-512.png +0 -0
  15. package/dist/web/index.html +13 -11
  16. package/dist/web/manifest.webmanifest +1 -2
  17. package/dist/web/state/favicon-error-32.png +0 -0
  18. package/dist/web/state/favicon-error.svg +1 -0
  19. package/dist/web/state/favicon-syncing-32.png +0 -0
  20. package/dist/web/state/favicon-syncing.svg +1 -0
  21. package/dist/web/state/favicon-waiting-32.png +0 -0
  22. package/dist/web/state/favicon-waiting.svg +1 -0
  23. package/hook/hook.js +23 -6
  24. package/package.json +1 -1
  25. package/release-notes.json +8 -1
  26. package/src/server/account-origins.mjs +201 -0
  27. package/src/server/account-routes.mjs +106 -5
  28. package/src/server/claude-accounts.mjs +78 -2
  29. package/src/server/cswap-admin.mjs +62 -1
  30. package/src/server/deck-prefs.mjs +9 -1
  31. package/src/server/feedback-form.mjs +125 -0
  32. package/src/server/http-io.mjs +40 -4
  33. package/src/server/index.mjs +9 -3
  34. package/src/server/prefs-routes.mjs +4 -1
  35. package/src/server/reports-routes.mjs +69 -11
  36. package/src/server/static-serve.mjs +1 -0
  37. package/dist/web/assets/index-CuVOi3gc.css +0 -1
  38. package/dist/web/assets/index-MjcQ231B.js +0 -305
  39. package/dist/web/icon-maskable.svg +0 -21
  40. package/dist/web/icon.svg +0 -24
@@ -47,6 +47,10 @@ export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
47
47
  // Asking claude-swap to collect, which every roster read does when something is
48
48
  // due, and when each account will next be read.
49
49
  import { nextReadAt, nudgeCollector } from "./claude-collector.mjs";
50
+ // Which accounts the deck signed in itself, and the incident one of them is in
51
+ // (#1893). Pure, and keyed the way LAN sync keys accounts.
52
+ import { hasRecovered, reauthFor } from "./account-origins.mjs";
53
+ import { accountKey } from "./lan-copies.mjs";
50
54
  import { dirname, join } from "node:path";
51
55
  import { existsSync } from "node:fs";
52
56
  import { homedir, platform } from "node:os";
@@ -349,6 +353,53 @@ function repairFor(num, email, now) {
349
353
  try { return _repairStaleCopy({ num, email, now }) ?? null; } catch { return null; }
350
354
  }
351
355
 
356
+ // ── which accounts the deck signed in (#1893) ────────────────────────────────
357
+ //
358
+ // HANDED IN, NOT IMPORTED, for the stale-copy repair's reason: what the deck
359
+ // remembers lives in prefs.json, and this module is read by dozens of tests
360
+ // against fixture stores that must never reach the user's own settings. The
361
+ // server hands it in when it starts listening (wireAccountOrigins, in
362
+ // account-routes.mjs); with nothing handed in, no row has an origin.
363
+ let _origins = null;
364
+
365
+ /**
366
+ * `{ entries, tidy }`: `entries()` answers prefs.json's `accounts` map as held
367
+ * in memory, and `tidy({ recovered, gone })` is told what a read found out of
368
+ * date in it — accounts read well after an incident somebody put off, and
369
+ * accounts no longer in the store at all. Null unregisters.
370
+ */
371
+ export function accountOriginsWith(source) {
372
+ _origins = source && typeof source.entries === "function" ? source : null;
373
+ }
374
+
375
+ /** The map as it stands. One that throws costs the roster its origins, never
376
+ * the read. */
377
+ function originsNow() {
378
+ try { return _origins?.entries() ?? {}; } catch { return {}; }
379
+ }
380
+
381
+ /**
382
+ * Tell the registered source what this read found out of date.
383
+ *
384
+ * GONE IS EVERY MARKED IDENTITY THE STORE NO LONGER HOLDS, however it left: the
385
+ * panel's remove, `cswap remove` in a terminal, a hand edit. Kept, a mark would
386
+ * outlive its account and land on the same address arriving later by a share or
387
+ * Local network — exactly what the mark must never be. Safe to decide off one
388
+ * read: claude-swap replaces sequence.json whole (temp file and rename), and an
389
+ * import refreshes a slot in place rather than removing it first. Asked of every
390
+ * account in the store, not of the ordered list drawn above, and never of a
391
+ * store with none: an emptied store is the one state where waiting costs
392
+ * nothing.
393
+ */
394
+ function tidyOrigins(origins, recovered, held) {
395
+ const present = new Set(Object.values(held ?? {}).map(a => accountKey(a?.email, a?.organizationUuid)));
396
+ const gone = present.size ? Object.keys(origins).filter(k => !present.has(k)) : [];
397
+ if (!recovered.length && !gone.length) return;
398
+ // `seen` rides along so the write changes only what this read judged: a mark
399
+ // re-made, or an incident put off, since then is not this read's to undo.
400
+ try { _origins?.tidy?.({ recovered, gone, seen: origins }); } catch { /* the next read says it again */ }
401
+ }
402
+
352
403
  /**
353
404
  * The read itself, split out from the admission control above it so the guard is
354
405
  * readable as the four lines it is.
@@ -385,6 +436,11 @@ async function readRoster(now, gen) {
385
436
  return finish({ ok: false, reason: "cswap_refused", version: refused.version, want: refused.want ?? null, fetchedAt: now });
386
437
  }
387
438
 
439
+ // What the deck remembers about its accounts, taken BEFORE the store is read
440
+ // (#1893). A sign-in marks its account only after `cswap add` has written
441
+ // it, so every mark in this copy names an account the store read below
442
+ // already holds — and none recorded mid-read can be mistaken for one gone.
443
+ const origins = originsNow();
388
444
  const root = backupRoot();
389
445
  const seq = await readSequence(root);
390
446
  if (!seq?.accounts) {
@@ -422,13 +478,22 @@ async function readRoster(now, gen) {
422
478
  : Object.keys(seq.accounts).sort((a, b) => Number(a) - Number(b));
423
479
 
424
480
  const accounts = [];
481
+ const recovered = [];
425
482
  for (const num of order) {
426
483
  const acct = seq.accounts[num];
427
484
  if (!acct) continue; // sequence lists a slot that no longer exists
428
485
 
429
486
  const row = rows[num];
430
- accounts.push(rosterRow({ seq, num, acct, row, identity, now }));
487
+ const key = accountKey(acct.email, acct.organizationUuid);
488
+ const origin = Object.hasOwn(origins, key) ? origins[key] : null;
489
+ const shown = rosterRow({ seq, num, acct, row, identity, now, key, origin });
490
+ accounts.push(shown);
491
+ // A put-off incident is over once a read succeeds after it, and the
492
+ // put-off goes with it (#1893) — said here, where the read is, rather than
493
+ // left for the page to notice.
494
+ if (origin && hasRecovered(origin, shown.fetchedAt)) recovered.push(key);
431
495
  }
496
+ tidyOrigins(origins, recovered, seq.accounts);
432
497
 
433
498
  return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
434
499
  }
@@ -443,11 +508,14 @@ async function readRoster(now, gen) {
443
508
  * verdict cache, and asks the registered repair about a `stale-copy` row —
444
509
  * which is what starts that repair (see repairStaleCopyWith).
445
510
  */
446
- function rosterRow({ seq, num, acct, row, identity, now }) {
511
+ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
447
512
  const matches = rowIsFor(row, acct);
448
513
  const good = matches ? row.lastGood : null;
449
514
 
450
515
  const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
516
+ // When claude-swap last TRIED, well or not. A refusal counts against a login
517
+ // only when it came after the deck last signed that login in (#1893).
518
+ const attemptedAtMs = matches && typeof row.lastAttemptAt === "number" ? row.lastAttemptAt * 1000 : null;
451
519
  const isActive = String(seq.activeAccountNumber) === num;
452
520
  const trouble = authTrouble(row, {
453
521
  matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
@@ -462,6 +530,7 @@ function rosterRow({ seq, num, acct, row, identity, now }) {
462
530
  ].filter(Boolean);
463
531
 
464
532
  const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
533
+ const reauth = reauthFor({ entry: origin, trouble, collector, fetchedAt: fetchedAtMs, attemptedAt: attemptedAtMs });
465
534
  return {
466
535
  num: Number(num),
467
536
  email: acct.email ?? null,
@@ -520,6 +589,13 @@ function rosterRow({ seq, num, acct, row, identity, now }) {
520
589
  // what turns "not collecting" into a sentence with a next step in it, and
521
590
  // it is null on every machine where the collector has not been asked yet.
522
591
  collector,
592
+ // "ccdeck_signin" when the deck's own `+ → Sign in` added this account,
593
+ // and null for every other way an account arrives (#1893).
594
+ origin: origin?.origin ?? null,
595
+ // The incident this account is in, when it is one the deck signed in and
596
+ // claude-swap has refused its login since: `{ key, since, dismissed }`.
597
+ // `key` and `since` together name it, for "Not now" to send back.
598
+ reauth: reauth ? { key, ...reauth } : null,
523
599
  };
524
600
  }
525
601
 
@@ -36,6 +36,29 @@ import { PRODUCT } from "./brand.mjs";
36
36
  // open longer than a user would plausibly take to fetch one.
37
37
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
38
38
  const CSWAP_TIMEOUT_MS = 60_000;
39
+
40
+ // ── telling the deck what it signed in (#1893) ──────────────────────────────
41
+ //
42
+ // A completed `+ → Sign in` is the one moment the deck knows where an account
43
+ // came from. It is told to whoever registered here — account-routes.mjs, which
44
+ // keeps the answer in prefs.json — rather than written from this module: tests
45
+ // drive the sign-in against fixture stores, and none of them may reach the
46
+ // user's own settings. (Forgetting is the roster's: an account gone from the
47
+ // store, however it went, is dropped on the next read.)
48
+ let _origins = null;
49
+
50
+ /** `{ signedIn({email, org, added}) }`, or null. */
51
+ export function accountOriginsWith(hooks) {
52
+ _origins = hooks && typeof hooks === "object" ? hooks : null;
53
+ }
54
+
55
+ /** Tell the registered hook, and never let its failure become the flow's: an
56
+ * account that was signed in is signed in whether or not this was kept. */
57
+ async function tellOrigins(event, detail) {
58
+ try { await _origins?.[event]?.(detail); }
59
+ catch (err) { console.error(`${PRODUCT}: could not note where an account came from:`, err?.message ?? err); }
60
+ }
61
+
39
62
  // ── whether this process can read what claude-swap holds ────────────────────
40
63
  //
41
64
  // ASKED OF CLAUDE-SWAP, NEVER READ OFF ITS WORDS. A Keychain it cannot open is
@@ -259,6 +282,28 @@ export function newSlot(before, after) {
259
282
  return fresh.length === 1 ? fresh[0] : null;
260
283
  }
261
284
 
285
+ const sameAddress = (a, b) => String(a ?? "").trim().toLowerCase() === String(b ?? "").trim().toLowerCase();
286
+
287
+ /**
288
+ * The slot a store holds for the identity the CLI reports, or null.
289
+ *
290
+ * Case-folded, because the CLI and the store need not spell an address alike,
291
+ * and by organization too when the CLI names one: one address under two
292
+ * organizations is two accounts, and the first slot with the address is not
293
+ * necessarily the one that was signed into (#1893). Without an organization to
294
+ * go on, the first slot with the address is still the best answer there is.
295
+ */
296
+ export function slotOf(store, identity) {
297
+ const mine = store.slots.filter(s => sameAddress(store.emails[s], identity?.email));
298
+ const org = identity?.orgId ?? "";
299
+ return (org ? mine.find(s => (store.orgs?.[s] ?? "") === org) : null) ?? mine[0] ?? null;
300
+ }
301
+
302
+ /** Whether a store held this exact account — address and organization. */
303
+ export function holds(store, email, org) {
304
+ return store.slots.some(s => sameAddress(store.emails[s], email) && (store.orgs?.[s] ?? "") === (org ?? ""));
305
+ }
306
+
262
307
 
263
308
  // ── login ────────────────────────────────────────────────────────────────────
264
309
 
@@ -665,7 +710,23 @@ async function registerSignedIn(flow, identity) {
665
710
  const slot = newSlot(flow.before, after);
666
711
  // No new slot means the account was already managed and cswap refreshed its
667
712
  // credentials in place. That is a success with a different sentence.
668
- const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
713
+ const num = slot ?? slotOf(after, identity) ?? null;
714
+
715
+ // Recorded now that claude-swap holds the account, and before the roster
716
+ // is invalidated below, so no read can land between the two and cache a
717
+ // row that does not know the deck signed it in (#1893). The store, not the
718
+ // CLI, supplies the organization: claude-swap keys the account by both.
719
+ //
720
+ // ADDED MEANS "NOT THERE WHEN THE SIGN-IN STARTED", asked of the identity
721
+ // rather than of newSlot. `flow.before` is minutes old by now, and a share
722
+ // or a Local network round can land a different account in between: two
723
+ // new slots, newSlot answers null, and the account this sign-in did add
724
+ // would never be marked.
725
+ if (num != null) {
726
+ const email = after.emails[num] || identity.email;
727
+ const org = after.orgs?.[num] ?? "";
728
+ await tellOrigins("signedIn", { email, org, added: !holds(flow.before, email, org) });
729
+ }
669
730
 
670
731
  // Assigned, not discarded. `done` is still the right state — the account
671
732
  // WAS added — but it is `done` with a qualification whenever this says the
@@ -45,6 +45,7 @@ import { NOT_JSON, prefsRefusalDetail, prefsWriteRefusal, unreadablePrefs } from
45
45
  import { DENIED, foreignPath, quarantinePath, setAsideForeign } from "./prefs-set-aside.mjs";
46
46
  import { deckDataDir } from "./deck-home.mjs";
47
47
  import { PRODUCT } from "./brand.mjs";
48
+ import { normaliseOrigins } from "./account-origins.mjs";
48
49
 
49
50
  /** Set to "1" to keep the deck off the desktop whatever the stored preference
50
51
  * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
@@ -91,6 +92,10 @@ export const DEFAULTS = Object.freeze({
91
92
  // not yet acknowledged, retried on the next start so switching off while
92
93
  // offline still ends in a deletion.
93
94
  report: Object.freeze({ installId: "", lastVersion: "", lastActiveDay: "", forget: "" }),
95
+ // The accounts this deck signed in itself, and the re-sign-in prompts
96
+ // somebody put off (#1893) — keyed by account identity, written by the deck
97
+ // and never by a page. See account-origins.mjs.
98
+ accounts: Object.freeze({}),
94
99
  // LAN sync, ON unless somebody turns it off (since 3.22.7; off before).
95
100
  // `passphrase` is the only secret this file has ever held, which is why the
96
101
  // write below names a mode. AGENTS_DECK_NO_LAN=1 keeps a deck off the network
@@ -281,6 +286,7 @@ export function normalise(raw) {
281
286
  autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
282
287
  reports: flagOr(src.reports, DEFAULTS.reports),
283
288
  report: normaliseReport(src.report),
289
+ accounts: normaliseOrigins(src.accounts),
284
290
  lan: normaliseLan(src.lan),
285
291
  };
286
292
  }
@@ -607,7 +613,9 @@ async function save(mutate, home, deps) {
607
613
  export function publicPrefs(prefs) {
608
614
  // The reporter's state is the server's alone: a page has no use for the
609
615
  // install id, and one that could read it could send reports as this install.
610
- const { report: _report, ...p } = normalise(prefs);
616
+ // Nor does it need which accounts the deck signed in (#1893): the roster
617
+ // carries what a row needs, and the map is the deck's own bookkeeping.
618
+ const { report: _report, accounts: _accounts, ...p } = normalise(prefs);
611
619
  // unpaired is engine-authored state too. No page draws or edits it; keeping
612
620
  // it out also means a future whole-prefs form cannot replay a stale unpair
613
621
  // list over a decision the engine made after the form loaded.
@@ -0,0 +1,125 @@
1
+ // A report with images (#1853's dialog): the multipart form the page posts,
2
+ // read and checked here, and the one this server sends on to api.ccdeck.dev.
3
+ //
4
+ // THE API'S LIMITS ARE HELD HERE FIRST, so a request it would refuse never
5
+ // leaves the machine: at most three images, each at most 5 MB, the whole
6
+ // request at most 12 MB, and each a PNG or a JPEG as its first bytes say —
7
+ // never as its name or the type the page declared, since a GIF renamed .png is
8
+ // still a GIF (ccdeck-api FeedbackForm and ImageScrub, which this mirrors).
9
+ // What the API checks and this does not is the image's inside: each side 1 to
10
+ // 8192 pixels, and a PNG or JPEG that is whole. The dialog fits the sides
11
+ // before sending, and a damaged file comes back as the API's own 400.
12
+ //
13
+ // WHAT GOES ON is the page's text as it came, this deck's version and system
14
+ // from feedbackFacts (the page's own are ignored, as they are for JSON), and
15
+ // each image's bytes under a name of this server's making. A file's own name
16
+ // can say whose machine it was on — "alice-desk.png" — and the API keeps no
17
+ // name, so none is sent. The API strips EXIF, GPS and text chunks itself.
18
+ //
19
+ // Node's own Request parses the form and its FormData builds the next one, so
20
+ // this costs no dependency. Both are there on the Node 18 the package promises
21
+ // (undici's multipart reader shipped inside it); `File` is not a global before
22
+ // Node 20 and is never named here.
23
+
24
+ export const IMAGES_FIELD = "images";
25
+ export const MAX_IMAGES = 3;
26
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
27
+ /** The whole request, images and text together. Three images at their limit do not fit, on purpose. */
28
+ export const MAX_REQUEST_BYTES = 12 * 1024 * 1024;
29
+
30
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
31
+ const JPEG_START = [0xff, 0xd8, 0xff];
32
+ const TEXT_FIELDS = ["kind", "title", "body", "contact"];
33
+
34
+ /** What a file is by its first bytes: "png", "jpeg", or null for anything else. */
35
+ export function imageFormat(bytes) {
36
+ const starts = sig => sig.every((b, i) => bytes[i] === b);
37
+ if (bytes.length >= PNG_SIGNATURE.length && starts(PNG_SIGNATURE)) return "png";
38
+ if (bytes.length >= JPEG_START.length && starts(JPEG_START)) return "jpeg";
39
+ return null;
40
+ }
41
+
42
+ const FORMAT = {
43
+ png: { type: "image/png", extension: "png" },
44
+ jpeg: { type: "image/jpeg", extension: "jpg" },
45
+ };
46
+
47
+ /** Whether a request's content type says it is a multipart form. */
48
+ export function isMultipart(contentType) {
49
+ return typeof contentType === "string" && /^multipart\/form-data\s*;/i.test(contentType.trim());
50
+ }
51
+
52
+ /**
53
+ * The page's form, read: its text fields and its images, each image known by
54
+ * its bytes. `problems` holds one sentence per thing the API would refuse, in
55
+ * the API's own words, and is empty when the form may go on. Null when the
56
+ * body is not a form at all.
57
+ */
58
+ export async function readFeedbackForm(bytes, contentType) {
59
+ let form;
60
+ try {
61
+ form = await new Request("http://127.0.0.1/", {
62
+ method: "POST",
63
+ headers: { "content-type": contentType },
64
+ body: bytes,
65
+ }).formData();
66
+ } catch {
67
+ return null;
68
+ }
69
+
70
+ const problems = [];
71
+ const fields = {};
72
+ const files = [];
73
+ let fileElsewhere = false;
74
+ let imageAsText = false;
75
+ for (const [name, value] of form.entries()) {
76
+ const isFile = typeof value !== "string";
77
+ if (name === IMAGES_FIELD) {
78
+ if (isFile) files.push(value);
79
+ else imageAsText = true;
80
+ } else if (isFile) {
81
+ fileElsewhere = true;
82
+ } else if (TEXT_FIELDS.includes(name) && !(name in fields)) {
83
+ fields[name] = value;
84
+ }
85
+ }
86
+ // Refused rather than ignored, as the API does: a part named "image" would
87
+ // otherwise leave its sender believing the screenshot arrived.
88
+ if (fileElsewhere) problems.push("Only parts named images may carry a file.");
89
+ if (imageAsText) problems.push("Send each image as a file part.");
90
+
91
+ const images = [];
92
+ if (files.length > MAX_IMAGES) {
93
+ problems.push(`At most ${MAX_IMAGES} images.`);
94
+ } else {
95
+ for (const [index, file] of files.entries()) {
96
+ const n = index + 1;
97
+ if (file.size > MAX_IMAGE_BYTES) {
98
+ problems.push(`Image ${n} is over ${MAX_IMAGE_BYTES / 1024 / 1024} MB.`);
99
+ continue;
100
+ }
101
+ const data = new Uint8Array(await file.arrayBuffer());
102
+ const format = imageFormat(data);
103
+ if (!format) problems.push(`Image ${n} is not a PNG or a JPEG.`);
104
+ else images.push({ data, format });
105
+ }
106
+ }
107
+ return { fields, images, problems };
108
+ }
109
+
110
+ /** The form this server posts on: the page's text, this deck's facts (feedbackFacts in reports-routes.mjs), and the images under names of its own. */
111
+ export function upstreamForm({ fields, images }, facts) {
112
+ const form = new FormData();
113
+ for (const name of ["kind", "title", "body"]) {
114
+ if (typeof fields[name] === "string") form.append(name, fields[name]);
115
+ }
116
+ const contact = typeof fields.contact === "string" ? fields.contact.trim() : "";
117
+ if (contact) form.append("contact", contact);
118
+ form.append("appVersion", facts.appVersion);
119
+ form.append("platform", facts.platform);
120
+ for (const [index, { data, format }] of images.entries()) {
121
+ const { type, extension } = FORMAT[format];
122
+ form.append(IMAGES_FIELD, new Blob([data], { type }), `image-${index + 1}.${extension}`);
123
+ }
124
+ return form;
125
+ }
@@ -74,10 +74,7 @@ export function readBody(req, res = null, limit = 64_000) {
74
74
  refused = true;
75
75
  body = "";
76
76
  if (res) send(res, 413, { error: "body too large" });
77
- req.resume();
78
- const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
79
- grace.unref?.();
80
- req.on("close", () => clearTimeout(grace));
77
+ drainRefused(req);
81
78
  reject(new Error("body too large"));
82
79
  }
83
80
  });
@@ -86,6 +83,45 @@ export function readBody(req, res = null, limit = 64_000) {
86
83
  });
87
84
  }
88
85
 
86
+ /** Keep reading a refused upload and throw it away, for a bounded while — see readBody. */
87
+ function drainRefused(req) {
88
+ req.resume();
89
+ const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
90
+ grace.unref?.();
91
+ req.on("close", () => clearTimeout(grace));
92
+ }
93
+
94
+ /**
95
+ * readBody for a body that is bytes rather than text: the feedback dialog's
96
+ * multipart form, whose images a utf8 decode would corrupt. The same cap in
97
+ * bytes, the same 413 on the wire before the hang-up and the same bounded
98
+ * drain. `refusal` is what the 413 says, so a page that reads a `reason` off
99
+ * the answer is given one.
100
+ */
101
+ export function readBytes(req, res = null, limit = 64_000, refusal = { error: "body too large" }) {
102
+ return new Promise((resolve, reject) => {
103
+ const chunks = [];
104
+ let size = 0;
105
+ let refused = false;
106
+ req.on("data", c => {
107
+ if (refused) return;
108
+ const chunk = typeof c === "string" ? Buffer.from(c) : c;
109
+ size += chunk.length;
110
+ if (size > limit) {
111
+ refused = true;
112
+ chunks.length = 0;
113
+ if (res) send(res, 413, refusal);
114
+ drainRefused(req);
115
+ reject(new Error("body too large"));
116
+ return;
117
+ }
118
+ chunks.push(chunk);
119
+ });
120
+ req.on("end", () => { if (!refused) resolve(Buffer.concat(chunks, size)); });
121
+ req.on("error", reject);
122
+ });
123
+ }
124
+
89
125
  // A request handler rejected. Two audiences, two different amounts of detail:
90
126
  // the operator, who needs the whole error — stack included — to find the bug,
91
127
  // and the HTTP client, which needs to know only that the request failed.
@@ -137,7 +137,7 @@ import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "
137
137
  import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
138
138
  // Usage reports and the feedback dialog (#1853). The requests to
139
139
  // api.ccdeck.dev are made in reports.mjs, never in this file.
140
- import { handleClientError, handleFeedback, handleReportsWrite } from "./reports-routes.mjs";
140
+ import { handleClientError, handleFeedback, handleFeedbackFacts, handleReportsWrite } from "./reports-routes.mjs";
141
141
  import { reporter } from "./reports.mjs";
142
142
  import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
143
143
  import { historySnapshot, processesReply, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
@@ -148,7 +148,7 @@ import { send, sendInternalError } from "./http-io.mjs";
148
148
  export { sendInternalError };
149
149
  // The accounts surface's routes — see account-routes.mjs. The boot wires the
150
150
  // stale-copy repair and starts auto-switch through the same module.
151
- import { cswapAutoModule, getProjectRollup, handleAccountLoginState, handleAccountProjects, handleClaudeAccountAdmin, handleClaudeAccountSwitch, handleClaudeAccounts, handleCswapAuto, handleCswapAutoAction, wireStaleCopyRepair } from "./account-routes.mjs";
151
+ import { cswapAutoModule, getProjectRollup, handleAccountLoginState, handleAccountProjects, handleClaudeAccountAdmin, handleClaudeAccountSwitch, handleClaudeAccounts, handleCswapAuto, handleCswapAutoAction, wireAccountOrigins, wireStaleCopyRepair } from "./account-routes.mjs";
152
152
  // Browser Watch's three routes — see browser-watch-routes.mjs.
153
153
  import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettings } from "./browser-watch-routes.mjs";
154
154
  // The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
@@ -191,7 +191,12 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
191
191
  // The repair a paused Claude account used to wait on a `resume` press for:
192
192
  // handed to the roster read here, by the server that is actually running,
193
193
  // rather than wired at import — see wireStaleCopyRepair.
194
- if (deckProviders().claude) wireStaleCopyRepair();
194
+ if (deckProviders().claude) {
195
+ wireStaleCopyRepair();
196
+ // Which accounts the deck signed in, and the prompt for one whose login
197
+ // has stopped working (#1893).
198
+ wireAccountOrigins();
199
+ }
195
200
  const removed = await sweepStaleDiscovery();
196
201
  if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
197
202
  // Where the log is and whether it can be written, asked before the first
@@ -307,6 +312,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
307
312
  if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
308
313
  if (req.method === "POST" && url.pathname === "/api/prefs/give-back") return guard(handlePrefsGiveBack(req, res), res);
309
314
  if (req.method === "POST" && url.pathname === "/api/reports") return guard(handleReportsWrite(req, res), res);
315
+ if (req.method === "GET" && url.pathname === "/api/feedback") return handleFeedbackFacts(req, res);
310
316
  if (req.method === "POST" && url.pathname === "/api/feedback") return guard(handleFeedback(req, res), res);
311
317
  if (req.method === "POST" && url.pathname === "/api/client-error") return guard(handleClientError(req, res), res);
312
318
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
@@ -55,7 +55,10 @@ export async function handlePrefsWrite(req, res) {
55
55
  // The reports switch has consequences a plain write would skip — a deletion
56
56
  // asked for — so it is changed only through /api/reports, and its state never
57
57
  // by the page at all (#1853).
58
- const { reports: _reports, report: _report, ...patch } = body;
58
+ // Which accounts the deck signed in is the deck's to record, on a sign-in it
59
+ // ran itself (#1893); a page that could write it could give any account a
60
+ // provenance it never had.
61
+ const { reports: _reports, report: _report, accounts: _accounts, ...patch } = body;
59
62
  try {
60
63
  await heldPrefs.write(patch);
61
64
  } catch (err) {
@@ -4,7 +4,8 @@
4
4
  // install id never has to reach the page.
5
5
 
6
6
  import { reportsVetoed } from "./deck-prefs.mjs";
7
- import { readBody, send } from "./http-io.mjs";
7
+ import { MAX_REQUEST_BYTES, isMultipart, readFeedbackForm, upstreamForm } from "./feedback-form.mjs";
8
+ import { readBody, readBytes, send } from "./http-io.mjs";
8
9
  import { heldPrefs } from "./prefs-state.mjs";
9
10
  import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
10
11
 
@@ -39,9 +40,13 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
39
40
  * `POST /api/feedback` `{ kind, title, body, contact? }`: the feedback dialog.
40
41
  *
41
42
  * The API stores it, and the people who make ccdeck read it there. They may
42
- * open a public issue from it, never with the contact; nothing becomes public
43
- * by being sent, which is why the answer to the page is a plain "it arrived"
44
- * and carries no link.
43
+ * open a public issue from it, never with the contact or the images; nothing
44
+ * becomes public by being sent, which is why the answer to the page is a plain
45
+ * "it arrived" and carries no link.
46
+ *
47
+ * A report with images comes as a multipart form instead, and goes on as one —
48
+ * see feedback-form.mjs for what is checked here before it leaves. With none it
49
+ * is the JSON post it always was.
45
50
  *
46
51
  * Not gated on the reports switch: pressing Send is its own decision, for this
47
52
  * one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
@@ -50,23 +55,76 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
50
55
  */
51
56
  export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
52
57
  if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
58
+ const contentType = req.headers?.["content-type"];
59
+ if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env });
53
60
  const body = await readJson(req, res, 32_000);
54
61
  if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
55
- const facts = installFacts({ env });
62
+ const facts = feedbackFacts({ env });
56
63
  const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
64
+ return forwardFeedback(res, fetchImpl, {
65
+ headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.appVersion}` },
66
+ body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts }),
67
+ timeoutMs: JSON_TIMEOUT_MS,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * What this deck adds to a piece of feedback, and all it adds: the ccdeck
73
+ * version, and the system as `os-arch` — `darwin-arm64`. installFacts knows
74
+ * more than that (the channel, the runtime, a locale, a device fingerprint)
75
+ * and none of it goes with feedback. One function for both posts and for the
76
+ * line the dialog draws before Send, so what the page says is sent and what
77
+ * the API is sent are one answer and cannot drift apart.
78
+ */
79
+ export function feedbackFacts({ env = process.env } = {}) {
80
+ const facts = installFacts({ env });
81
+ return { appVersion: facts.version, platform: `${facts.os}-${facts.arch}` };
82
+ }
83
+
84
+ /** `GET /api/feedback`: what a report sent from here would carry besides its
85
+ * words, for the dialog to say before Send. Nothing leaves the machine. */
86
+ export function handleFeedbackFacts(_req, res, { env = process.env } = {}) {
87
+ return send(res, 200, { ok: true, ...feedbackFacts({ env }) });
88
+ }
89
+
90
+ /** Twenty seconds is plenty for a few kilobytes of JSON. */
91
+ const JSON_TIMEOUT_MS = 20_000;
92
+ /** And not for 12 MB: at 2 Mbit/s up, a line a hotel can offer, that is fifty
93
+ * seconds of upload before the API has read a byte of it. */
94
+ const FORM_TIMEOUT_MS = 120_000;
95
+
96
+ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
97
+ // Over the cap, readBytes has already answered 413 too_large and the 400
98
+ // below is a no-op; this is the answer to a read that failed some other way.
99
+ const bytes = await readBytes(req, res, MAX_REQUEST_BYTES, { ok: false, reason: "too_large" }).catch(() => null);
100
+ if (!bytes) return send(res, 400, { ok: false, reason: "bad_request" });
101
+ const form = await readFeedbackForm(bytes, contentType);
102
+ if (!form) return send(res, 400, { ok: false, reason: "bad_request" });
103
+ if (form.problems.length > 0) return send(res, 400, { ok: false, reason: "invalid", errors: { images: form.problems } });
104
+ const facts = feedbackFacts({ env });
105
+ return forwardFeedback(res, fetchImpl, {
106
+ // No content type: fetch writes the form's own, boundary and all.
107
+ headers: { "user-agent": `ccdeck/${facts.appVersion}` },
108
+ body: upstreamForm(form, facts),
109
+ timeoutMs: FORM_TIMEOUT_MS,
110
+ });
111
+ }
112
+
113
+ /** One post to the API, JSON or form, and what its answer means for the page. */
114
+ async function forwardFeedback(res, fetchImpl, { headers, body, timeoutMs }) {
57
115
  try {
58
116
  const upstream = await fetchImpl(`${REPORTS_API}/v1/feedback`, {
59
117
  method: "POST",
60
- headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.version}` },
61
- body: JSON.stringify({
62
- kind: body.kind, title: body.title, body: body.body, contact,
63
- appVersion: facts.version, platform: `${facts.os}-${facts.arch}`,
64
- }),
65
- signal: AbortSignal.timeout(20_000),
118
+ headers,
119
+ body,
120
+ signal: AbortSignal.timeout(timeoutMs),
66
121
  });
67
122
  const answer = await upstream.json().catch(() => null);
68
123
  if (upstream.status === 202) return send(res, 200, { ok: true });
69
124
  if (upstream.status === 400) return send(res, 400, { ok: false, reason: "invalid", errors: answer?.errors ?? {} });
125
+ // Its own reason rather than "unavailable": the API is up, and the fix is
126
+ // a smaller request, which is not what "try again in a moment" says.
127
+ if (upstream.status === 413) return send(res, 413, { ok: false, reason: "too_large" });
70
128
  if (upstream.status === 429) return send(res, 429, { ok: false, reason: "too_many" });
71
129
  return send(res, 502, { ok: false, reason: "unavailable" });
72
130
  } catch {
@@ -29,6 +29,7 @@ const MIME = {
29
29
  // with nothing in the console that names the reason.
30
30
  ".webmanifest": "application/manifest+json; charset=utf-8",
31
31
  ".png": "image/png",
32
+ ".ico": "image/x-icon",
32
33
  ".jpg": "image/jpeg",
33
34
  ".woff2": "font/woff2",
34
35
  ".map": "application/json",