@songsid/agend 2.3.0 → 2.3.2-beta.1

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 (201) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/authority/authority-state.d.ts +4 -0
  4. package/dist/authority/authority-state.js +16 -10
  5. package/dist/authority/authority-state.js.map +1 -1
  6. package/dist/authority/session-authorizations.d.ts +7 -1
  7. package/dist/authority/session-authorizations.js +6 -4
  8. package/dist/authority/session-authorizations.js.map +1 -1
  9. package/dist/authority/web-authority.d.ts +19 -6
  10. package/dist/authority/web-authority.js +22 -8
  11. package/dist/authority/web-authority.js.map +1 -1
  12. package/dist/backend/muse.d.ts +20 -2
  13. package/dist/backend/muse.js +154 -16
  14. package/dist/backend/muse.js.map +1 -1
  15. package/dist/channel/adapters/discord.d.ts +2 -0
  16. package/dist/channel/adapters/discord.js +33 -3
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/channel/adapters/telegram.d.ts +1 -1
  19. package/dist/channel/adapters/telegram.js +3 -35
  20. package/dist/channel/adapters/telegram.js.map +1 -1
  21. package/dist/channel/mcp-tools.js +5 -5
  22. package/dist/channel/mcp-tools.js.map +1 -1
  23. package/dist/channel/types.d.ts +11 -0
  24. package/dist/cli.js +6 -3
  25. package/dist/cli.js.map +1 -1
  26. package/dist/config-validator.js +42 -5
  27. package/dist/config-validator.js.map +1 -1
  28. package/dist/daemon.d.ts +47 -3
  29. package/dist/daemon.js +380 -60
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/delivery-outbox.d.ts +23 -1
  32. package/dist/delivery-outbox.js +114 -17
  33. package/dist/delivery-outbox.js.map +1 -1
  34. package/dist/doctor.d.ts +3 -0
  35. package/dist/doctor.js +9 -0
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/event-log.d.ts +5 -0
  38. package/dist/event-log.js +13 -0
  39. package/dist/event-log.js.map +1 -1
  40. package/dist/fleet-context.d.ts +1 -1
  41. package/dist/fleet-lock.d.ts +6 -0
  42. package/dist/fleet-lock.js +19 -0
  43. package/dist/fleet-lock.js.map +1 -1
  44. package/dist/fleet-manager.d.ts +174 -2
  45. package/dist/fleet-manager.js +678 -68
  46. package/dist/fleet-manager.js.map +1 -1
  47. package/dist/fleet-process-handlers.d.ts +1 -0
  48. package/dist/fleet-process-handlers.js +9 -1
  49. package/dist/fleet-process-handlers.js.map +1 -1
  50. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
  51. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
  52. package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
  53. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +109 -57
  54. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +110 -58
  55. package/dist/guest-seen-people.d.ts +38 -0
  56. package/dist/guest-seen-people.js +78 -0
  57. package/dist/guest-seen-people.js.map +1 -0
  58. package/dist/instance-config-impact.js +4 -1
  59. package/dist/instance-config-impact.js.map +1 -1
  60. package/dist/instance-lifecycle.d.ts +2 -0
  61. package/dist/instance-lifecycle.js +27 -1
  62. package/dist/instance-lifecycle.js.map +1 -1
  63. package/dist/locale.js +62 -12
  64. package/dist/locale.js.map +1 -1
  65. package/dist/log-service.js +1 -0
  66. package/dist/log-service.js.map +1 -1
  67. package/dist/mac-auto-restore.d.ts +117 -0
  68. package/dist/mac-auto-restore.js +294 -0
  69. package/dist/mac-auto-restore.js.map +1 -0
  70. package/dist/needs-you.js +1 -0
  71. package/dist/needs-you.js.map +1 -1
  72. package/dist/outbound-handlers.d.ts +2 -2
  73. package/dist/outbound-handlers.js +5 -5
  74. package/dist/outbound-handlers.js.map +1 -1
  75. package/dist/outbound-schemas.d.ts +2 -0
  76. package/dist/outbound-schemas.js +2 -0
  77. package/dist/outbound-schemas.js.map +1 -1
  78. package/dist/public-web-gateway.js +9 -1
  79. package/dist/public-web-gateway.js.map +1 -1
  80. package/dist/search/search-index.d.ts +10 -2
  81. package/dist/search/search-index.js +17 -4
  82. package/dist/search/search-index.js.map +1 -1
  83. package/dist/service-installer.d.ts +23 -1
  84. package/dist/service-installer.js +40 -11
  85. package/dist/service-installer.js.map +1 -1
  86. package/dist/session-oom-stop.d.ts +45 -0
  87. package/dist/session-oom-stop.js +259 -0
  88. package/dist/session-oom-stop.js.map +1 -0
  89. package/dist/session-oom.d.ts +13 -0
  90. package/dist/session-oom.js +119 -7
  91. package/dist/session-oom.js.map +1 -1
  92. package/dist/settings-api.d.ts +27 -1
  93. package/dist/settings-api.js +108 -2
  94. package/dist/settings-api.js.map +1 -1
  95. package/dist/settings-change.js +37 -3
  96. package/dist/settings-change.js.map +1 -1
  97. package/dist/settings-effect.js +5 -2
  98. package/dist/settings-effect.js.map +1 -1
  99. package/dist/spawn-gate.d.ts +2 -0
  100. package/dist/spawn-gate.js +17 -4
  101. package/dist/spawn-gate.js.map +1 -1
  102. package/dist/start-attempt.d.ts +15 -1
  103. package/dist/start-attempt.js +35 -5
  104. package/dist/start-attempt.js.map +1 -1
  105. package/dist/start-failure-chat.d.ts +41 -0
  106. package/dist/start-failure-chat.js +159 -0
  107. package/dist/start-failure-chat.js.map +1 -0
  108. package/dist/start-failure-notice.d.ts +4 -0
  109. package/dist/start-failure-notice.js +8 -0
  110. package/dist/start-failure-notice.js.map +1 -1
  111. package/dist/start-failure.d.ts +49 -0
  112. package/dist/start-failure.js +141 -1
  113. package/dist/start-failure.js.map +1 -1
  114. package/dist/telegram-api-root.d.ts +1 -0
  115. package/dist/telegram-api-root.js +40 -0
  116. package/dist/telegram-api-root.js.map +1 -0
  117. package/dist/turn-reply-guard.d.ts +25 -0
  118. package/dist/turn-reply-guard.js +111 -12
  119. package/dist/turn-reply-guard.js.map +1 -1
  120. package/dist/types.d.ts +21 -3
  121. package/dist/ui/backend-signin.js +5 -2
  122. package/dist/ui/chat-ambient.js +1 -1
  123. package/dist/ui/chat-effects.js +4 -4
  124. package/dist/ui/chat-render.js +5 -2
  125. package/dist/ui/chat-strings.js +2 -2
  126. package/dist/ui/chat-thread.js +2 -2
  127. package/dist/ui/first-run.js +2 -2
  128. package/dist/ui/fleet-canvas-view.js +114 -0
  129. package/dist/ui/fleet-canvas.js +15 -77
  130. package/dist/ui/fleet-office-editor.js +80 -5
  131. package/dist/ui/fleet-office-layout.js +197 -0
  132. package/dist/ui/fleet-office-model.js +6 -3
  133. package/dist/ui/fleet-office-pack.d.ts +39 -0
  134. package/dist/ui/fleet-office-pack.js +240 -29
  135. package/dist/ui/fleet-office-skin.js +130 -21
  136. package/dist/ui/fleet-office-store.js +75 -4
  137. package/dist/ui/fleet-office-view.js +154 -0
  138. package/dist/ui/fleet-office-zones.js +95 -33
  139. package/dist/ui/fleet-office.js +530 -94
  140. package/dist/ui/fleet-usage.js +7 -6
  141. package/dist/ui/panel-chat.js +20 -7
  142. package/dist/ui/panel-details.js +2 -2
  143. package/dist/ui/panel-fleet.js +11 -77
  144. package/dist/ui/panel-log.js +5 -5
  145. package/dist/ui/panel-settings.js +586 -164
  146. package/dist/ui/search-dialog.js +26 -7
  147. package/dist/ui/settings-appearance.js +82 -29
  148. package/dist/ui/settings-apply.js +42 -7
  149. package/dist/ui/settings-dialogs.js +68 -22
  150. package/dist/ui/settings-draft.js +51 -0
  151. package/dist/ui/settings-guests.js +244 -78
  152. package/dist/ui/settings-learn.js +2 -2
  153. package/dist/ui/settings-model.js +2 -0
  154. package/dist/ui/settings-registry.js +142 -0
  155. package/dist/ui/settings-strings.js +108 -44
  156. package/dist/ui/settings-web-authority.js +34 -23
  157. package/dist/ui/shared/app-i18n.js +14 -0
  158. package/dist/ui/shared/app-nav.js +10 -5
  159. package/dist/ui/shared/app-prefs.js +80 -2
  160. package/dist/ui/shared/app-route.js +39 -5
  161. package/dist/ui/shared/app-shell.js +80 -8
  162. package/dist/ui/shared/app-stream.js +2 -1
  163. package/dist/ui/shared/app.css +251 -20
  164. package/dist/ui/shared/app.js +3 -0
  165. package/dist/ui/shared/effect-stock-trust.js +1 -1
  166. package/dist/ui/shared/identity-color.js +4 -4
  167. package/dist/ui/shared/signin.js +2 -2
  168. package/dist/ui/shared/ui-dialog.js +6 -1
  169. package/dist/ui/shared/ui-icons.js +2 -0
  170. package/dist/ui/shared/usage-dialog.js +21 -11
  171. package/dist/ui/shared/view-strings.js +2 -0
  172. package/dist/ui/shared/web-authorize.js +13 -12
  173. package/dist/web-api.d.ts +9 -3
  174. package/dist/web-api.js +161 -6
  175. package/dist/web-api.js.map +1 -1
  176. package/dist/web-auth.js +7 -1
  177. package/dist/web-auth.js.map +1 -1
  178. package/dist/web-experimental.d.ts +1 -1
  179. package/dist/web-experimental.js +1 -1
  180. package/dist/web-experimental.js.map +1 -1
  181. package/dist/web-mod-agent.js +2 -2
  182. package/dist/web-mod-agent.js.map +1 -1
  183. package/dist/web-office-skins.d.ts +108 -0
  184. package/dist/web-office-skins.js +298 -0
  185. package/dist/web-office-skins.js.map +1 -0
  186. package/dist/web-office-store.d.ts +8 -2
  187. package/dist/web-office-store.js +37 -7
  188. package/dist/web-office-store.js.map +1 -1
  189. package/dist/web-settings-patch.d.ts +6 -0
  190. package/dist/web-settings-patch.js +61 -4
  191. package/dist/web-settings-patch.js.map +1 -1
  192. package/dist/web-shell-routes.d.ts +4 -2
  193. package/dist/web-shell-routes.js +17 -7
  194. package/dist/web-shell-routes.js.map +1 -1
  195. package/dist/working-recovery.d.ts +104 -0
  196. package/dist/working-recovery.js +263 -0
  197. package/dist/working-recovery.js.map +1 -0
  198. package/dist/working-state-journal.d.ts +70 -0
  199. package/dist/working-state-journal.js +106 -0
  200. package/dist/working-state-journal.js.map +1 -0
  201. package/package.json +1 -1
