getbotons 0.0.0-stage → 0.1.0

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 (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,117 @@
1
+ import type { BrainProblem, DatabaseProblem, Health, HealthCheck, HealthDetails, OperationsBrain, OperationsHealth, ProviderInfo } from '../../shared/types.ts';
2
+ import { buildSha, versionNumber } from '../../shared/util.ts';
3
+ import type { Config } from './config.ts';
4
+ import type { Engine } from './engine.ts';
5
+ import type { ProviderRegistry } from './providers/index.ts';
6
+ import type { Store } from './store.ts';
7
+
8
+ export const SCHEDULER_STALE_INTERVALS = 3;
9
+
10
+ /** SQLite's primary result codes (node:sqlite's `errcode`, extended codes included), as the owner's fixed words. */
11
+ const SQLITE_PROBLEMS: Record<number, DatabaseProblem> = { 5: 'busy', 6: 'busy', 8: 'read-only', 10: 'io-error', 11: 'corrupt', 13: 'disk-full', 14: 'cannot-open', 26: 'corrupt' };
12
+
13
+ /** What a failed database check says about where to look, as a fixed word. */
14
+ export function databaseProblem(err: unknown): DatabaseProblem {
15
+ const code = (err as { errcode?: unknown })?.errcode;
16
+ return (typeof code === 'number' && SQLITE_PROBLEMS[code & 0xff]) || 'other';
17
+ }
18
+
19
+ function brainProblem(p: ProviderInfo): BrainProblem | null {
20
+ if (p.available) return null;
21
+ if (!p.installed) return 'not-installed';
22
+ if (p.loggedIn === false) return 'signed-out';
23
+ return 'unavailable';
24
+ }
25
+
26
+ /** A brain for the owner's view: its states and times, a version number only if one is found, and none of the CLI's text. */
27
+ export function ownerBrain(p: ProviderInfo, usageLimitUntil: string | null = null): OperationsBrain {
28
+ return {
29
+ id: p.id,
30
+ label: p.label,
31
+ available: p.available,
32
+ installed: p.installed,
33
+ loggedIn: p.loggedIn,
34
+ version: versionNumber(p.version),
35
+ problem: brainProblem(p),
36
+ lastSuccessAt: p.lastSuccessAt,
37
+ lastErrorAt: p.lastError?.at ?? null,
38
+ usageLimitUntil,
39
+ readiness: p.readiness,
40
+ };
41
+ }
42
+
43
+ /** Whether this server can do its job, not just answer. */
44
+ export class HealthMonitor {
45
+ #cfg: Config;
46
+ #store: Store;
47
+ #engine: Engine;
48
+ #providers: ProviderRegistry;
49
+ #startedAt = Date.now();
50
+ #lastTickAt: number | null = null;
51
+
52
+ constructor(deps: { cfg: Config; store: Store; engine: Engine; providers: ProviderRegistry }) {
53
+ this.#cfg = deps.cfg;
54
+ this.#store = deps.store;
55
+ this.#engine = deps.engine;
56
+ this.#providers = deps.providers;
57
+ }
58
+
59
+ schedulerTicked(at = Date.now()): void {
60
+ this.#lastTickAt = at;
61
+ }
62
+
63
+ async check(): Promise<Required<Health>> {
64
+ return (await this.#check()).health;
65
+ }
66
+
67
+ /** The same checks for Settings → Health: fixed words for what failed, never error text, paths or CLI output. */
68
+ async owner(usageLimitUntil: (providerId: string) => string | null = () => null): Promise<OperationsHealth> {
69
+ const { health, databaseProblem: problem } = await this.#check();
70
+ const { details } = health;
71
+ return {
72
+ ok: health.ok,
73
+ build: buildSha(health.build),
74
+ failing: health.failing,
75
+ details: {
76
+ database: { ok: details.database.ok, problem: details.database.ok ? null : problem },
77
+ scheduler: details.scheduler,
78
+ providers: details.providers.map((p) => ownerBrain(p, usageLimitUntil(p.id))),
79
+ draining: details.draining,
80
+ },
81
+ };
82
+ }
83
+
84
+ async #check(): Promise<{ health: Required<Health>; databaseProblem: DatabaseProblem | null }> {
85
+ const now = Date.now();
86
+ const { problem, ...database } = this.#database();
87
+ const intervalMs = this.#cfg.schedulerIntervalMs;
88
+ const scheduler = {
89
+ ok: now - (this.#lastTickAt ?? this.#startedAt) <= SCHEDULER_STALE_INTERVALS * intervalMs,
90
+ lastTickAt: this.#lastTickAt === null ? null : new Date(this.#lastTickAt).toISOString(),
91
+ intervalMs,
92
+ };
93
+ let providers: ProviderInfo[] = [];
94
+ let providersOk = false;
95
+ try {
96
+ providers = await this.#providers.infos();
97
+ providersOk = providers.some((p) => p.available);
98
+ } catch {}
99
+ const draining = this.#engine.draining;
100
+ const failing: HealthCheck[] = [];
101
+ if (!database.ok) failing.push('database');
102
+ if (!scheduler.ok) failing.push('scheduler');
103
+ if (!providersOk) failing.push('providers');
104
+ if (draining) failing.push('draining');
105
+ const details: HealthDetails = { database, scheduler, providers, draining };
106
+ return { health: { ok: failing.length === 0, build: this.#cfg.build, failing, details }, databaseProblem: problem };
107
+ }
108
+
109
+ #database(): HealthDetails['database'] & { problem: DatabaseProblem | null } {
110
+ try {
111
+ this.#store.db.prepare('select count(*) as n from bots').get();
112
+ return { ok: true, error: null, problem: null };
113
+ } catch (err) {
114
+ return { ok: false, error: (err as Error).message, problem: databaseProblem(err) };
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,96 @@
1
+ import { closeSync, constants, mkdirSync, openSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /*
5
+ * Folders held open, so a path under one is looked up from the folder itself, not again from the root. Server code
6
+ * that reads or writes a Workspace Boton's files runs outside its sandbox, while the Boton can rename and relink anything
7
+ * in its workspace: checking a path and then using it again would follow a folder swapped for a link in between.
8
+ * Linux names an open folder's entries as /proc/self/fd/<fd>/<name>, so each step opens one name in a folder already
9
+ * open, never through a link. Imports Node builtins only (the `botons` CLI loads it).
10
+ */
11
+
12
+ /**
13
+ * Whether this system can look up a name in an open folder (Linux's /proc/self/fd). Without it nothing here works:
14
+ * a folder looked up again by its path is what a swap between two steps redirects, so there is no fallback to that.
15
+ */
16
+ export let heldLookups: boolean = (() => {
17
+ try {
18
+ return process.platform === 'linux' && statSync('/proc/self/fd').isDirectory();
19
+ } catch {
20
+ return false;
21
+ }
22
+ })();
23
+
24
+ /** Tests only (set in code, never from a setting): as on a system without lookups in an open folder. */
25
+ export function useHeldLookups(on: boolean): void {
26
+ heldLookups = on;
27
+ }
28
+
29
+ export class NoHeldLookups extends Error {
30
+ constructor() {
31
+ super('this system cannot look up a name in an open folder (Linux /proc/self/fd), so Botons does not touch files a Boton could swap meanwhile');
32
+ }
33
+ }
34
+
35
+ export interface HeldDir {
36
+ fd: number;
37
+ /** Where it was when opened; only for messages. */
38
+ path: string;
39
+ }
40
+
41
+ /** The path that names the open folder `dir` itself. */
42
+ export function heldPath(dir: HeldDir): string {
43
+ if (!heldLookups) throw new NoHeldLookups();
44
+ return `/proc/self/fd/${dir.fd}`;
45
+ }
46
+
47
+ export class LinkInTheWay extends Error {}
48
+
49
+ const DIR_FLAGS = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
50
+
51
+ /** The path that names `name` in the open folder `dir`. */
52
+ export function under(dir: HeldDir, name: string): string {
53
+ if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\0')) throw new Error(`not a single name: ${name}`);
54
+ return `${heldPath(dir)}/${name}`;
55
+ }
56
+
57
+ /** Opens the folder at `path` (links followed: the caller trusts it, as the server's own setting). */
58
+ export function holdDir(path: string): HeldDir {
59
+ return { fd: openSync(path, constants.O_RDONLY | constants.O_DIRECTORY), path };
60
+ }
61
+
62
+ /**
63
+ * Opens the folder `parts` beneath `root`, one name at a time, refusing a link anywhere on the way (LinkInTheWay). With
64
+ * `create`, missing folders are made. The caller closes what it returns unless it is `root` itself (no `parts`).
65
+ */
66
+ export function holdBeneath(root: HeldDir, parts: string[], create = false): HeldDir {
67
+ let cur = root;
68
+ try {
69
+ for (const name of parts) {
70
+ const at = under(cur, name);
71
+ let fd: number;
72
+ try {
73
+ fd = openSync(at, DIR_FLAGS);
74
+ } catch (e) {
75
+ const code = (e as NodeJS.ErrnoException).code;
76
+ if (code === 'ENOENT' && create) {
77
+ try {
78
+ mkdirSync(at);
79
+ } catch (m) {
80
+ if ((m as NodeJS.ErrnoException).code !== 'EEXIST') throw m;
81
+ }
82
+ fd = openSync(at, DIR_FLAGS);
83
+ } else throw e;
84
+ }
85
+ if (cur !== root) closeSync(cur.fd);
86
+ cur = { fd, path: join(cur.path, name) };
87
+ }
88
+ return cur;
89
+ } catch (e) {
90
+ if (cur !== root) closeSync(cur.fd);
91
+ const code = (e as NodeJS.ErrnoException).code;
92
+ // O_NOFOLLOW on a link: ELOOP; O_DIRECTORY on a link it didn't follow, or on a file: ENOTDIR.
93
+ if (code === 'ELOOP' || code === 'ENOTDIR') throw new LinkInTheWay(`${join(root.path, ...parts)} is not a folder of its own: a link or file is in the way`);
94
+ throw e;
95
+ }
96
+ }
@@ -0,0 +1,141 @@
1
+ import type { Routine, RoutineRun, WebhookRunStatus } from '../../shared/types.ts';
2
+ import { earlierName } from '../../shared/legacy-names.mjs';
3
+ import type { Config } from './config.ts';
4
+ import type { Engine } from './engine.ts';
5
+ import type { Ctx, Router } from './http.ts';
6
+ import { log } from './log.ts';
7
+ import { HttpError, type Store } from './store.ts';
8
+ import { orNull } from './util.ts';
9
+ import { DeliveryError, deliveryId, deliveryKeys, freshnessProblem, handshake, matchesFilter, parseDelivery, safeEqual, shownHeaders, signatureProblem } from './webhooks.ts';
10
+
11
+ const WAIT_DEFAULT_S = 300;
12
+ const WAIT_MAX_S = 1800;
13
+ /** `?wait` on the public listener is cut to this: an open request is a held socket on the internet. */
14
+ export const PUBLIC_WAIT_MAX_S = 30;
15
+
16
+ /** `?timeout=<seconds>` for webhook waits, clamped to 1..`max`; junk falls back to the default. */
17
+ export function waitSeconds(raw: string | null, max: number): number {
18
+ const n = raw === null || raw.trim() === '' ? NaN : Number(raw);
19
+ return Number.isFinite(n) ? Math.min(Math.max(n, 1), max) : Math.min(WAIT_DEFAULT_S, max);
20
+ }
21
+
22
+ function givenKey(c: Ctx): string {
23
+ const auth = c.req.headers.authorization ?? '';
24
+ return (auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '') || String(c.req.headers['x-boton-key'] ?? c.req.headers[earlierName('x-boton-key')!] ?? '') || c.url.searchParams.get('key') || '';
25
+ }
26
+
27
+ export function addHookRoutes(r: Router, deps: { cfg: Config; store: Store; engine: Engine }): Router {
28
+ const { cfg, store, engine } = deps;
29
+
30
+ /** 202 with the queued run (a redelivery's: 200), or with ?wait=1 its result once it has one. */
31
+ const answerRun = async (c: Ctx, routine: Routine, run: RoutineRun, callerGone: AbortSignal, extra: { duplicate?: true } = {}) => {
32
+ const location = `${routine.webhook!.url}/runs/${encodeURIComponent(run.id)}`;
33
+ const pending = (r: RoutineRun, status: number) => {
34
+ c.res.setHeader('location', location);
35
+ c.json(status, { ok: true, runId: r.id, status: r.status, ...extra });
36
+ };
37
+ const wait = ['1', 'true', 'yes'].includes(c.url.searchParams.get('wait') ?? '');
38
+ if (!wait) return pending(run, extra.duplicate ? 200 : 202);
39
+ c.req.socket.setTimeout(0);
40
+ const viaPublic = cfg.hooks.port !== null && c.req.socket.localPort === cfg.hooks.port;
41
+ const done = await engine.waitForRun(run, waitSeconds(c.url.searchParams.get('timeout'), viaPublic ? PUBLIC_WAIT_MAX_S : WAIT_MAX_S) * 1000, callerGone);
42
+ if (callerGone.aborted) return;
43
+ if (engine.closing.aborted && (done.status === 'queued' || done.status === 'running')) {
44
+ c.res.setHeader('location', location);
45
+ c.res.setHeader('retry-after', '30');
46
+ return c.json(503, {
47
+ ok: false,
48
+ runId: done.id,
49
+ status: done.status,
50
+ error: 'The server is restarting before this run finished. It runs (or is marked interrupted) when the server is back: check the run at the Location.',
51
+ ...extra,
52
+ });
53
+ }
54
+ // Gone: the run was deleted with its routine or Boton while waiting.
55
+ const gone = !store.findRun(done.id);
56
+ if (!gone && (done.status === 'queued' || done.status === 'running')) return pending(done, 202);
57
+ c.json(gone ? 410 : 200, { ok: done.status === 'succeeded', runId: done.id, status: done.status, reply: done.reply, error: done.error, ...extra });
58
+ };
59
+
60
+ // A generic routine takes its key; the others take only their sender's signature.
61
+ r.on('POST', '/hooks/routines/:id', async (c) => {
62
+ // On res, not req: req's 'close' fires as soon as its body has been read.
63
+ const callerGone = new AbortController();
64
+ c.res.once('close', () => callerGone.abort());
65
+ const routine = orNull(() => store.getRoutine(c.params.id));
66
+ const secrets = routine?.webhook ? store.webhookSecrets(routine.id) : null;
67
+ if (!routine?.webhook || !secrets) throw new HttpError(401, 'Unknown routine or bad key');
68
+ const { source } = routine.webhook;
69
+ const headers = c.req.headers;
70
+ if (source === 'generic') {
71
+ const given = givenKey(c);
72
+ if (!given || !safeEqual(given, secrets.key)) throw new HttpError(401, 'Unknown routine or bad key');
73
+ }
74
+ const bytes = await c.rawBytes();
75
+ // A signed sender's signature covers the exact bytes, so a forged delivery is refused before anything parses them.
76
+ if (source !== 'generic') {
77
+ const bad = signatureProblem(source, secrets, headers, bytes);
78
+ if (bad) throw new HttpError(401, bad);
79
+ }
80
+ let delivery: ReturnType<typeof parseDelivery>;
81
+ try {
82
+ delivery = parseDelivery(bytes, headers['content-type']);
83
+ } catch (err) {
84
+ if (err instanceof DeliveryError) throw new HttpError(400, err.message);
85
+ throw err;
86
+ }
87
+ if (source !== 'generic') {
88
+ const stale = freshnessProblem(source, delivery.json);
89
+ if (stale) throw new HttpError(401, stale);
90
+ }
91
+ const answer = handshake(source, headers, delivery.json);
92
+ if (answer) return c.json(200, answer);
93
+
94
+ // A redelivery within a day gets the run the first one started, unless that run failed or was skipped.
95
+ const id = deliveryId(source, headers, delivery.json);
96
+ const keys = deliveryKeys(source, id, bytes);
97
+ const earlier = keys.map((k) => store.findDelivery(routine.id, k)).find((r) => r !== null) ?? null;
98
+ if (earlier) {
99
+ // A failed run the owner retried: the redelivery gets the retry.
100
+ const first = store.findRun(earlier);
101
+ const run = first && store.latestRetry(first);
102
+ if (!run) return c.json(200, { ok: true, runId: earlier, status: null, duplicate: true });
103
+ if (run.status !== 'failed' && run.status !== 'skipped') return answerRun(c, routine, run, callerGone.signal, { duplicate: true });
104
+ log.info('webhook redelivery of a run that did not succeed: running it again', { routineId: routine.id, runId: run.id, status: run.status });
105
+ }
106
+ // A Slack retry with no event_id can't be matched to its first try, which probably started a run.
107
+ if (source === 'slack' && id === null && headers['x-slack-retry-num'] !== undefined) return c.json(200, { ok: true, skipped: true, reason: 'Slack retry' });
108
+ if (!routine.enabled) throw new HttpError(409, 'Routine is paused');
109
+ if (routine.webhook.filter && !matchesFilter(routine.webhook.filter, headers, delivery.json)) {
110
+ return c.json(200, { ok: true, skipped: true, reason: 'Filtered out' });
111
+ }
112
+ const run = engine.triggerRoutine(routine.id, 'webhook', delivery.payload, shownHeaders(headers));
113
+ for (const k of keys) store.recordDelivery(routine.id, k, run.id);
114
+ return answerRun(c, routine, run, callerGone.signal);
115
+ });
116
+
117
+ // Takes the routine's key whatever its sender.
118
+ r.on('GET', '/hooks/routines/:id/runs/:runId', (c) => {
119
+ const secrets = store.webhookSecrets(c.params.id);
120
+ const given = givenKey(c);
121
+ if (!secrets || !given || !safeEqual(given, secrets.key)) throw new HttpError(401, 'Unknown routine or bad key');
122
+ const run = store.findRun(c.params.runId);
123
+ if (!run || run.routineId !== c.params.id) throw new HttpError(404, `No run ${c.params.runId} of this routine`);
124
+ return {
125
+ ok: run.status === 'succeeded',
126
+ runId: run.id,
127
+ status: run.status,
128
+ trigger: run.trigger,
129
+ reply: run.reply,
130
+ error: run.error,
131
+ startedAt: run.startedAt,
132
+ finishedAt: run.finishedAt,
133
+ attempts: run.attempts,
134
+ retriedAs: run.meta?.retriedAs ?? null,
135
+ cancelled: !!run.meta?.cancelled,
136
+ timedOut: !!run.meta?.timedOut,
137
+ } satisfies WebhookRunStatus;
138
+ });
139
+
140
+ return r;
141
+ }
@@ -0,0 +1,207 @@
1
+ import { serverCommand } from './install-type.ts';
2
+ import { createReadStream, existsSync, statSync } from 'node:fs';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import { isIP } from 'node:net';
5
+ import { extname, join, normalize } from 'node:path';
6
+ import type { Principal, RouteAccess } from './auth.ts';
7
+ import { log } from './log.ts';
8
+ import { HttpError } from './store.ts';
9
+ import { inside, orNull } from './util.ts';
10
+
11
+ export interface Ctx {
12
+ req: IncomingMessage;
13
+ res: ServerResponse;
14
+ url: URL;
15
+ params: Record<string, string>;
16
+ /** Null for a public route called without a token, and on /hooks. */
17
+ principal: Principal | null;
18
+ /** At most 1 MB (or the route's own maxBody), else 413. */
19
+ rawBytes(): Promise<Buffer>;
20
+ /** JSON. With `strictUtf8`, bytes that aren't UTF-8 are a 400 rather than read as U+FFFD. */
21
+ body<T = any>(opts?: { strictUtf8?: boolean }): Promise<T>;
22
+ json(status: number, body: unknown): void;
23
+ }
24
+
25
+ type Handler = (ctx: Ctx) => unknown | Promise<unknown>;
26
+
27
+ const MAX_BODY = 1024 * 1024;
28
+
29
+ export class Router {
30
+ #routes: { method: string; parts: string[]; handler: Handler; access: RouteAccess; maxBody: number }[] = [];
31
+
32
+ /** By default only paired devices may call it, with a body of at most 1 MB. */
33
+ on(method: string, path: string, handler: Handler, access: RouteAccess = {}, opts: { maxBody?: number } = {}): this {
34
+ this.#routes.push({ method, parts: path.split('/').filter(Boolean), handler, access, maxBody: opts.maxBody ?? MAX_BODY });
35
+ return this;
36
+ }
37
+
38
+ match(method: string, pathname: string): { handler: Handler; params: Record<string, string>; access: RouteAccess; maxBody: number } | null | 'method' {
39
+ const segs = pathname.split('/').filter(Boolean);
40
+ let pathMatched = false;
41
+ for (const r of this.#routes) {
42
+ if (r.parts.length !== segs.length) continue;
43
+ const params: Record<string, string> = {};
44
+ let ok = true;
45
+ for (let i = 0; i < segs.length; i++) {
46
+ if (r.parts[i].startsWith(':')) {
47
+ const value = safeDecode(segs[i]);
48
+ if (value === null) throw new HttpError(400, 'Malformed URL encoding');
49
+ params[r.parts[i].slice(1)] = value;
50
+ } else if (r.parts[i] !== segs[i]) {
51
+ ok = false;
52
+ break;
53
+ }
54
+ }
55
+ if (!ok) continue;
56
+ pathMatched = true;
57
+ if (r.method === method) return { handler: r.handler, params, access: r.access, maxBody: r.maxBody };
58
+ }
59
+ return pathMatched ? 'method' : null;
60
+ }
61
+ }
62
+
63
+ export const safeDecode = (s: string): string | null => orNull(() => decodeURIComponent(s));
64
+
65
+ export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}) {
66
+ if (res.writableEnded || res.destroyed) return;
67
+ // Too late to change the status (say, a stream failed midway): just finish the response.
68
+ if (res.headersSent) return void res.end();
69
+ const data = JSON.stringify(body);
70
+ res.writeHead(status, { ...headers, 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
71
+ res.end(data);
72
+ }
73
+
74
+ export function makeCtx(req: IncomingMessage, res: ServerResponse, url: URL, params: Record<string, string>, principal: Principal | null, maxBody = MAX_BODY): Ctx {
75
+ let raw: Promise<Buffer> | null = null;
76
+ const rawBytes = () =>
77
+ (raw ??= new Promise<Buffer>((resolve, reject) => {
78
+ let size = 0;
79
+ const chunks: Buffer[] = [];
80
+ req.on('data', (c: Buffer) => {
81
+ size += c.length;
82
+ if (size > maxBody) {
83
+ reject(new HttpError(413, 'Request body too large'));
84
+ req.destroy();
85
+ return;
86
+ }
87
+ chunks.push(c);
88
+ });
89
+ req.on('end', () => resolve(Buffer.concat(chunks)));
90
+ // The caller hung up (or a request timeout cut it off): not a server error.
91
+ const incomplete = () => new HttpError(400, 'Request body incomplete');
92
+ req.on('error', (err) => reject(req.complete ? err : incomplete()));
93
+ req.on('close', () => {
94
+ if (!req.complete) reject(incomplete());
95
+ });
96
+ }));
97
+ return {
98
+ req,
99
+ res,
100
+ url,
101
+ params,
102
+ principal,
103
+ rawBytes,
104
+ async body(opts: { strictUtf8?: boolean } = {}) {
105
+ const bytes = await rawBytes();
106
+ let text: string;
107
+ if (!opts.strictUtf8) text = bytes.toString('utf8');
108
+ else {
109
+ try {
110
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);
111
+ } catch {
112
+ throw new HttpError(400, 'Body must be UTF-8 text');
113
+ }
114
+ }
115
+ if (!text.trim()) return {};
116
+ let parsed: unknown;
117
+ try {
118
+ parsed = JSON.parse(text);
119
+ } catch {
120
+ throw new HttpError(400, 'Body must be valid JSON');
121
+ }
122
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new HttpError(400, 'Body must be a JSON object');
123
+ return parsed as any;
124
+ },
125
+ json: (status, body) => sendJson(res, status, body),
126
+ };
127
+ }
128
+
129
+ const RAW_UPLOAD = /^\/api\/bots\/[^/]+\/uploads$/;
130
+
131
+ /**
132
+ * Browser-safety checks, a second line behind auth: the Host check blocks DNS
133
+ * rebinding, the rest drive-by requests from other sites. Mutations need a
134
+ * Content-Type that forces a CORS preflight: exactly application/json
135
+ * (`text/plain; x=application/json` is not it), or octet-stream for an upload.
136
+ */
137
+ export function guardRequest(req: IncomingMessage, url: URL, allowedHosts: Set<string>, publicOrigins: ReadonlySet<string> = new Set()): string | null {
138
+ const host = (req.headers.host ?? '').replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
139
+ if (!host || (!isIP(host) && !allowedHosts.has(host))) return `Host "${host}" is not allowed. Set BOTONS_ALLOWED_HOSTS to permit it.`;
140
+ const origin = req.headers.origin;
141
+ if (origin && origin !== 'null') {
142
+ try {
143
+ const parsed = new URL(origin);
144
+ const configured = parsed.origin !== 'null' && origin.toLowerCase() === parsed.origin.toLowerCase() && publicOrigins.has(parsed.origin.toLowerCase());
145
+ if (!configured && parsed.host.toLowerCase() !== (req.headers.host ?? '').toLowerCase()) return 'Cross-origin requests are not allowed';
146
+ } catch {
147
+ return 'Bad Origin header';
148
+ }
149
+ }
150
+ const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
151
+ if (mutating && url.pathname.startsWith('/api/')) {
152
+ if (origin === 'null') return 'Requests from an opaque origin (Origin: null) are not allowed';
153
+ const site = req.headers['sec-fetch-site'];
154
+ if (site !== undefined && site !== 'same-origin') return 'Cross-site requests are not allowed';
155
+ const type = (req.headers['content-type'] ?? '').split(';', 1)[0].trim().toLowerCase();
156
+ const upload = req.method === 'POST' && RAW_UPLOAD.test(url.pathname) && type === 'application/octet-stream';
157
+ if (!upload && type !== 'application/json') return 'API requests must use Content-Type: application/json';
158
+ }
159
+ return null;
160
+ }
161
+
162
+ const TYPES: Record<string, string> = {
163
+ '.html': 'text/html; charset=utf-8',
164
+ '.js': 'text/javascript; charset=utf-8',
165
+ '.css': 'text/css; charset=utf-8',
166
+ '.svg': 'image/svg+xml',
167
+ '.png': 'image/png',
168
+ '.ico': 'image/x-icon',
169
+ '.json': 'application/json',
170
+ '.webmanifest': 'application/manifest+json',
171
+ '.woff2': 'font/woff2',
172
+ };
173
+
174
+ /**
175
+ * The owner's app is never shown inside another page (CSP frame-ancestors, and X-Frame-Options for browsers without
176
+ * it): no site can lay it under its own and steer the owner's clicks onto an approval. A generated file's preview is
177
+ * served by the API with its own policy (PREVIEW_CSP in files.ts), never from here.
178
+ */
179
+ export const APP_FRAMING = { 'content-security-policy': "frame-ancestors 'none'", 'x-frame-options': 'DENY' } as const;
180
+
181
+ /** `rel`: the decoded request path. */
182
+ export function serveStatic(root: string, rel: string, res: ServerResponse): void {
183
+ if (!existsSync(join(root, 'index.html'))) {
184
+ res.writeHead(503, { 'content-type': 'text/html; charset=utf-8', ...APP_FRAMING });
185
+ res.end(`<h1>Botons</h1><p>The web app is not built yet. Run <code>${serverCommand('repair')}</code>.</p>`);
186
+ return;
187
+ }
188
+ const file = normalize(join(root, rel));
189
+ let target = inside(root, file) && existsSync(file) && statSync(file).isFile() ? file : null;
190
+ if (!target) {
191
+ if (extname(rel)) return void res.writeHead(404, APP_FRAMING).end('Not found');
192
+ target = join(root, 'index.html'); // SPA fallback
193
+ rel = '/index.html';
194
+ }
195
+ const immutable = rel.startsWith('/assets/');
196
+ res.writeHead(200, {
197
+ 'content-type': TYPES[extname(target)] ?? 'application/octet-stream',
198
+ 'cache-control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache',
199
+ ...APP_FRAMING,
200
+ });
201
+ createReadStream(target)
202
+ .on('error', (err) => {
203
+ log.error(`could not read ${target}`, { err });
204
+ res.destroy();
205
+ })
206
+ .pipe(res);
207
+ }