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,497 @@
1
+ import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
2
+ import type { IncomingMessage } from 'node:http';
3
+ import type { DatabaseSync } from 'node:sqlite';
4
+ import type { AuthStatus, Device, PairInput, PairingRequest, UrlKeys } from '../../shared/types.ts';
5
+ import { log } from './log.ts';
6
+ import { HttpError } from './store.ts';
7
+ import { approveCommand, pairCommand, SETTING_CLAIM, SETTING_SETUP } from './setup.ts';
8
+ import { isLoopback, plainAddress, type Tailnet } from './tailnet.ts';
9
+ import { newId, nowIso, type Row } from './util.ts';
10
+
11
+ /*
12
+ * Device tokens travel only in `Authorization: Bearer`, never a cookie: a
13
+ * browser sends a host's cookies to every port on it, so any other web server
14
+ * on this machine (say, one a Boton started) would get them. Loopback is not
15
+ * trusted: Botons run on this machine too. Only sha256 hashes are stored.
16
+ */
17
+
18
+ /*
19
+ * Pairing. A new device asks (POST /api/auth/pair-requests) and gets two things: a secret it keeps and a
20
+ * short code it shows. The owner approves the code on the server's machine (`npm run pair -- --approve`)
21
+ * or on a paired device (Settings → Devices), and only then does the secret turn into a device token. The
22
+ * server cannot tell who sends a request: a Boton on this machine can leave out its token and look like
23
+ * any browser, from the same loopback address, with whatever headers it likes. So nothing here trusts the
24
+ * caller; what pairs a device is the owner's approval of the code read off that device's own screen. A
25
+ * code that leaks (pasted into a chat, say) is useless without the secret, which never leaves the device
26
+ * that asked. The one approval-free path is the owner's own Tailscale device, never loopback or this host.
27
+ *
28
+ * Codes are Crockford's base32, 8 characters (40 bits): the owner types them, and nobody can redeem a
29
+ * guessed one. Requests last 15 minutes. Admission is bounded globally (per-address limits cannot
30
+ * distinguish a Boton on loopback from the owner). At most MAX_PENDING_REQUESTS wait
31
+ * at once; new requests are refused while the queue is full.
32
+ */
33
+ const CODE_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
34
+ const CODE_LENGTH = 8;
35
+ const PAIRING_REQUEST_TTL_MS = 15 * 60_000;
36
+ const MAX_PENDING_REQUESTS = 50;
37
+ /** How often a device's lastUsedAt is written, at most. */
38
+ const LAST_USED_EVERY_MS = 60_000;
39
+
40
+ export type Principal =
41
+ /** deviceId is null only with BOTONS_AUTH=off and no token. */
42
+ | { kind: 'device'; deviceId: string | null }
43
+ /** `sandboxed`: a webhook run with Workspace access; reaches only routes marked `sandboxed`. */
44
+ | { kind: 'boton'; botId: string; tokenId: string; sandboxed: boolean };
45
+
46
+ /** Who may call a route besides paired devices. */
47
+ export interface RouteAccess {
48
+ public?: boolean;
49
+ /** Boton tokens too, for the Boton this returns (null: not found, the handler 404s); another Boton's is 403. */
50
+ boton?: (params: Record<string, string>) => string | null;
51
+ /** A GET a browser makes by URL (no header): a device's URL key for this purpose (`?key=`) works too. */
52
+ urlKey?: UrlKeyPurpose;
53
+ /** Any Boton's token; the handler acts as the calling Boton, never the one in the path. */
54
+ anyBoton?: boolean;
55
+ /** A sandboxed turn's token too. Such a turn must not change what the Boton's later turns do or know. */
56
+ sandboxed?: boolean;
57
+ }
58
+
59
+ const SANDBOXED_REFUSAL =
60
+ 'This run was started by a webhook (or tests one) and has Workspace access, so it can only read your routines, memories, skills and team, send notifications, share files and ask your owner for approval: it cannot create, change, run or delete routines, add or forget memories, save or change skills, or ask other Botons. Ask your owner with `botons approval request` (if they allow it, the follow-up turn has your usual access), or say in your reply what you wanted to do, so your owner can do it.';
61
+
62
+ const BOTON_PAIRING = "A Boton cannot pair a device. Only your owner pairs devices, by approving the code a new device shows, on the server's machine or a paired device.";
63
+
64
+ function hashSecret(secret: string): string {
65
+ return createHash('sha256').update(secret).digest('hex');
66
+ }
67
+
68
+ function newToken(prefix: string): string {
69
+ return `${prefix}_${randomBytes(32).toString('base64url')}`;
70
+ }
71
+
72
+ function toDevice(r: Row, currentId: string | null): Device {
73
+ return { id: r.id, label: r.label, createdAt: r.created_at, lastUsedAt: r.last_used_at ?? null, current: r.id === currentId };
74
+ }
75
+
76
+ function cleanLabel(label: unknown): string {
77
+ if (typeof label !== 'string') return '';
78
+ return label.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 80);
79
+ }
80
+
81
+ // ------------------------------------------------- database (also used by `npm run pair`)
82
+
83
+ export function createDevice(db: DatabaseSync, label: string): { device: Device; token: string } {
84
+ const id = newId('dev');
85
+ const token = newToken('mdt');
86
+ const now = nowIso();
87
+ db.prepare("insert into auth_tokens (id, hash, kind, bot_id, label, created_at, last_used_at) values (?, ?, 'device', null, ?, ?, ?)").run(
88
+ id,
89
+ hashSecret(token),
90
+ cleanLabel(label) || 'Device',
91
+ now,
92
+ now,
93
+ );
94
+ const device = toDevice(db.prepare('select * from auth_tokens where id = ?').get(id)!, id);
95
+ return { device, token };
96
+ }
97
+
98
+ export function listDevices(db: DatabaseSync, currentId: string | null = null): Device[] {
99
+ return db
100
+ .prepare("select * from auth_tokens where kind = 'device' order by created_at, id")
101
+ .all()
102
+ .map((r) => toDevice(r, currentId));
103
+ }
104
+
105
+ export function deleteDevice(db: DatabaseSync, id: string): boolean {
106
+ return Number(db.prepare("delete from auth_tokens where id = ? and kind = 'device'").run(id).changes) > 0;
107
+ }
108
+
109
+ export function deleteAllDevices(db: DatabaseSync): number {
110
+ return Number(db.prepare("delete from auth_tokens where kind = 'device'").run().changes);
111
+ }
112
+
113
+ /** Case, spaces and dashes don't matter, and O, I and L read as 0, 1 and 1. */
114
+ export function normalizeCode(input: string): string {
115
+ return input.toUpperCase().replace(/[\s-]+/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
116
+ }
117
+
118
+ function purgeRequests(db: DatabaseSync): void {
119
+ db.prepare('delete from pairing_requests where expires_at <= ?').run(nowIso());
120
+ }
121
+
122
+ /** A device asks to pair. Only the asker gets the secret; the code is for the owner to approve. */
123
+ export function createPairingRequest(db: DatabaseSync, label = '', ttlMs = PAIRING_REQUEST_TTL_MS): Omit<PairingRequest, 'approveCommand'> {
124
+ purgeRequests(db);
125
+ // Never evict an accepted request: unauthenticated callers must not invalidate
126
+ // the code an owner is already approving. Expiry frees admission capacity.
127
+ if (Number(db.prepare('select count(*) as n from pairing_requests').get()!.n) >= MAX_PENDING_REQUESTS) {
128
+ throw new HttpError(429, 'Pairing requests are full. Try again after pending requests expire (15 minutes).');
129
+ }
130
+ const secret = newToken('mpr');
131
+ const now = new Date();
132
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
133
+ for (let tries = 0; ; tries++) {
134
+ let raw = '';
135
+ for (let i = 0; i < CODE_LENGTH; i++) raw += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
136
+ const added = db
137
+ .prepare('insert into pairing_requests (secret_hash, code_hash, label, created_at, expires_at) values (?, ?, ?, ?, ?) on conflict(code_hash) do nothing')
138
+ .run(hashSecret(secret), hashSecret(raw), cleanLabel(label), now.toISOString(), expiresAt);
139
+ if (Number(added.changes) > 0) return { secret, code: raw.match(/.{4}/g)!.join('-'), expiresAt };
140
+ if (tries > 5) throw new Error('could not pick a unique pairing code');
141
+ }
142
+ }
143
+
144
+ export interface ApprovedRequest {
145
+ /** The label the device gave, or the one the owner set while approving. */
146
+ label: string;
147
+ createdAt: string;
148
+ }
149
+
150
+ /**
151
+ * The owner approves the request whose device shows `code`; `label` names the device (it beats the device's
152
+ * own guess). Null: no request waiting with that code (wrong, expired, or approved already). Callers must be
153
+ * the owner: the server's machine (`npm run pair`) or a paired device.
154
+ */
155
+ export function approvePairingRequest(db: DatabaseSync, code: string, label = ''): ApprovedRequest | null {
156
+ purgeRequests(db);
157
+ const named = cleanLabel(label);
158
+ const row = db
159
+ .prepare('update pairing_requests set approved_at = ?, approved_label = ? where code_hash = ? and approved_at is null and expires_at > ? returning label, created_at')
160
+ .get(nowIso(), named || null, hashSecret(normalizeCode(code)), nowIso()) as Row | undefined;
161
+ return row ? { label: named || String(row.label) || 'Unnamed device', createdAt: String(row.created_at) } : null;
162
+ }
163
+
164
+ /** For `npm run pair`: what is waiting, without codes or secrets. */
165
+ export function pendingPairingRequests(db: DatabaseSync): { label: string; createdAt: string; expiresAt: string; approved: boolean }[] {
166
+ purgeRequests(db);
167
+ return db
168
+ .prepare('select label, created_at, expires_at, approved_at from pairing_requests order by created_at, rowid')
169
+ .all()
170
+ .map((r) => ({ label: String(r.label), createdAt: String(r.created_at), expiresAt: String(r.expires_at), approved: r.approved_at != null }));
171
+ }
172
+
173
+ type UrlKeyPurpose = keyof UrlKeys;
174
+ const URL_KEY_PURPOSES: UrlKeyPurpose[] = ['events', 'files'];
175
+
176
+ /** The device id and an HMAC of the purpose keyed with its token's hash: revoking the device ends it. */
177
+ function urlKeyOf(deviceId: string, tokenHash: string, purpose: UrlKeyPurpose): string {
178
+ return `${deviceId}.${createHmac('sha256', tokenHash).update(`botons-url-key:${purpose}`).digest('base64url')}`;
179
+ }
180
+
181
+ /** How long a preview page's key lasts: long enough to look at it, short if a page leaks its own URL. */
182
+ export const PREVIEW_KEY_TTL_MS = 60 * 60_000;
183
+
184
+ /** `<device id>.<expiry, base 36 ms>.<HMAC of the Boton, path and expiry keyed with its token's hash>`. */
185
+ function previewKeyOf(deviceId: string, tokenHash: string, botId: string, path: string, expiresAt: number): string {
186
+ const exp = expiresAt.toString(36);
187
+ const mac = createHmac('sha256', tokenHash).update(`botons-preview\0${botId}\0${path}\0${exp}`).digest('base64url');
188
+ return `${deviceId}.${exp}.${mac}`;
189
+ }
190
+
191
+ // ------------------------------------------------------------------- server
192
+
193
+ interface AuthOptions {
194
+ /** False with BOTONS_AUTH=off: a request without a valid token counts as a paired device. */
195
+ enabled: boolean;
196
+ /** Null: the Tailscale login this machine is signed in as. */
197
+ ownerLogin: string | null;
198
+ /** Null: no Tailscale lookups, so pairing needs a code. */
199
+ tailnet: Tailnet | null;
200
+ hostname: string;
201
+ container?: boolean;
202
+ }
203
+
204
+ export interface Paired {
205
+ device: Device;
206
+ token: string;
207
+ method: 'approval' | 'tailscale';
208
+ /** The first device this server ever paired: it claimed the server for its owner. */
209
+ claim: boolean;
210
+ }
211
+
212
+ export class Auth {
213
+ #db: DatabaseSync;
214
+ #opts: AuthOptions;
215
+ /** Open SSE streams per device, so revoking a device ends them. */
216
+ #streams = new Map<string, Set<() => void>>();
217
+ /** Sandboxed turns' token ids. In memory only: turn tokens never outlive the process. */
218
+ #sandboxed = new Set<string>();
219
+
220
+ constructor(db: DatabaseSync, opts: AuthOptions) {
221
+ this.#db = db;
222
+ this.#opts = opts;
223
+ }
224
+
225
+ /** False with BOTONS_AUTH=off. */
226
+ get required(): boolean {
227
+ return this.#opts.enabled;
228
+ }
229
+
230
+ #bearer(req: IncomingMessage): string | null {
231
+ return /^Bearer\s+(\S+)\s*$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;
232
+ }
233
+
234
+ /** Null: no token, or one that is unknown, revoked or from a finished turn. */
235
+ authenticate(req: IncomingMessage, opts: { url?: URL; urlKey?: UrlKeyPurpose } = {}): Principal | null {
236
+ const token = this.#bearer(req);
237
+ const row = token ? this.#lookup(token) : null;
238
+ if (row?.kind === 'boton') {
239
+ const tokenId = String(row.id);
240
+ return { kind: 'boton', botId: String(row.bot_id), tokenId, sandboxed: this.#sandboxed.has(tokenId) };
241
+ }
242
+ if (row) return { kind: 'device', deviceId: String(row.id) };
243
+ if (!token && opts.urlKey && opts.url && (req.method === 'GET' || req.method === 'HEAD')) {
244
+ const given = opts.url.searchParams.get('key');
245
+ const deviceId = given ? this.#checkUrlKey(given, opts.urlKey) : null;
246
+ if (deviceId) return { kind: 'device', deviceId };
247
+ }
248
+ return this.#opts.enabled ? null : { kind: 'device', deviceId: null };
249
+ }
250
+
251
+ #lookup(token: string): Row | null {
252
+ const row = this.#db.prepare('select id, kind, bot_id, last_used_at from auth_tokens where hash = ?').get(hashSecret(token)) as Row | undefined;
253
+ if (!row) return null;
254
+ this.#touch(row);
255
+ return row;
256
+ }
257
+
258
+ #touch(row: Row): void {
259
+ if (row.kind === 'device' && (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > LAST_USED_EVERY_MS)) {
260
+ this.#db.prepare('update auth_tokens set last_used_at = ? where id = ?').run(nowIso(), row.id);
261
+ }
262
+ }
263
+
264
+ #checkUrlKey(given: string, purpose: UrlKeyPurpose): string | null {
265
+ const dot = given.lastIndexOf('.');
266
+ if (dot <= 0) return null;
267
+ const row = this.#db.prepare("select id, kind, hash, last_used_at from auth_tokens where id = ? and kind = 'device'").get(given.slice(0, dot)) as Row | undefined;
268
+ if (!row) return null;
269
+ const want = Buffer.from(urlKeyOf(String(row.id), String(row.hash), purpose));
270
+ const got = Buffer.from(given);
271
+ if (want.length !== got.length || !timingSafeEqual(want, got)) return null;
272
+ this.#touch(row);
273
+ return String(row.id);
274
+ }
275
+
276
+ #urlKeys(deviceId: string | null): UrlKeys | null {
277
+ if (!deviceId) return null;
278
+ const row = this.#db.prepare("select id, hash from auth_tokens where id = ? and kind = 'device'").get(deviceId) as Row | undefined;
279
+ if (!row) return null;
280
+ return Object.fromEntries(URL_KEY_PURPOSES.map((p) => [p, urlKeyOf(String(row.id), String(row.hash), p)])) as unknown as UrlKeys;
281
+ }
282
+
283
+ /**
284
+ * A key for one file's preview page (`GET /api/bots/:id/files/preview`), for a paired device. The page
285
+ * runs a Boton's script, which can read its own URL, so this key opens only that file, and only until
286
+ * `expiresAt`; revoking the device ends it sooner. Null when there is no device (BOTONS_AUTH=off).
287
+ */
288
+ previewKey(deviceId: string | null, botId: string, path: string, now = Date.now()): { key: string; expiresAt: number } | null {
289
+ if (!deviceId) return null;
290
+ const row = this.#db.prepare("select id, hash from auth_tokens where id = ? and kind = 'device'").get(deviceId) as Row | undefined;
291
+ if (!row) return null;
292
+ const expiresAt = now + PREVIEW_KEY_TTL_MS;
293
+ return { key: previewKeyOf(String(row.id), String(row.hash), botId, path, expiresAt), expiresAt };
294
+ }
295
+
296
+ /** The `key` of a preview page opens exactly this Boton's file at this path (as signed, before resolving), unexpired. */
297
+ previewAllowed(given: string | null, botId: string, path: string, now = Date.now()): boolean {
298
+ if (!given) return !this.#opts.enabled;
299
+ const m = /^(.+)\.([0-9a-z]{1,12})\.[A-Za-z0-9_-]+$/.exec(given);
300
+ if (!m) return false;
301
+ const expiresAt = Number.parseInt(m[2], 36);
302
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now || expiresAt > now + PREVIEW_KEY_TTL_MS) return false;
303
+ const row = this.#db.prepare("select id, hash, last_used_at from auth_tokens where id = ? and kind = 'device'").get(m[1]) as Row | undefined;
304
+ if (!row) return false;
305
+ const want = Buffer.from(previewKeyOf(String(row.id), String(row.hash), botId, path, expiresAt));
306
+ const got = Buffer.from(given);
307
+ if (want.length !== got.length || !timingSafeEqual(want, got)) return false;
308
+ this.#touch(row);
309
+ return true;
310
+ }
311
+
312
+ /** Throws 401 (nobody) or 403 (a Boton out of bounds). */
313
+ authorize(principal: Principal | null, access: RouteAccess, params: Record<string, string>, req: IncomingMessage): void {
314
+ if (access.public) return;
315
+ if (!principal) throw new HttpError(401, this.#unauthorized(req, access));
316
+ if (principal.kind === 'device') return;
317
+ if (!access.boton && !access.anyBoton) throw new HttpError(403, 'A Boton token cannot use this endpoint');
318
+ const owner = access.boton ? access.boton(params) : null;
319
+ if (owner !== null && owner !== principal.botId) throw new HttpError(403, 'That belongs to another Boton');
320
+ if (principal.sandboxed && !access.sandboxed) throw new HttpError(403, SANDBOXED_REFUSAL);
321
+ }
322
+
323
+ #unauthorized(req: IncomingMessage, access: RouteAccess): string {
324
+ const token = this.#bearer(req);
325
+ if (token?.startsWith('mmt_')) {
326
+ return "This Boton token is no longer valid: each one lasts a single turn. Run `botons` (the one on your PATH, which has this turn's token) during your current turn; don't save or reuse a token.";
327
+ }
328
+ const device = 'This device is not paired with Botons. Open the web app to pair it (see "Pairing devices" in the README).';
329
+ if (!token && (access.boton || access.anyBoton)) {
330
+ return `${device} A Boton: run \`botons\` from your PATH, which sends this turn's token.`;
331
+ }
332
+ return device;
333
+ }
334
+
335
+ /** A method so tests can stand in a tailnet address. */
336
+ remoteAddress(req: IncomingMessage): string {
337
+ return plainAddress(req.socket.remoteAddress ?? '');
338
+ }
339
+
340
+ /** The owner's Tailscale device this request comes from. Never loopback or this machine itself: Botons run here. */
341
+ async ownerPeer(req: IncomingMessage): Promise<{ login: string; node: string } | null> {
342
+ const tailnet = this.#opts.tailnet;
343
+ const ip = this.remoteAddress(req);
344
+ if (!tailnet || !ip || isLoopback(ip)) return null;
345
+ const self = await tailnet.self();
346
+ if (!self || self.ips.some((a) => plainAddress(a) === ip)) return null;
347
+ const owner = this.#opts.ownerLogin ?? self.login;
348
+ if (!owner) return null;
349
+ const peer = await tailnet.whois(ip);
350
+ if (!peer || peer.tagged || peer.nodeId === self.nodeId) return null;
351
+ return peer.login.toLowerCase() === owner.toLowerCase() ? { login: peer.login, node: peer.node } : null;
352
+ }
353
+
354
+ async status(req: IncomingMessage): Promise<AuthStatus> {
355
+ const p = this.authenticate(req);
356
+ return {
357
+ required: this.#opts.enabled,
358
+ caller: p ? p.kind : 'none',
359
+ deviceId: p?.kind === 'device' ? p.deviceId : null,
360
+ botId: p?.kind === 'boton' ? p.botId : null,
361
+ urlKeys: p?.kind === 'device' ? this.#urlKeys(p.deviceId) : null,
362
+ ownerPairing: p ? null : await this.ownerPeer(req),
363
+ hostname: this.#opts.hostname,
364
+ claimed: this.claimed(),
365
+ pairCommand: pairCommand({ container: !!this.#opts.container }),
366
+ };
367
+ }
368
+
369
+ /** A device has paired here at some point (a claim recorded), or one is paired now. */
370
+ claimed(): boolean {
371
+ return !!this.#db.prepare('select 1 from settings where key = ?').get(SETTING_CLAIM) || listDevices(this.#db).length > 0;
372
+ }
373
+
374
+ /** The first pairing on a server records its claim: when, and which device. Never replaced. */
375
+ #paired(made: { device: Device; token: string }, method: Paired['method']): Paired {
376
+ const first = this.#db.prepare("select count(*) as n from auth_tokens where kind = 'device'").get()!.n === 1;
377
+ const claim =
378
+ first &&
379
+ Number(
380
+ this.#db
381
+ .prepare('insert into settings(key, value) values (?, ?) on conflict(key) do nothing')
382
+ .run(SETTING_CLAIM, JSON.stringify({ at: made.device.createdAt, deviceId: made.device.id, label: made.device.label })).changes,
383
+ ) > 0;
384
+ // A new server walks its owner through setup until they finish or skip it, even once it has a Boton.
385
+ // One that already has Botons (every device revoked, then a new one paired) is set up already.
386
+ if (claim && !this.#db.prepare('select 1 from bots limit 1').get()) this.#db.prepare('insert into settings(key, value) values (?, ?) on conflict(key) do nothing').run(SETTING_SETUP, JSON.stringify({ completedAt: null }));
387
+ return { ...made, method, claim };
388
+ }
389
+
390
+ /**
391
+ * A device asks to pair. Anyone may ask, a Boton that leaves out its token included: asking grants nothing
392
+ * until the owner approves the code, which the caller can only show, not approve. Refused under a Boton's
393
+ * token all the same, so a Boton that tries is told plainly.
394
+ */
395
+ requestPairing(req: IncomingMessage, label: unknown): PairingRequest {
396
+ if (this.authenticate(req)?.kind === 'boton') throw new HttpError(403, BOTON_PAIRING);
397
+ const made = createPairingRequest(this.#db, cleanLabel(label));
398
+ return { ...made, approveCommand: approveCommand({ container: !!this.#opts.container }, made.code) };
399
+ }
400
+
401
+ /** The owner (a paired device, the route's default) approves the request whose device shows `code`. */
402
+ approvePairing(code: unknown, label: unknown): ApprovedRequest {
403
+ if (typeof code !== 'string' || !normalizeCode(code)) throw new HttpError(400, 'Enter the code the new device shows');
404
+ const approved = approvePairingRequest(this.#db, code, cleanLabel(label));
405
+ if (!approved) throw new HttpError(404, 'No device is waiting with that code. Check it against the new device\'s screen; a code lasts 15 minutes, so the device may need a new one.');
406
+ return approved;
407
+ }
408
+
409
+ /**
410
+ * With the secret of a request the owner approved, or with none if the request comes from the owner's
411
+ * Tailscale peer. A code alone pairs nothing. Never under a Boton's token.
412
+ */
413
+ async pair(req: IncomingMessage, input: PairInput): Promise<Paired> {
414
+ if (this.authenticate(req)?.kind === 'boton') throw new HttpError(403, BOTON_PAIRING);
415
+ const label = cleanLabel(input.label);
416
+ const secret = typeof input.secret === 'string' ? input.secret.trim() : '';
417
+ if (secret) {
418
+ purgeRequests(this.#db);
419
+ const hash = hashSecret(secret);
420
+ const used = this.#db.prepare('delete from pairing_requests where secret_hash = ? and approved_at is not null returning label, approved_label').get(hash) as Row | undefined;
421
+ if (!used) {
422
+ if (this.#db.prepare('select 1 from pairing_requests where secret_hash = ?').get(hash)) throw new HttpError(409, 'Waiting for the owner to approve this device.');
423
+ throw new HttpError(400, 'This pairing request is unknown or has expired. Start again for a new code.');
424
+ }
425
+ return this.#paired(createDevice(this.#db, String(used.approved_label ?? '') || String(used.label) || label || 'Paired device'), 'approval');
426
+ }
427
+ if (typeof input.code === 'string' && input.code.trim()) {
428
+ throw new HttpError(400, `A code alone doesn't pair a device. Open Botons on the new device: it shows a code, and you approve that code with \`${approveCommand({ container: !!this.#opts.container })}\` on ${this.#opts.hostname} or from a paired device (Settings → Devices).`);
429
+ }
430
+ const owner = await this.ownerPeer(req);
431
+ if (!owner) {
432
+ throw new HttpError(403, `This device needs your approval. Ask to pair (the web app does), then approve its code with \`${approveCommand({ container: !!this.#opts.container })}\` on ${this.#opts.hostname} or from a paired device (Settings → Devices).`);
433
+ }
434
+ return this.#paired(createDevice(this.#db, [owner.node, label].filter(Boolean).join(' · ')), 'tailscale');
435
+ }
436
+
437
+ devices(currentId: string | null): Device[] {
438
+ return listDevices(this.#db, currentId);
439
+ }
440
+
441
+ /** Its token stops working and its open event streams end. False if there is no such device. */
442
+ revokeDevice(id: string): boolean {
443
+ const found = deleteDevice(this.#db, id);
444
+ for (const close of [...(this.#streams.get(id) ?? [])]) close();
445
+ this.#streams.delete(id);
446
+ return found;
447
+ }
448
+
449
+ /** A token for one turn; revoke() when the turn ends. */
450
+ mintBotonToken(botId: string, opts: { sandboxed?: boolean } = {}): { id: string; token: string; revoke: () => void } {
451
+ const id = newId('mtk');
452
+ const token = newToken('mmt');
453
+ this.#db
454
+ .prepare("insert into auth_tokens (id, hash, kind, bot_id, label, created_at) values (?, ?, 'boton', ?, 'turn', ?)")
455
+ .run(id, hashSecret(token), botId, nowIso());
456
+ if (opts.sandboxed) this.#sandboxed.add(id);
457
+ return {
458
+ id,
459
+ token,
460
+ revoke: () => {
461
+ this.#sandboxed.delete(id);
462
+ try {
463
+ this.#db.prepare('delete from auth_tokens where id = ?').run(id);
464
+ } catch (err) {
465
+ // Only when the database is already closed (shutdown); the next start purges it.
466
+ log.warn(`could not revoke a turn token: ${(err as Error).message}`, { botId });
467
+ }
468
+ },
469
+ };
470
+ }
471
+
472
+ /** Turn tokens left by a previous process belong to turns that are gone. */
473
+ purgeBotonTokens(): void {
474
+ this.#db.prepare("delete from auth_tokens where kind = 'boton'").run();
475
+ this.#sandboxed.clear();
476
+ }
477
+
478
+ /** Revoking the principal's device calls `close`. Returns the unregister function. */
479
+ trackStream(principal: Principal | null, close: () => void): () => void {
480
+ if (principal?.kind !== 'device' || !principal.deviceId) return () => {};
481
+ const id = principal.deviceId;
482
+ let set = this.#streams.get(id);
483
+ if (!set) this.#streams.set(id, (set = new Set()));
484
+ set.add(close);
485
+ return () => {
486
+ set.delete(close);
487
+ if (!set.size && this.#streams.get(id) === set) this.#streams.delete(id);
488
+ };
489
+ }
490
+
491
+ /** False once the principal's token is gone (revoked here, by `npm run pair -- --revoke`, or its turn ended). */
492
+ isLive(principal: Principal | null): boolean {
493
+ if (!principal) return !this.#opts.enabled;
494
+ const id = principal.kind === 'device' ? principal.deviceId : principal.tokenId;
495
+ return id === null || !!this.#db.prepare('select 1 from auth_tokens where id = ?').get(id);
496
+ }
497
+ }