@@ -52,6 +52,7 @@ register("view", {
52
52
  "usageFull": "Full",
53
53
  "usageFullTitle": "The full card for each login",
54
54
  "usageExpand": "Show the full card",
55
+ "usageCachedAt": "cached at {at}",
55
56
  "usageCollapse": "Back to one line",
56
57
  "usageWindowHours": "{n}h",
57
58
  "usageWindowDays": "{n}d",
@@ -294,6 +295,7 @@ register("view", {
294
295
  "usageFull": "完整",
295
296
  "usageFullTitle": "每個登入顯示完整卡片",
296
297
  "usageExpand": "顯示完整卡片",
298
+ "usageCachedAt": "快取於 {at}",
297
299
  "usageCollapse": "收回成一行",
298
300
  "usageWindowHours": "{n}小時",
299
301
  "usageWindowDays": "{n}天",
@@ -12,7 +12,6 @@ register("webauth", {
12
12
  authorize: "Authorize this browser",
13
13
  waiting: "Waiting for your Confirm in Discord or Telegram ({0})",
14
14
  sent: "Sent — confirm it in your DM within 5 minutes.",
15
- publicLink: "Not on the temporary public link: open the dashboard directly to authorize.",
16
15
  reason_disabled: "Web authority is turned off.",
17
16
  reason_loopback_only: "Only this computer can authorize, and this browser is not on it.",
18
17
  reason_no_account: "This browser was not signed in as an account (agend web): sign in with /web to authorize.",
@@ -20,7 +19,8 @@ register("webauth", {
20
19
  reason_not_admin_session: "Guests can't authorize.",
21
20
  reason_token_rotated: "The dashboard's token changed: sign in again.",
22
21
  reason_public_link: "Not on the temporary public link.",
23
- reason_named_link_off: "Over your named link: turn on \"Also over my named link\" in Settings → Web on this computer.",
22
+ reason_named_link_off: "Over your named link: turn on \"Also over my named link\" in Settings → Access & security on this computer.",
23
+ reason_quick_link_off: "Over the temporary public link: turn on \"Also over the temporary public link\" in Settings → Access & security on this computer.",
24
24
  reason_not_session: "Sign in to authorize.",
25
25
  reason_other: "This browser can't be authorized here.",
26
26
  refusal_pending: "A request is already waiting in your DM.",
@@ -39,7 +39,6 @@ register("webauth", {
39
39
  authorize: "授權這個瀏覽器",
40
40
  waiting: "等你在 Discord 或 Telegram 按下確認({0})",
41
41
  sent: "已送出——請在 5 分鐘內到私訊確認。",
42
- publicLink: "臨時公開連結上無法授權:請直接開啟儀表板再授權。",
43
42
  reason_disabled: "網頁授權已關閉。",
44
43
  reason_loopback_only: "只有這台電腦可以授權,而這個瀏覽器不在這台電腦上。",
45
44
  reason_no_account: "這個瀏覽器不是用帳號登入的(agend web):請用 /web 登入後再授權。",
@@ -47,7 +46,8 @@ register("webauth", {
47
46
  reason_not_admin_session: "訪客無法授權。",
48
47
  reason_token_rotated: "儀表板的 token 已變更:請重新登入。",
49
48
  reason_public_link: "臨時公開連結上無法授權。",
50
- reason_named_link_off: "透過固定網域:請在這台電腦的 設定 → 網頁 開啟「也允許透過我的固定網域」。",
49
+ reason_named_link_off: "透過固定網域:請在這台電腦的 設定 → 存取與安全 開啟「也允許透過我的固定網域」。",
50
+ reason_quick_link_off: "透過臨時公開連結:請在這台電腦的 設定 → 存取與安全 開啟「也允許透過臨時公開連結」。",
51
51
  reason_not_session: "登入後才能授權。",
52
52
  reason_other: "這個瀏覽器在這裡無法授權。",
53
53
  refusal_pending: "已經有一個請求在你的私訊中等待。",
@@ -62,7 +62,9 @@ register("webauth", {
62
62
  },
63
63
  });
64
64
  const tn = (k, ...v) => t(`webauth.${k}`, ...v);
65
- const REASONS = ["disabled", "loopback_only", "no_account", "not_admin", "not_admin_session", "token_rotated", "public_link", "named_link_off", "not_session"];
65
+ // The switch and "Only this computer" are changed in Settings on this computer — never over the public link (#2525 review).
66
+ const fixable = (reason) => (reason === "disabled" || reason === "loopback_only") && globalThis.document?.body?.dataset?.publicLink !== "1";
67
+ const REASONS = ["disabled", "loopback_only", "no_account", "not_admin", "not_admin_session", "token_rotated", "public_link", "named_link_off", "quick_link_off", "not_session"];
66
68
  // Until when: the time, with the day when it isn't today (an authorization lasts up to 48 h).
67
69
  const clock = (ms) => {
68
70
  const d = new Date(ms), today = new Date().toDateString() === d.toDateString();
@@ -70,7 +72,6 @@ const clock = (ms) => {
70
72
  : d.toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
71
73
  };
72
74
  const mmss = (ms) => { const s = Math.max(0, Math.ceil(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
73
- const publicLink = () => globalThis.document?.body?.dataset?.publicLink === "1";
74
75
 
75
76
  // One read per page load, shared by every line that shows it (the composer's, the session menu's): coming back to a chat
76
77
  // reads nothing (#1374). It is read again only after an ask, and while a card is pending.
@@ -98,7 +99,9 @@ export function useWebAuthorization() {
98
99
  const [deadline, setDeadline] = useState(0), [now, setNow] = useState(() => Date.now());
99
100
  const alive = useRef(true);
100
101
  const load = async () => {
101
- if (publicLink() || guestPage()) return;
102
+ // §0.2 / §0.3: over the public link too — the server says whether this browser may be authorized there (its link's
103
+ // option on) or why not; a guest never asks.
104
+ if (guestPage()) return;
102
105
  const body = await readStatus();
103
106
  if (!alive.current) return;
104
107
  if (signedOut) { setSt(null); setNotice(null); return; }
@@ -144,7 +147,6 @@ export function resetWebAuthorization() { shared = null; answered = false; infli
144
147
  export function AuthorizeChip() {
145
148
  const a = useWebAuthorization();
146
149
  if (guestPage()) return null;
147
- if (publicLink()) return html`<p class="webauth-chip note" data-state="public">${tn("publicLink")}</p>`;
148
150
  if (!a.st) return null;
149
151
  return html`<div class="webauth-chip" data-state=${a.st.state}>${body(a)}${a.notice ? html` <span class="note" role="status">${a.notice}</span>` : null}</div>`;
150
152
  }
@@ -163,7 +165,6 @@ export function ComposerAuth() {
163
165
  const tip = useRef("wa-tip-" + Math.random().toString(36).slice(2, 8));
164
166
  useEffect(() => { setOpen(false); }, [a.st && a.st.state]); // a new state closes the bubble
165
167
  if (guestPage()) return null;
166
- if (publicLink()) return html`<span class="webauth-row note" data-state="public" title=${tn("publicLink")}>${tn("publicLink")}</span>`;
167
168
  if (!a.st) return null;
168
169
  const st = a.st, id = tip.current;
169
170
  const said = a.notice ? html`<span class="webauth-notice" role="status">${a.notice}</span>` : null;
@@ -184,8 +185,8 @@ export function ComposerAuth() {
184
185
  title=${tn("authorizeWhy")} aria-label=${tn("authorizeWhy")} onClick=${a.ask}>${tn("authorizeShort")}</button>${said}</span>`;
185
186
  }
186
187
  const why = REASONS.includes(st.reason) ? tn(`reason_${st.reason}`) : tn("reason_other");
187
- return html`<span class="webauth-row" data-state="none"><span class="note" title=${why}>${tn("notVerified")}<span class="sr-only">: ${why}</span></span>${st.reason === "disabled" || st.reason === "loopback_only"
188
- ? html` · <a href="/settings/web" title=${why}>${tn("settings")}</a>` : null}</span>`;
188
+ return html`<span class="webauth-row" data-state="none"><span class="note" title=${why}>${tn("notVerified")}<span class="sr-only">: ${why}</span></span>${fixable(st.reason)
189
+ ? html` · <a href="/settings/access" title=${why}>${tn("settings")}</a>` : null}</span>`;
189
190
  }
190
191
 
191
192
  function body(a) {
@@ -196,5 +197,5 @@ function body(a) {
196
197
  return html`<span class="note">${tn("notVerified")} ·</span> <button type="button" class="btn btn-sm" onClick=${a.ask}>${tn("authorize")}</button>`;
197
198
  const why = REASONS.includes(st.reason) ? tn(`reason_${st.reason}`) : tn("reason_other");
198
199
  // The fix, where there is one the person can take here: the switch lives in Settings → Web.
199
- return html`<span class="note">${tn("notVerified")}: ${why}</span>${st.reason === "disabled" || st.reason === "loopback_only" ? html` <a href="/settings/web">${tn("settings")}</a>` : null}`;
200
+ return html`<span class="note">${tn("notVerified")}: ${why}</span>${fixable(st.reason) ? html` <a href="/settings/access">${tn("settings")}</a>` : null}`;
200
201
  }
package/dist/web-api.d.ts CHANGED
@@ -9,6 +9,7 @@ import type { ModStore } from "./web-mod-store.js";
9
9
  import type { WebEffectService } from "./web-effect-service.js";
10
10
  import type { ThemeStore } from "./web-theme-store.js";
11
11
  import type { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
12
+ import { type OfficeSkinStore } from "./web-office-skins.js";
12
13
  import type { WebAppearance } from "./web-appearance.js";
13
14
  import type { SendOpts } from "./channel/types.js";
14
15
  import type { IncomingMessage, ServerResponse } from "node:http";
@@ -195,6 +196,10 @@ export interface WebApiContext {
195
196
  readonly officeStore?: OfficeStore;
196
197
  /** How often a person may save the offices (absent: no limit, as in a test without it). */
197
198
  readonly officeWrites?: OfficeWriteLimiter;
199
+ /** #2513: the Office view's uploaded skin packs (web-office-skins/); absent: /ui/office-skins is not offered. */
200
+ readonly officeSkins?: OfficeSkinStore;
201
+ /** How often a person may upload or delete a skin pack (absent: no limit). */
202
+ readonly officeSkinWrites?: OfficeWriteLimiter;
198
203
  readonly webAppearance?: WebAppearance;
199
204
  /** #1629 r4-5: installed mods and a person's staged import. */
200
205
  readonly mods?: ModStore;
@@ -212,7 +217,7 @@ export interface WebApiContext {
212
217
  } | null;
213
218
  /** A fleet.yaml instance (own key) or a ClassicBot room, now. */
214
219
  isKnownInstance?(name: string): boolean;
215
- startInstance(name: string, config: unknown, topicMode: boolean): Promise<void>;
220
+ startInstance(name: string, config: unknown, topicMode: boolean, kind?: "fleet-topic" | "classic", resumePaused?: boolean): Promise<void>;
216
221
  stopInstance(name: string): Promise<{
217
222
  backgroundWorkEnded: boolean;
218
223
  }>;
@@ -402,8 +407,9 @@ export interface WebPerson {
402
407
  readonly platformName?: string;
403
408
  }
404
409
  /**
405
- * #1939 §3.4 / #2453 §0.2: where this request came from — the admin's named link by the gateway's own provenance (its
406
- * exact hostname), else what the TCP peer and Host say (a quick tunnel's request is `lan`, and refused as public_link).
410
+ * #1939 §3.4 / #2453 §0.2 / §0.3: where this request came from — over the public gateway, the link itself by the
411
+ * gateway's own provenance (never a header): the admin's named tunnel on its exact hostname, else the temporary
412
+ * quick-tunnel link. Off the gateway, what the TCP peer and Host say.
407
413
  */
408
414
  export declare function webPeerOf(req: IncomingMessage): WebPeer;
409
415
  export declare function handleWebRequest(req: IncomingMessage, res: ServerResponse, url: URL, ctx: WebApiContext): boolean;
package/dist/web-api.js CHANGED
@@ -15,6 +15,7 @@ import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widg
15
15
  import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
16
16
  import { canonicalText } from "./web-theme.js";
17
17
  import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
18
+ import { SKIN_WIRE_BYTES } from "./web-office-skins.js";
18
19
  import { gatewayRequestContext } from "./web-request-context.js";
19
20
  import { crossInstanceVisibility } from "./cross-instance-notice.js";
20
21
  import { permitWebContinuation } from "./web-continuation.js";
@@ -447,6 +448,141 @@ function handleAppIcon(req, res, url, ctx, method) {
447
448
  });
448
449
  return true;
449
450
  }
451
+ /**
452
+ * #2513: the Office view's skin packs (web-office-skins.ts). The reads are the offices' (GET /ui/offices): whoever may
453
+ * open the org chart, the public link too (its manifest lists them; a guest's routes do not): the list, a pack's
454
+ * manifest (?v=<sha>: immutable, else no-store) and its pictures (by the sha of their bytes: immutable, image/png,
455
+ * nosniff, sandboxed). The writes are PUT /ui/offices's: an admin session only — never the CLI token, never the public
456
+ * link (absent from its manifest, and refused here too) — asked again once the body is in; rate limited per person.
457
+ * Every page hears `office_skins` on the stream and reads the list again.
458
+ */
459
+ function handleOfficeSkins(req, res, url, ctx, path, method) {
460
+ const store = ctx.officeSkins;
461
+ if (!store) {
462
+ json(res, 404, { error: "Skin packs are not kept on this fleet" });
463
+ return true;
464
+ }
465
+ const one = /^\/ui\/office-skins\/([A-Za-z0-9_-]{1,40})$/.exec(path);
466
+ const picture = /^\/ui\/office-skins\/([A-Za-z0-9_-]{1,40})\/img\/([0-9a-f]{32})\.png$/.exec(path);
467
+ if (method === "GET" || method === "HEAD") {
468
+ res.setHeader("X-Content-Type-Options", "nosniff");
469
+ if (path === "/ui/office-skins") {
470
+ res.setHeader("Cache-Control", "private, no-store");
471
+ json(res, 200, store.list());
472
+ return true;
473
+ }
474
+ if (one) {
475
+ const pack = store.get(one[1]);
476
+ if (!pack) {
477
+ res.setHeader("Cache-Control", "private, no-store");
478
+ json(res, 404, { error: "skin_unknown" });
479
+ return true;
480
+ }
481
+ const v = url.searchParams.get("v");
482
+ res.setHeader("Cache-Control", v === pack.sha ? "private, max-age=31536000, immutable" : "private, no-store");
483
+ json(res, 200, pack);
484
+ return true;
485
+ }
486
+ if (picture) {
487
+ const bytes = store.picture(picture[1], picture[2]);
488
+ if (!bytes) {
489
+ res.setHeader("Cache-Control", "private, no-store");
490
+ json(res, 404, { error: "No such image" });
491
+ return true;
492
+ }
493
+ res.setHeader("Content-Type", "image/png");
494
+ // Keyed by the sha of its bytes: a picture at this URL never changes.
495
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
496
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
497
+ res.writeHead(200);
498
+ res.end(method === "HEAD" ? undefined : bytes);
499
+ return true;
500
+ }
501
+ json(res, 404, { error: "not found" });
502
+ return true;
503
+ }
504
+ const upload = method === "POST" && path === "/ui/office-skins", removal = method === "DELETE" && !!one;
505
+ if (!upload && !removal) {
506
+ json(res, 404, { error: "not found" });
507
+ return true;
508
+ }
509
+ if (gatewayRequestContext(req)) {
510
+ json(res, 403, { error: "skins_not_over_public_link" });
511
+ return true;
512
+ }
513
+ const who = () => {
514
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
515
+ if (verdict.kind !== "allow")
516
+ return { status: 401 };
517
+ return verdict.via === "session" && verdict.session?.tier === "admin" ? { person: verdict.session.owner ? webPersonKey(verdict.session.owner) : "admin-session" } : { status: 403 };
518
+ };
519
+ const refuse = (status) => json(res, status, { error: status === 401 ? WEB_TOKEN_INVALID_MESSAGE : "skins_need_an_admin_session" });
520
+ const first = who();
521
+ if ("status" in first) {
522
+ refuse(first.status);
523
+ return true;
524
+ }
525
+ const limited = (person) => {
526
+ const allowed = ctx.officeSkinWrites ? ctx.officeSkinWrites.check(person) : { ok: true };
527
+ if (allowed.ok)
528
+ return false;
529
+ res.setHeader("Retry-After", String(allowed.retry_after_s));
530
+ json(res, 429, { error: "skins_rate_limited", retry_after_s: allowed.retry_after_s });
531
+ return true;
532
+ };
533
+ const answer = (r, body, person) => {
534
+ if (!r.ok) {
535
+ json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
536
+ return;
537
+ }
538
+ ctx.officeSkinWrites?.record(person);
539
+ ctx.emitSseEvent("office_skins", { rev: r.rev });
540
+ json(res, 200, body());
541
+ };
542
+ if (removal) {
543
+ if (limited(first.person))
544
+ return true;
545
+ const r = store.remove(one[1]);
546
+ answer(r, () => ({ rev: r.ok ? r.rev : "" }), first.person);
547
+ return true;
548
+ }
549
+ (async () => {
550
+ let bytes;
551
+ try {
552
+ bytes = await readBoundedWebBody(req, SKIN_WIRE_BYTES);
553
+ }
554
+ catch {
555
+ json(res, 413, { error: "skin_too_large", detail: `at most ${Math.floor(SKIN_WIRE_BYTES / 1024 / 1024)} MiB a request` });
556
+ return;
557
+ }
558
+ if (!permitWebContinuation(req, res, ctx))
559
+ return;
560
+ const now = who();
561
+ if ("status" in now) {
562
+ refuse(now.status);
563
+ return;
564
+ }
565
+ let body;
566
+ try {
567
+ body = JSON.parse(bytes.toString("utf8"));
568
+ }
569
+ catch {
570
+ json(res, 400, { error: "Invalid JSON" });
571
+ return;
572
+ }
573
+ if (limited(now.person))
574
+ return;
575
+ const r = store.add(body, now.person);
576
+ answer(r, () => (r.ok ? { rev: r.rev, pack: r.pack } : null), now.person);
577
+ })().catch(err => {
578
+ ctx.logger.error({ err: err.message }, "Uploading a skin pack failed");
579
+ try {
580
+ json(res, 500, { error: "Uploading a skin pack failed" });
581
+ }
582
+ catch { /* already answered */ }
583
+ });
584
+ return true;
585
+ }
450
586
  /**
451
587
  * #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
452
588
  * signed-in human session is, or the status to answer.
@@ -1311,11 +1447,13 @@ function webAuthorityOf(req, url, ctx, instance) {
1311
1447
  peer: webPeerOf(req), ...(instance !== undefined ? { instance } : {}) });
1312
1448
  }
1313
1449
  /**
1314
- * #1939 §3.4 / #2453 §0.2: where this request came from — the admin's named link by the gateway's own provenance (its
1315
- * exact hostname), else what the TCP peer and Host say (a quick tunnel's request is `lan`, and refused as public_link).
1450
+ * #1939 §3.4 / #2453 §0.2 / §0.3: where this request came from — over the public gateway, the link itself by the
1451
+ * gateway's own provenance (never a header): the admin's named tunnel on its exact hostname, else the temporary
1452
+ * quick-tunnel link. Off the gateway, what the TCP peer and Host say.
1316
1453
  */
1317
1454
  export function webPeerOf(req) {
1318
- return gatewayRequestContext(req)?.named === true ? "named_link" : classifyWebPeer(req);
1455
+ const gateway = gatewayRequestContext(req);
1456
+ return gateway ? (gateway.named === true ? "named_link" : "quick_link") : classifyWebPeer(req);
1319
1457
  }
1320
1458
  /**
1321
1459
  * #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
@@ -2074,6 +2212,8 @@ export function handleWebRequest(req, res, url, ctx) {
2074
2212
  });
2075
2213
  return true;
2076
2214
  }
2215
+ if (path === "/ui/office-skins" || path.startsWith("/ui/office-skins/"))
2216
+ return handleOfficeSkins(req, res, url, ctx, path, method);
2077
2217
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
2078
2218
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
2079
2219
  if (method === "GET" && imageMatch) {
@@ -2625,7 +2765,7 @@ export function handleWebRequest(req, res, url, ctx) {
2625
2765
  (async () => {
2626
2766
  let held = false;
2627
2767
  try {
2628
- await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
2768
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
2629
2769
  ctx.emitSseEvent("status", ctx.getUiStatus());
2630
2770
  if (!held)
2631
2771
  json(res, 200, { started: name });
@@ -3268,10 +3408,10 @@ export function handleWebRequest(req, res, url, ctx) {
3268
3408
  return [403, "usage_report_not_over_public_link"];
3269
3409
  return null;
3270
3410
  };
3271
- // Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
3411
+ // Over the public link, where to turn it on: Settings → Web dashboard on the dashboard reached directly (this computer's own
3272
3412
  // address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
3273
3413
  const answer = ([status, error]) => json(res, status, {
3274
- error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
3414
+ error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web-dashboard` } : {})
3275
3415
  });
3276
3416
  const first = refusal();
3277
3417
  if (first) {
@@ -3866,6 +4006,11 @@ function handleUpload(req, res, url, ctx) {
3866
4006
  // staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
3867
4007
  const deliverable = () => uploadRecipient(ctx, instance);
3868
4008
  const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
4009
+ // #2366: a guest's scope (and whether guests may chat) is asked again at each commit point — narrowed, revoked or made
4010
+ // read-only while the body was arriving, the upload is refused and its part file removed, as it would have been at
4011
+ // the start. Read now: the scope is never stored (guestRequestOf's scope() is the fleet's guestScope as it is now).
4012
+ const guestNow = () => guestChatRefusal(guestRequestOf(req), instance);
4013
+ const refuseGuest = (r) => drop(() => json(res, r.status, { error: r.error }));
3869
4014
  req.on("end", () => {
3870
4015
  if (over)
3871
4016
  return;
@@ -3880,6 +4025,11 @@ function handleUpload(req, res, url, ctx) {
3880
4025
  drop(() => json(res, 415, { error: "the file is empty" }));
3881
4026
  return;
3882
4027
  }
4028
+ const refusedAtEnd = guestNow();
4029
+ if (refusedAtEnd) {
4030
+ refuseGuest(refusedAtEnd);
4031
+ return;
4032
+ }
3883
4033
  if (!deliverable()) {
3884
4034
  gone();
3885
4035
  return;
@@ -3892,6 +4042,11 @@ function handleUpload(req, res, url, ctx) {
3892
4042
  drop();
3893
4043
  return;
3894
4044
  }
4045
+ const refusedAtFinish = guestNow(); // #2366: and the guest's scope may have changed in it
4046
+ if (refusedAtFinish) {
4047
+ refuseGuest(refusedAtFinish);
4048
+ return;
4049
+ }
3895
4050
  if (!deliverable()) {
3896
4051
  gone();
3897
4052
  return;