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,97 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { sep } from 'node:path';
3
+
4
+ // Node builtins only: the `botons` CLI loads this.
5
+
6
+ export type Row = Record<string, any>;
7
+
8
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
9
+
10
+ /** Short, prefixed, random ids that are easy for a Boton to type: "rt_k3j9x2ab". */
11
+ export function newId(prefix: string, length = 8): string {
12
+ let out = '';
13
+ for (const b of randomBytes(length)) out += ALPHABET[b % ALPHABET.length];
14
+ return `${prefix}_${out}`;
15
+ }
16
+
17
+ export function newSecret(): string {
18
+ return randomBytes(24).toString('base64url');
19
+ }
20
+
21
+ export const nowIso = (): string => new Date().toISOString();
22
+
23
+ /** "Tue, Nov 5, 9:30 AM" in `tz`. */
24
+ export function fmtTime(iso: string | Date, tz: string): string {
25
+ return new Date(iso).toLocaleString('en-US', { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
26
+ }
27
+
28
+ /** At most `max` characters: cut, the last one is "…". */
29
+ export function truncate(text: string, max: number): string {
30
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
31
+ }
32
+
33
+ /** On one line, then truncated. */
34
+ export function shorten(text: string, max: number): string {
35
+ return truncate(text.replace(/\s+/g, ' ').trim(), max);
36
+ }
37
+
38
+ /** `p` is `root` or in it, not just a path that starts like it ("/x/dist-old" for "/x/dist"). */
39
+ export function inside(root: string, p: string): boolean {
40
+ return p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
41
+ }
42
+
43
+ /** `fallback` for null, undefined or anything that isn't JSON. */
44
+ export function parseJson<T>(raw: unknown, fallback: T): T {
45
+ try {
46
+ return raw === null || raw === undefined ? fallback : (JSON.parse(String(raw)) as T);
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+
52
+ /** The strings in `v`, if it is a list. */
53
+ export const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []);
54
+
55
+ export function orNull<T>(get: () => T): T | null {
56
+ try {
57
+ return get();
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** A timeout in seconds as the agent CLIs take one (a positive number, at most a day), or undefined. */
64
+ export function seconds(v: unknown): number | undefined {
65
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 86_400 ? v : undefined;
66
+ }
67
+
68
+ // Line and paragraph separators and bidirectional overrides could make text read as other lines or in another order.
69
+ const UNSAFE_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
70
+
71
+ /**
72
+ * `text` in at most `max` UTF-16 units, cut after its last whole sentence that fits ("…" marks the cut): a word
73
+ * boundary when not even its first sentence fits, a character (never half of one) when not even a word does.
74
+ * Control characters, bidirectional overrides and runs of blank lines are dropped first.
75
+ */
76
+ export function clipText(text: string, max: number): { text: string; cut: boolean } {
77
+ // Only what can be kept is segmented: an endless reply costs no more than a long one.
78
+ const sliced = text.length > max * 4;
79
+ const clean = text
80
+ .slice(0, max * 4)
81
+ .replace(/\r\n?/g, '\n')
82
+ .replace(UNSAFE_CHARS, '')
83
+ .replace(/\n{3,}/g, '\n\n')
84
+ .trim();
85
+ if (!sliced && clean.length <= max) return { text: clean, cut: false };
86
+ const room = max - 1;
87
+ const fit = (granularity: 'sentence' | 'word' | 'grapheme') => {
88
+ let end = 0;
89
+ for (const s of new Intl.Segmenter('en', { granularity }).segment(clean)) {
90
+ if (s.index + s.segment.length > room) break;
91
+ end = s.index + s.segment.length;
92
+ }
93
+ return end;
94
+ };
95
+ const end = fit('sentence') || fit('word') || fit('grapheme');
96
+ return { text: `${clean.slice(0, end).trimEnd()}…`, cut: true };
97
+ }
@@ -0,0 +1,216 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
2
+ import type { IncomingHttpHeaders } from 'node:http';
3
+ import type { WebhookFilter, WebhookSource } from '../../shared/types.ts';
4
+
5
+ export const WEBHOOK_SOURCES: readonly WebhookSource[] = ['generic', 'github', 'linear', 'slack', 'sentry'];
6
+
7
+ export function isWebhookSource(v: unknown): v is WebhookSource {
8
+ return typeof v === 'string' && (WEBHOOK_SOURCES as readonly string[]).includes(v);
9
+ }
10
+
11
+ export const SOURCE_NAMES: Record<WebhookSource, string> = { generic: 'generic', github: 'GitHub', linear: 'Linear', slack: 'Slack', sentry: 'Sentry' };
12
+
13
+ export interface HookSecrets {
14
+ key: string;
15
+ secret: string | null;
16
+ }
17
+
18
+ function header(headers: IncomingHttpHeaders, name: string): string | undefined {
19
+ const v = headers[name];
20
+ return Array.isArray(v) ? v[0] : v;
21
+ }
22
+
23
+ function hmacHex(secret: string, ...parts: (string | Buffer)[]): string {
24
+ const h = createHmac('sha256', secret);
25
+ for (const p of parts) h.update(p);
26
+ return h.digest('hex');
27
+ }
28
+
29
+ function hexEqual(given: string | undefined, expected: string): boolean {
30
+ if (!given || given.length !== expected.length || !/^[0-9a-f]+$/i.test(given)) return false;
31
+ return timingSafeEqual(Buffer.from(given.toLowerCase(), 'hex'), Buffer.from(expected, 'hex'));
32
+ }
33
+
34
+ export function safeEqual(a: string, b: string): boolean {
35
+ const x = Buffer.from(a);
36
+ const y = Buffer.from(b);
37
+ return x.length === y.length && timingSafeEqual(x, y);
38
+ }
39
+
40
+ /** Null if a signed delivery is genuine and fresh, else the 401's message. `body` is the exact bytes received. */
41
+ export function verifySignature(
42
+ source: Exclude<WebhookSource, 'generic'>,
43
+ secrets: HookSecrets,
44
+ headers: IncomingHttpHeaders,
45
+ body: Buffer,
46
+ json: unknown,
47
+ now = Date.now(),
48
+ ): string | null {
49
+ return signatureProblem(source, secrets, headers, body, now) ?? freshnessProblem(source, json, now);
50
+ }
51
+
52
+ /**
53
+ * Null if the signature over the exact bytes received is genuine (and, for Slack, its signed timestamp fresh), else
54
+ * the 401's message. It needs only the bytes, so it comes before anything parses them.
55
+ */
56
+ export function signatureProblem(source: Exclude<WebhookSource, 'generic'>, secrets: HookSecrets, headers: IncomingHttpHeaders, body: Buffer, now = Date.now()): string | null {
57
+ // GitHub lets you choose the secret, so the key will do; the others make their own.
58
+ const secret = secrets.secret ?? (source === 'github' ? secrets.key : null);
59
+ if (!secret) return `This routine has no ${SOURCE_NAMES[source]} signing secret yet`;
60
+ switch (source) {
61
+ case 'github': {
62
+ const sig = header(headers, 'x-hub-signature-256') ?? '';
63
+ return sig.startsWith('sha256=') && hexEqual(sig.slice(7), hmacHex(secret, body)) ? null : 'Bad or missing X-Hub-Signature-256';
64
+ }
65
+ case 'linear':
66
+ return hexEqual(header(headers, 'linear-signature'), hmacHex(secret, body)) ? null : 'Bad or missing Linear-Signature';
67
+ case 'slack': {
68
+ const ts = header(headers, 'x-slack-request-timestamp') ?? '';
69
+ const sig = header(headers, 'x-slack-signature') ?? '';
70
+ if (!/^\d{1,12}$/.test(ts) || !sig.startsWith('v0=') || !hexEqual(sig.slice(3), hmacHex(secret, `v0:${ts}:`, body))) {
71
+ return 'Bad or missing X-Slack-Signature';
72
+ }
73
+ return Math.abs(now - Number(ts) * 1000) <= 5 * 60_000 ? null : 'Stale X-Slack-Request-Timestamp';
74
+ }
75
+ case 'sentry':
76
+ // Sentry-Hook-Timestamp isn't signed, so checking it would stop no replay; the Request-ID dedupe does.
77
+ return hexEqual(header(headers, 'sentry-hook-signature'), hmacHex(secret, body)) ? null : 'Bad or missing Sentry-Hook-Signature';
78
+ }
79
+ }
80
+
81
+ /** Null if a genuinely signed delivery is fresh by what its signed body says (Linear's webhookTimestamp), else the 401's message. */
82
+ export function freshnessProblem(source: Exclude<WebhookSource, 'generic'>, json: unknown, now = Date.now()): string | null {
83
+ if (source !== 'linear') return null;
84
+ const ts = Number((json as { webhookTimestamp?: unknown } | null)?.webhookTimestamp);
85
+ return Number.isFinite(ts) && Math.abs(now - ts) <= 60_000 ? null : 'Stale or missing webhookTimestamp';
86
+ }
87
+
88
+ /** The most fields a form delivery may have (a Slack slash command has about a dozen). */
89
+ export const FORM_FIELDS_MAX = 1000;
90
+
91
+ /** A delivery that can't be read as its Content-Type says: a 400. */
92
+ export class DeliveryError extends Error {}
93
+
94
+ /**
95
+ * The run's payload from a delivery's bytes, and the body as JSON if it is.
96
+ * A form becomes a JSON object; a form of only `payload=<JSON>` becomes that JSON.
97
+ */
98
+ export function parseDelivery(body: Buffer, contentType: string | undefined): { payload: string | null; json: unknown } {
99
+ const text = body.toString('utf8');
100
+ if (/^\s*application\/x-www-form-urlencoded\b/i.test(contentType ?? '')) {
101
+ // No prototype, so a field named __proto__ is just a field. A repeated field's values gather in one array, in place.
102
+ const form: Record<string, string | string[]> = Object.create(null);
103
+ let fields = 0;
104
+ for (const [k, v] of new URLSearchParams(text)) {
105
+ if (++fields > FORM_FIELDS_MAX) throw new DeliveryError(`The form has too many form fields (at most ${FORM_FIELDS_MAX})`);
106
+ const prev = form[k];
107
+ if (prev === undefined) form[k] = v;
108
+ else if (Array.isArray(prev)) prev.push(v);
109
+ else form[k] = [prev, v];
110
+ }
111
+ const keys = Object.keys(form);
112
+ if (!keys.length) return { payload: null, json: undefined };
113
+ if (keys.length === 1 && typeof form.payload === 'string') {
114
+ try {
115
+ const inner: unknown = JSON.parse(form.payload);
116
+ if (inner && typeof inner === 'object') return { payload: JSON.stringify(inner), json: inner };
117
+ } catch {}
118
+ }
119
+ return { payload: JSON.stringify(form), json: form };
120
+ }
121
+ if (!text.trim()) return { payload: null, json: undefined };
122
+ let json: unknown;
123
+ try {
124
+ json = JSON.parse(text);
125
+ } catch {}
126
+ return { payload: text, json };
127
+ }
128
+
129
+ /** The answer to a sender checking the URL (Slack's url_verification and ssl_check, GitHub's ping), or null for a real delivery. */
130
+ export function handshake(source: WebhookSource, headers: IncomingHttpHeaders, json: unknown): Record<string, unknown> | null {
131
+ const body = json as Record<string, unknown> | null | undefined;
132
+ if (source === 'slack') {
133
+ if (body?.type === 'url_verification' && typeof body.challenge === 'string') return { challenge: body.challenge };
134
+ if (body?.ssl_check === '1') return { ok: true, skipped: true, reason: 'Slack SSL check' };
135
+ }
136
+ if (source === 'github' && header(headers, 'x-github-event') === 'ping') return { ok: true, skipped: true, reason: 'GitHub ping' };
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * The sender's own delivery id, or a generic caller's Idempotency-Key. Never
142
+ * Idempotency-Key for a signed source: no signature covers it, so it could make
143
+ * a captured delivery count as new.
144
+ */
145
+ export function deliveryId(source: WebhookSource, headers: IncomingHttpHeaders, json: unknown): string | null {
146
+ const id =
147
+ source === 'slack'
148
+ ? (json as { event_id?: unknown } | null | undefined)?.event_id
149
+ : header(headers, { generic: 'idempotency-key', github: 'x-github-delivery', linear: 'linear-delivery', sentry: 'request-id' }[source]);
150
+ return typeof id === 'string' && id.trim() && id.length <= 200 ? id.trim() : null;
151
+ }
152
+
153
+ /** The ids a delivery counts under. A signed one's body hash too, since its delivery id header isn't signed. */
154
+ export function deliveryKeys(source: WebhookSource, id: string | null, bytes: Buffer): string[] {
155
+ const keys = id ? [id] : [];
156
+ if (source !== 'generic') keys.push(`sha256:${createHash('sha256').update(bytes).digest('hex')}`);
157
+ return keys;
158
+ }
159
+
160
+ /** The headers a run keeps and the Boton sees: none that carries a secret or a signature. */
161
+ const SHOWN_HEADERS = [
162
+ 'Content-Type',
163
+ 'User-Agent',
164
+ 'Idempotency-Key',
165
+ 'X-GitHub-Event',
166
+ 'X-GitHub-Delivery',
167
+ 'X-GitHub-Hook-ID',
168
+ 'X-GitHub-Hook-Installation-Target-Type',
169
+ 'X-GitHub-Hook-Installation-Target-ID',
170
+ 'Linear-Event',
171
+ 'Linear-Delivery',
172
+ 'Sentry-Hook-Resource',
173
+ 'Request-ID',
174
+ ] as const;
175
+
176
+ export function shownHeaders(headers: IncomingHttpHeaders): Record<string, string> | null {
177
+ const out: Record<string, string> = {};
178
+ for (const name of SHOWN_HEADERS) {
179
+ const v = headers[name.toLowerCase()];
180
+ const value = (Array.isArray(v) ? v.join(', ') : (v ?? '')).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
181
+ if (value) out[name] = value.slice(0, 300);
182
+ }
183
+ return Object.keys(out).length ? out : null;
184
+ }
185
+
186
+ export function matchesFilter(filter: WebhookFilter, headers: IncomingHttpHeaders, json: unknown): boolean {
187
+ let value: unknown;
188
+ if (filter.kind === 'header') {
189
+ const v = headers[filter.name.toLowerCase()];
190
+ value = Array.isArray(v) ? v.join(', ') : v;
191
+ } else {
192
+ value = json;
193
+ for (const key of filter.name.split('.')) {
194
+ value = value && typeof value === 'object' && Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : undefined;
195
+ }
196
+ }
197
+ if (typeof value === 'number' || typeof value === 'boolean') value = String(value);
198
+ return typeof value === 'string' && filter.values.includes(value);
199
+ }
200
+
201
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
202
+
203
+ /** A filter as given in CreateRoutineInput / UpdateRoutineInput, checked and tidied. Throws with the reason if it is not one. */
204
+ export function parseFilter(v: unknown): WebhookFilter | null {
205
+ if (v === null) return null;
206
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('webhookFilter must be an object ({ kind, name, values }) or null');
207
+ const { kind, name, values } = v as Record<string, unknown>;
208
+ if (kind !== 'header' && kind !== 'field') throw new Error('webhookFilter.kind must be "header" or "field"');
209
+ const n = typeof name === 'string' ? name.trim() : '';
210
+ if (!n || n.length > 200) throw new Error('webhookFilter.name must be a header name or a JSON field path such as action or pull_request.base.ref');
211
+ if (kind === 'header' && !HEADER_NAME.test(n)) throw new Error(`webhookFilter.name "${n}" is not a header name`);
212
+ if (kind === 'field' && n.split('.').some((k) => !k)) throw new Error(`webhookFilter.name "${n}" is not a field path`);
213
+ const list = Array.isArray(values) ? values.map((x) => (typeof x === 'string' ? x.trim() : '')) : [];
214
+ if (!list.length || list.length > 50 || list.some((x) => !x || x.length > 200)) throw new Error('webhookFilter.values must be 1 to 50 values, each 1 to 200 characters');
215
+ return { kind, name: n, values: [...new Set(list)] };
216
+ }