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,650 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { isIP } from 'node:net';
3
+ import type { DatabaseSync } from 'node:sqlite';
4
+ import type { OAuthConnection, OAuthConnectionInput, OAuthConnectStart } from '../../shared/types.ts';
5
+ import { allowedHostNames, type Config } from './config.ts';
6
+ import type { EventHub } from './events.ts';
7
+ import { log } from './log.ts';
8
+ import type { Notifier } from './notify.ts';
9
+ import { authorizeUrl, canonicalResource, discover, exchangeCode, OAuthError, pkce, refreshGrant, register, type ServerMetadata, type Tokens } from './oauth.ts';
10
+ import { hostOf, refusedOutbound } from './outbound.ts';
11
+ import { sanitize } from './secrets.ts';
12
+ import { HttpError, type Store } from './store.ts';
13
+ import { newId, nowIso, parseJson, type Row } from './util.ts';
14
+ import { offKey, retirePolicy } from './connectors.ts';
15
+
16
+ /*
17
+ * Remote MCP servers signed in to with OAuth ("oauth:<name>" connectors). Tokens stay in the database and are only
18
+ * read back for the server's own calls and as a turn's Authorization header; the refresh token never leaves here.
19
+ * Each connection has one refresh at a time (turns, the feed and keep-alive share it), and a refresh is saved only
20
+ * if the connection is still the one it started from (same generation and refresh token), in one statement. A refresh
21
+ * is marked as started before it is sent and the mark goes with its outcome, so one whose answer was never saved is
22
+ * never sent again: the connector asks to be signed in again instead.
23
+ */
24
+
25
+ const NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/;
26
+ const MAX_CONNECTIONS = 20;
27
+ const FLOW_TTL_MS = 10 * 60_000;
28
+ const FLOWS_PER_CONNECTION = 5;
29
+ export const KEEPALIVE_MS = 7 * 86_400_000;
30
+ const TRANSIENT_WAIT_MS = 5_000;
31
+ export const CALLBACK_PATH = '/oauth/callback';
32
+
33
+ const sha = (s: string) => createHash('sha256').update(s).digest('hex');
34
+
35
+ export interface ConnectionRecord {
36
+ id: string;
37
+ name: string;
38
+ url: string;
39
+ status: OAuthConnection['status'];
40
+ generation: number;
41
+ resource: string;
42
+ issuer: string;
43
+ metadata: ServerMetadata | null;
44
+ clientId: string;
45
+ accessToken: string | null;
46
+ accessExpiresAt: string | null;
47
+ accessLifetimeMs: number | null;
48
+ refreshToken: string | null;
49
+ feed: boolean;
50
+ unknownJobs: 'nowhere' | 'primary';
51
+ }
52
+
53
+ export class OAuthConnections {
54
+ #db: DatabaseSync;
55
+ #cfg: Config;
56
+ #store: Store;
57
+ #notifier: Notifier;
58
+ /** One refresh at a time per connection: what is running, if anything. */
59
+ #serial = new Map<string, Promise<unknown>>();
60
+ /** Called after a sign-in completes (the feed detection). */
61
+ onConnected: (id: string) => void = () => {};
62
+
63
+ constructor(opts: { db: DatabaseSync; cfg: Config; store: Store; hub: EventHub; notifier: Notifier }) {
64
+ this.#db = opts.db;
65
+ this.#cfg = opts.cfg;
66
+ this.#store = opts.store;
67
+ this.#notifier = opts.notifier;
68
+ }
69
+
70
+ get redirectUri(): string | null {
71
+ return this.#cfg.oauth.publicUrl ? `${this.#cfg.oauth.publicUrl}${CALLBACK_PATH}` : null;
72
+ }
73
+
74
+ #row(id: string): Row | undefined {
75
+ return this.#db.prepare('select * from oauth_connections where id = ?').get(id) as Row | undefined;
76
+ }
77
+
78
+ #toPublic(r: Row): OAuthConnection {
79
+ // Saved already sanitized; sanitized again on the way out, with the tokens the row holds now, so neither a
80
+ // token-shaped run nor a credential too short to look like one (an earlier answer may have echoed it) is shown.
81
+ const clean = (text: string) => sanitize(text, [r.access_token, r.refresh_token]);
82
+ return {
83
+ id: r.id,
84
+ name: r.name,
85
+ url: displayUrl(r.url),
86
+ status: r.status,
87
+ statusReason: r.status_reason ? clean(r.status_reason) : null,
88
+ issuer: r.issuer ? hostOf(r.issuer) : null,
89
+ scope: r.scope || null,
90
+ connectedAt: r.status === 'connected' ? (r.refreshed_at ?? null) : null,
91
+ refreshedAt: r.refreshed_at ?? null,
92
+ refreshError: r.status === 'connected' && r.refresh_error ? clean(r.refresh_error) : null,
93
+ keepConnected: !!r.keep_connected,
94
+ nextKeepAliveAt: r.status === 'connected' && r.keep_connected ? (r.keepalive_at ?? null) : null,
95
+ feed: !!r.feed,
96
+ feedCheckedAt: r.feed_checked_at ?? null,
97
+ feedError: r.feed_error ? clean(r.feed_error) : null,
98
+ unknownJobs: r.unknown_jobs,
99
+ createdAt: r.created_at,
100
+ };
101
+ }
102
+
103
+ record(id: string): ConnectionRecord | null {
104
+ const r = this.#row(id);
105
+ return r ? toRecord(r) : null;
106
+ }
107
+
108
+ byName(name: string): ConnectionRecord | null {
109
+ const r = this.#db.prepare('select * from oauth_connections where name = ?').get(name) as Row | undefined;
110
+ return r ? toRecord(r) : null;
111
+ }
112
+
113
+ records(): ConnectionRecord[] {
114
+ return (this.#db.prepare('select * from oauth_connections order by name').all() as Row[]).map(toRecord);
115
+ }
116
+
117
+ list(): OAuthConnection[] {
118
+ return (this.#db.prepare('select * from oauth_connections order by name').all() as Row[]).map((r) => this.#toPublic(r));
119
+ }
120
+
121
+ get(id: string): OAuthConnection {
122
+ const r = this.#row(id);
123
+ if (!r) throw new HttpError(404, `No OAuth connector with id ${id}`);
124
+ return this.#toPublic(r);
125
+ }
126
+
127
+ emit(id: string): OAuthConnection | null {
128
+ const r = this.#row(id);
129
+ if (!r) return null;
130
+ const connection = this.#toPublic(r);
131
+ this.#store.publish({ type: 'oauth.upserted', connection });
132
+ return connection;
133
+ }
134
+
135
+ /** `quiet`: published by the caller once it is set up (an import's create and settings are one change). */
136
+ create(input: OAuthConnectionInput, opts: { quiet?: boolean } = {}): OAuthConnection {
137
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
138
+ if (!NAME.test(name)) throw new HttpError(400, 'name must be 1-40 lowercase letters, digits, - or _, starting with a letter or digit');
139
+ const url = typeof input.url === 'string' ? input.url.trim() : '';
140
+ let parsed: URL;
141
+ try {
142
+ parsed = new URL(url);
143
+ } catch {
144
+ throw new HttpError(400, "url must be the MCP server's URL, such as https://mcp.example.com/mcp");
145
+ }
146
+ if (parsed.hash) throw new HttpError(400, 'url must not have a #fragment');
147
+ const refused = refusedOutbound(url, this.#cfg.oauth.allow);
148
+ if (refused) throw new HttpError(400, `Botons won't connect to ${hostOf(url)}: ${refused}`);
149
+ if (this.#db.prepare('select 1 from oauth_connections where name = ?').get(name)) throw new HttpError(409, `There is already an OAuth connector named ${name}`);
150
+ if (Number((this.#db.prepare('select count(*) as n from oauth_connections').get() as Row).n) >= MAX_CONNECTIONS) throw new HttpError(409, `At most ${MAX_CONNECTIONS} OAuth connectors`);
151
+ const id = newId('oac');
152
+ const now = nowIso();
153
+ this.#db.prepare("insert into oauth_connections (id, name, url, status, created_at, updated_at) values (?, ?, ?, 'new', ?, ?)").run(id, name, url, now, now);
154
+ log.info('OAuth connector added', { connectionId: id, connector: `oauth:${name}`, host: hostOf(url) });
155
+ return opts.quiet ? this.get(id) : this.emit(id)!;
156
+ }
157
+
158
+ update(id: string, input: OAuthConnectionInput, opts: { quiet?: boolean } = {}): OAuthConnection {
159
+ const r = this.#row(id);
160
+ if (!r) throw new HttpError(404, `No OAuth connector with id ${id}`);
161
+ if (input.name !== undefined && input.name !== r.name) throw new HttpError(400, "An OAuth connector's name can't change; add a new one instead");
162
+ if (input.url !== undefined && input.url !== r.url) throw new HttpError(400, "An OAuth connector's URL can't change; add a new one instead");
163
+ if (input.keepConnected !== undefined) {
164
+ if (typeof input.keepConnected !== 'boolean') throw new HttpError(400, 'keepConnected must be true or false');
165
+ const at = input.keepConnected ? new Date(Date.now() + KEEPALIVE_MS).toISOString() : null;
166
+ this.#db.prepare('update oauth_connections set keep_connected = ?, keepalive_at = coalesce(?, keepalive_at), updated_at = ? where id = ?').run(input.keepConnected ? 1 : 0, r.keepalive_at ? null : at, nowIso(), id);
167
+ }
168
+ if (input.unknownJobs !== undefined) {
169
+ if (input.unknownJobs !== 'nowhere' && input.unknownJobs !== 'primary') throw new HttpError(400, 'unknownJobs must be "nowhere" or "primary"');
170
+ this.#db.prepare('update oauth_connections set unknown_jobs = ?, updated_at = ? where id = ?').run(input.unknownJobs, nowIso(), id);
171
+ }
172
+ return opts.quiet ? this.get(id) : this.emit(id)!;
173
+ }
174
+
175
+ /** Its tokens, flows and work records go with it, and every Boton loses it. */
176
+ delete(id: string): void {
177
+ const r = this.#row(id);
178
+ if (!r) throw new HttpError(404, `No OAuth connector with id ${id}`);
179
+ this.#store.atomically(() => {
180
+ this.#db.prepare('delete from oauth_connections where id = ?').run(id);
181
+ retirePolicy(this.#db, `oauth:${r.name}`);
182
+ this.#db.prepare('delete from settings where key = ?').run(offKey(`oauth:${r.name}`));
183
+ this.#store.dropFromBots('connections', `oauth:${r.name}`);
184
+ });
185
+ this.#store.publish({ type: 'oauth.deleted', id });
186
+ log.info('OAuth connector removed', { connectionId: id, connector: `oauth:${r.name}` });
187
+ }
188
+
189
+ // ------------------------------------------------------------- sign-in
190
+
191
+ /** Where the callback may send the browser back to: an origin the app is served at, else its public URL. */
192
+ #returnTo(raw: unknown): string {
193
+ const fallback = this.#cfg.publicUrl;
194
+ if (typeof raw !== 'string' || raw.length > 300) return fallback;
195
+ let u: URL;
196
+ try {
197
+ u = new URL(raw);
198
+ } catch {
199
+ return fallback;
200
+ }
201
+ if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.username || u.password) return fallback;
202
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase();
203
+ const ok = allowedHostNames(this.#cfg).has(host) || (isIP(host) > 0 && (host.startsWith('127.') || host === '::1' || this.#cfg.urls.some((x) => new URL(x).hostname.replace(/^\[|\]$/g, '') === host)));
204
+ return ok ? u.origin : fallback;
205
+ }
206
+
207
+ /** A paired device starts a sign-in: discovery, a registered client (reused when it fits), a fresh state and PKCE verifier. */
208
+ async startConnect(id: string, deviceId: string | null, returnTo: unknown): Promise<OAuthConnectStart> {
209
+ const r = this.#row(id);
210
+ if (!r) throw new HttpError(404, `No OAuth connector with id ${id}`);
211
+ if (!deviceId) throw new HttpError(403, 'Connect from a paired device');
212
+ const redirectUri = this.redirectUri;
213
+ if (!redirectUri) {
214
+ throw new HttpError(
215
+ 409,
216
+ 'Set BOTONS_OAUTH_PUBLIC_URL to an HTTPS address that reaches this server\'s /oauth/callback (see "OAuth connectors" in the README), then restart Botons.',
217
+ );
218
+ }
219
+ const allow = this.#cfg.oauth.allow;
220
+ let found;
221
+ try {
222
+ found = await discover(r.url, allow);
223
+ } catch (err) {
224
+ throw new HttpError(err instanceof OAuthError && err.kind === 'transient' ? 502 : 400, `Can't sign in to ${r.name}: ${sanitize((err as Error).message)}`);
225
+ }
226
+ const clientFor = `${found.issuer} ${redirectUri}`;
227
+ let clientId = r.client_for === clientFor && r.client_id ? String(r.client_id) : '';
228
+ if (!clientId) {
229
+ const pending = this.#db.prepare('select client_id from oauth_flows where connection_id = ? and issuer = ? and redirect_uri = ? order by created_at desc limit 1').get(id, found.issuer, redirectUri) as Row | undefined;
230
+ clientId = pending?.client_id ?? '';
231
+ }
232
+ if (!clientId) {
233
+ try {
234
+ clientId = await register(found.metadata, redirectUri, found.scope, allow);
235
+ } catch (err) {
236
+ throw new HttpError(err instanceof OAuthError && err.kind === 'transient' ? 502 : 400, `Can't sign in to ${r.name}: ${sanitize((err as Error).message)}`);
237
+ }
238
+ log.info('registered Botons with an authorization server', { connectionId: id, issuer: hostOf(found.issuer) });
239
+ }
240
+ const state = randomBytes(32).toString('base64url');
241
+ const { verifier, challenge } = pkce();
242
+ const now = new Date();
243
+ const expiresAt = new Date(now.getTime() + FLOW_TTL_MS).toISOString();
244
+ this.#store.atomically(() => {
245
+ this.#db.prepare('delete from oauth_flows where expires_at <= ?').run(now.toISOString());
246
+ const old = this.#db.prepare('select state_hash from oauth_flows where connection_id = ? order by created_at desc').all(id) as Row[];
247
+ for (const f of old.slice(FLOWS_PER_CONNECTION - 1)) this.#db.prepare('delete from oauth_flows where state_hash = ?').run(f.state_hash);
248
+ this.#db
249
+ .prepare(
250
+ 'insert into oauth_flows (state_hash, connection_id, device_id, generation, issuer, resource, metadata, scope, client_id, redirect_uri, code_verifier, return_to, created_at, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
251
+ )
252
+ .run(sha(state), id, deviceId, r.generation, found.issuer, found.resource, JSON.stringify(found.metadata), found.scope, clientId, redirectUri, verifier, this.#returnTo(returnTo), now.toISOString(), expiresAt);
253
+ });
254
+ log.info('OAuth sign-in started', { connectionId: id, deviceId, issuer: hostOf(found.issuer) });
255
+ return { authorizeUrl: authorizeUrl(found.metadata, { clientId, redirectUri, scope: found.scope, state, challenge, resource: found.resource }), expiresAt };
256
+ }
257
+
258
+ /**
259
+ * GET /oauth/callback, with no device token (the browser comes back from the authorization server). The state is
260
+ * claimed once, atomically; then everything it was bound to must still hold before the code is redeemed.
261
+ */
262
+ async callback(query: URLSearchParams): Promise<{ ok: boolean; message: string; returnTo: string; name: string | null }> {
263
+ const fallback = this.#cfg.publicUrl;
264
+ const state = query.get('state') ?? '';
265
+ const fail = (message: string, returnTo = fallback, name: string | null = null) => ({ ok: false, message, returnTo, name });
266
+ if (!state || state.length > 200) return fail('This sign-in link is not valid. Start again from Settings → Connectors.');
267
+ const flow = this.#db.prepare('delete from oauth_flows where state_hash = ? returning *').get(sha(state)) as Row | undefined;
268
+ if (!flow) return fail('This sign-in was already used, or it is not one Botons started. Start again from Settings → Connectors.');
269
+ const returnTo = String(flow.return_to);
270
+ const conn = this.#row(flow.connection_id);
271
+ const name = conn ? String(conn.name) : null;
272
+ const fields = { connectionId: flow.connection_id, deviceId: flow.device_id };
273
+ if (flow.expires_at <= nowIso()) return fail('This sign-in took too long and expired. Start again from Settings → Connectors.', returnTo, name);
274
+ if (!conn) return fail('That connector was deleted.', returnTo);
275
+ if (!this.#db.prepare("select 1 from auth_tokens where id = ? and kind = 'device'").get(flow.device_id)) {
276
+ log.warn('OAuth callback refused: the device that started it is no longer paired', fields);
277
+ return fail('The device that started this sign-in is no longer paired.', returnTo, name);
278
+ }
279
+ if (Number(conn.generation) !== Number(flow.generation)) return fail(`${name} was signed in again since this sign-in started. Nothing changed.`, returnTo, name);
280
+ const metadata = parseJson<ServerMetadata | null>(flow.metadata, null);
281
+ if (!metadata || metadata.issuer !== flow.issuer) return fail('This sign-in is not valid.', returnTo, name);
282
+ const iss = query.get('iss');
283
+ if (iss !== null ? iss !== flow.issuer : metadata.authorization_response_iss_parameter_supported === true) {
284
+ log.warn('OAuth callback refused: the authorization response came from another issuer', fields);
285
+ return fail('The answer did not come from the authorization server this sign-in was for, so it was not used.', returnTo, name);
286
+ }
287
+ if (canonicalResource(flow.resource) !== canonicalResource(conn.url)) return fail('This sign-in is for another resource.', returnTo, name);
288
+ const error = query.get('error');
289
+ if (error) {
290
+ const code = /^[\w.-]{1,60}$/.test(error) ? error : 'error';
291
+ log.info('OAuth sign-in declined at the authorization server', { ...fields, error: code });
292
+ return fail(code === 'access_denied' ? `Signing in to ${name} was declined.` : `Signing in to ${name} failed (${code}).`, returnTo, name);
293
+ }
294
+ const code = query.get('code') ?? '';
295
+ if (!code || code.length > 2000) return fail('The authorization server sent no code.', returnTo, name);
296
+ let tokens: Tokens;
297
+ try {
298
+ tokens = await exchangeCode(metadata, { code, verifier: flow.code_verifier, clientId: flow.client_id, redirectUri: flow.redirect_uri, resource: flow.resource }, this.#cfg.oauth.allow);
299
+ } catch (err) {
300
+ const why = sanitize((err as Error).message, [code, flow.code_verifier]);
301
+ log.warn('OAuth code exchange failed', { ...fields, error: why });
302
+ return fail(`Signing in to ${name} failed: ${why}`, returnTo, name);
303
+ }
304
+ if (!tokens.refreshToken) log.info('the authorization server issued no refresh token: the connector needs signing in again when the access token expires', fields);
305
+ const now = Date.now();
306
+ // Checked again with the save: the device may have been unpaired while the code was being redeemed.
307
+ const saved = this.#store.atomically((): 'saved' | 'unpaired' | 'changed' => {
308
+ if (!this.#db.prepare("select 1 from auth_tokens where id = ? and kind = 'device'").get(flow.device_id)) return 'unpaired';
309
+ const changed = this.#db
310
+ .prepare(
311
+ `update oauth_connections set status = 'connected', status_reason = null, generation = generation + 1, resource = ?, issuer = ?, as_metadata = ?, client_id = ?, client_for = ?, scope = ?,
312
+ access_token = ?, access_expires_at = ?, access_lifetime_ms = ?, refresh_token = ?, refreshed_at = ?, refresh_error = null, refresh_error_kind = null, refresh_retry_at = null, refresh_started_at = null,
313
+ keepalive_at = case when keep_connected = 1 then ? else keepalive_at end, notified_generation = null, updated_at = ?
314
+ where id = ? and generation = ?`,
315
+ )
316
+ .run(
317
+ flow.resource,
318
+ flow.issuer,
319
+ JSON.stringify(metadata),
320
+ flow.client_id,
321
+ `${flow.issuer} ${flow.redirect_uri}`,
322
+ tokens.scope ?? flow.scope,
323
+ tokens.accessToken,
324
+ tokens.expiresInMs ? new Date(now + tokens.expiresInMs).toISOString() : null,
325
+ tokens.expiresInMs,
326
+ tokens.refreshToken,
327
+ new Date(now).toISOString(),
328
+ new Date(now + KEEPALIVE_MS).toISOString(),
329
+ nowIso(),
330
+ flow.connection_id,
331
+ flow.generation,
332
+ ).changes;
333
+ // Older sign-ins for it can't complete any more.
334
+ if (!Number(changed)) return 'changed';
335
+ this.#db.prepare('delete from oauth_flows where connection_id = ?').run(flow.connection_id);
336
+ return 'saved';
337
+ });
338
+ if (saved === 'unpaired') {
339
+ log.warn('OAuth sign-in discarded: the device that started it was unpaired while it finished', fields);
340
+ return fail('The device that started this sign-in is no longer paired.', returnTo, name);
341
+ }
342
+ if (saved === 'changed') return fail(`${name} was signed in again since this sign-in started. Nothing changed.`, returnTo, name);
343
+ log.info('OAuth connector signed in', { ...fields, issuer: hostOf(flow.issuer) });
344
+ this.emit(flow.connection_id);
345
+ try {
346
+ this.onConnected(flow.connection_id);
347
+ } catch (err) {
348
+ log.warn('could not start looking at a signed-in connector', { ...fields, err });
349
+ }
350
+ return { ok: true, message: `${name} is connected.`, returnTo, name };
351
+ }
352
+
353
+ // ------------------------------------------------------------- tokens
354
+
355
+ /** Run `fn` after whatever this connection is already running: one refresh at a time. */
356
+ #one<T>(id: string, fn: () => Promise<T>): Promise<T> {
357
+ const prev = this.#serial.get(id) ?? Promise.resolve();
358
+ const next = prev.catch(() => {}).then(fn);
359
+ this.#serial.set(id, next);
360
+ void next.catch(() => {}).then(() => {
361
+ if (this.#serial.get(id) === next) this.#serial.delete(id);
362
+ });
363
+ return next;
364
+ }
365
+
366
+ /**
367
+ * An access token good for at least `forMs` (capped at half its lifetime), refreshing first if needed. Throws an
368
+ * OAuthError: grant when the connector must be signed in to again.
369
+ */
370
+ accessToken(id: string, forMs: number): Promise<string> {
371
+ return this.#one(id, async () => {
372
+ const c = this.record(id);
373
+ if (!c) throw new OAuthError('setup', 'The connector was deleted');
374
+ if (c.status !== 'connected' || !c.accessToken) throw new OAuthError('grant', `${c.name} is not connected: sign in again in Settings → Connectors`);
375
+ if (fresh(c, forMs)) return c.accessToken;
376
+ return this.#refresh(c, 'expiring');
377
+ });
378
+ }
379
+
380
+ /** The token `stale` was refused: refresh, unless another caller already did. */
381
+ replaceToken(id: string, stale: string): Promise<string> {
382
+ return this.#one(id, async () => {
383
+ const c = this.record(id);
384
+ if (!c) throw new OAuthError('setup', 'The connector was deleted');
385
+ if (c.status !== 'connected' || !c.accessToken) throw new OAuthError('grant', `${c.name} is not connected`);
386
+ if (c.accessToken !== stale) return c.accessToken;
387
+ return this.#refresh(c, 'refused');
388
+ });
389
+ }
390
+
391
+ #keeping = new Set<string>();
392
+
393
+ /** Keep-alive: refresh now, so the grant doesn't lapse while unused. A failure tries again in an hour. */
394
+ keepAlive(id: string): Promise<void> {
395
+ if (this.#keeping.has(id)) return Promise.resolve();
396
+ this.#keeping.add(id);
397
+ return this.#one(id, async () => {
398
+ const c = this.record(id);
399
+ if (!c || c.status !== 'connected') return;
400
+ await this.#refresh(c, 'keep-alive');
401
+ })
402
+ .catch((err) => {
403
+ log.info('keep-alive refresh failed', { connectionId: id, error: this.safeError(id, err) });
404
+ this.#db.prepare('update oauth_connections set keepalive_at = ? where id = ? and keepalive_at <= ?').run(new Date(Date.now() + 3_600_000).toISOString(), id, nowIso());
405
+ })
406
+ .finally(() => this.#keeping.delete(id));
407
+ }
408
+
409
+ /** Connections whose weekly refresh is due. */
410
+ keepAliveDue(now = new Date()): string[] {
411
+ return (this.#db.prepare("select id from oauth_connections where status = 'connected' and keep_connected = 1 and keepalive_at is not null and keepalive_at <= ?").all(now.toISOString()) as Row[]).map((r) => r.id);
412
+ }
413
+
414
+ /** Inside #one. */
415
+ async #refresh(c: ConnectionRecord, why: string): Promise<string> {
416
+ const fields = { connectionId: c.id, reason: why };
417
+ if (!c.refreshToken || !c.metadata) {
418
+ this.#grantLost(c, 'The access token expired and the server gave no refresh token');
419
+ throw new OAuthError('grant', `${c.name} needs signing in again`);
420
+ }
421
+ const state = this.#db.prepare('select refresh_retry_at, refresh_started_at from oauth_connections where id = ?').get(c.id) as Row | undefined;
422
+ // #one runs one refresh at a time here, so a refresh still marked as started was cut off by a stop or crash.
423
+ if (state?.refresh_started_at) throw this.#interrupted(c);
424
+ if (state?.refresh_retry_at && state.refresh_retry_at > nowIso()) throw new OAuthError('transient', `${c.name}: the last refresh failed; trying again shortly`);
425
+ // Saved before the request goes out: if this process stops before the answer is saved, the server may have
426
+ // rotated the refresh token already, and a server that detects reuse revokes the whole grant when the old one
427
+ // is sent again. The next start finds the mark and asks for a new sign-in instead of sending it.
428
+ const marked = this.#db
429
+ .prepare("update oauth_connections set refresh_started_at = ?, updated_at = ? where id = ? and generation = ? and refresh_token = ? and status = 'connected' and refresh_started_at is null")
430
+ .run(nowIso(), nowIso(), c.id, c.generation, c.refreshToken).changes;
431
+ if (!Number(marked)) {
432
+ const now2 = this.record(c.id);
433
+ if (now2?.status === 'connected' && now2.accessToken) return now2.accessToken;
434
+ throw new OAuthError('grant', `${c.name} is not connected`);
435
+ }
436
+ let tokens: Tokens;
437
+ try {
438
+ tokens = await refreshGrant(c.metadata, { refreshToken: c.refreshToken, accessToken: c.accessToken, clientId: c.clientId, resource: c.resource }, this.#cfg.oauth.allow);
439
+ } catch (err) {
440
+ // Anything but a classified answer may have come after the request was carried out. refreshGrant's errors are
441
+ // already sanitized from the raw answer with both tokens; anything else is, here, the same way.
442
+ const e = err instanceof OAuthError ? err : new OAuthError('transient', String((err as Error)?.message ?? err), true, [c.accessToken, c.refreshToken]);
443
+ if (e.kind === 'grant') {
444
+ this.#grantLost(c, e.message);
445
+ log.warn('OAuth refresh refused: the connector needs signing in again', { ...fields, error: e.message });
446
+ } else if (e.ambiguous) {
447
+ // The request may have been carried out and its answer lost: the server may have rotated the refresh token,
448
+ // and a server that detects reuse revokes the whole grant when the old one is sent again. So it never is.
449
+ this.#grantLost(c, `A token refresh got no usable answer (${e.message}), so it can't safely be repeated`);
450
+ log.warn('OAuth refresh answer lost: the refresh token is not used again; the connector needs signing in again', { ...fields, error: e.message });
451
+ throw new OAuthError('grant', `${c.name} needs signing in again: a token refresh got no usable answer`);
452
+ } else {
453
+ // The server answered with an error, or was never reached: the refresh token is unchanged.
454
+ this.#db
455
+ .prepare('update oauth_connections set refresh_error = ?, refresh_error_kind = ?, refresh_retry_at = ?, refresh_started_at = null, updated_at = ? where id = ? and generation = ?')
456
+ .run(e.message, 'transient', new Date(Date.now() + TRANSIENT_WAIT_MS).toISOString(), nowIso(), c.id, c.generation);
457
+ this.emit(c.id);
458
+ log.warn('OAuth refresh failed; the grant is kept', { ...fields, error: e.message });
459
+ }
460
+ throw e;
461
+ }
462
+ const now = Date.now();
463
+ const changed = this.#db
464
+ .prepare(
465
+ `update oauth_connections set access_token = ?, access_expires_at = ?, access_lifetime_ms = ?, refresh_token = ?, scope = coalesce(?, scope), refreshed_at = ?,
466
+ refresh_error = null, refresh_error_kind = null, refresh_retry_at = null, refresh_started_at = null, keepalive_at = case when keep_connected = 1 then ? else keepalive_at end, updated_at = ?
467
+ where id = ? and generation = ? and refresh_token = ? and status = 'connected'`,
468
+ )
469
+ .run(
470
+ tokens.accessToken,
471
+ tokens.expiresInMs ? new Date(now + tokens.expiresInMs).toISOString() : null,
472
+ tokens.expiresInMs,
473
+ tokens.refreshToken ?? c.refreshToken,
474
+ tokens.scope,
475
+ new Date(now).toISOString(),
476
+ new Date(now + KEEPALIVE_MS).toISOString(),
477
+ nowIso(),
478
+ c.id,
479
+ c.generation,
480
+ c.refreshToken,
481
+ ).changes;
482
+ if (!Number(changed)) {
483
+ // Signed in again (or deleted) meanwhile: the newer grant wins; this pair is dropped.
484
+ log.info('OAuth refresh discarded: the connector changed while it ran', fields);
485
+ const now2 = this.record(c.id);
486
+ if (now2?.status === 'connected' && now2.accessToken) return now2.accessToken;
487
+ throw new OAuthError('grant', `${c.name} is not connected`);
488
+ }
489
+ log.info('OAuth token refreshed', { ...fields, rotated: !!tokens.refreshToken });
490
+ this.emit(c.id);
491
+ return tokens.accessToken;
492
+ }
493
+
494
+ /** A refresh that was sent but whose answer was never saved: its refresh token is never sent again. */
495
+ #interrupted(c: ConnectionRecord): OAuthError {
496
+ this.#grantLost(c, 'A token refresh was cut off before its answer was saved, so it can’t safely be repeated');
497
+ log.warn('OAuth refresh interrupted: the refresh token is not used again; the connector needs signing in again', { connectionId: c.id });
498
+ return new OAuthError('grant', `${c.name} needs signing in again: a token refresh was cut off`);
499
+ }
500
+
501
+ /** At start: connectors whose refresh was cut off by a stop or crash need signing in again. */
502
+ recoverInterrupted(): number {
503
+ let n = 0;
504
+ for (const r of this.#db.prepare("select * from oauth_connections where refresh_started_at is not null and status = 'connected'").all() as Row[]) {
505
+ this.#interrupted(toRecord(r));
506
+ n++;
507
+ }
508
+ // A connection that isn't connected keeps no tokens; the mark means nothing there.
509
+ this.#db.prepare("update oauth_connections set refresh_started_at = null where refresh_started_at is not null and status <> 'connected'").run();
510
+ return n;
511
+ }
512
+
513
+ /** The grant is gone: Reconnect, and the Botons that have it hear once per sign-in. */
514
+ #grantLost(c: ConnectionRecord, why: string): void {
515
+ const reason = sanitize(why, [c.accessToken, c.refreshToken]);
516
+ const notify = this.#store.atomically(() => {
517
+ const changed = this.#db
518
+ .prepare(
519
+ "update oauth_connections set status = 'reconnect', status_reason = ?, access_token = null, access_expires_at = null, refresh_token = null, refresh_error = null, refresh_error_kind = null, refresh_retry_at = null, refresh_started_at = null, updated_at = ? where id = ? and generation = ? and status = 'connected'",
520
+ )
521
+ .run(reason, nowIso(), c.id, c.generation).changes;
522
+ if (!Number(changed)) return false;
523
+ const claimed = this.#db.prepare('update oauth_connections set notified_generation = generation where id = ? and (notified_generation is null or notified_generation <> generation)').run(c.id).changes;
524
+ if (!Number(claimed)) return false;
525
+ const id = `oauth:${c.name}`;
526
+ for (const b of this.#store.listBots()) {
527
+ if (!b.connections.includes(id)) continue;
528
+ this.#store.addMessage(b.id, 'event', `${c.name} needs signing in again: its connector is off until ${this.#store.getSettings().ownerName} reconnects it in Settings → Connectors`, {
529
+ kind: 'connector.reconnect',
530
+ connectorId: id,
531
+ });
532
+ }
533
+ return true;
534
+ });
535
+ this.emit(c.id);
536
+ if (notify) {
537
+ void this.#notifier
538
+ .push({ bot: null, reason: 'failure', detail: `The connector ${c.name} needs signing in again (Settings → Connectors).`, generic: 'A connector needs signing in again.', priority: 'default' })
539
+ .catch((err) => log.warn('could not push about a connector that needs signing in', { connectionId: c.id, err }));
540
+ }
541
+ }
542
+
543
+ /** An error's text for a log line or a saved status: without this connector's tokens or anything token-shaped. */
544
+ safeError(id: string, err: unknown): string {
545
+ const c = this.record(id);
546
+ return sanitize(typeof err === 'string' ? err : ((err as Error)?.message ?? String(err)), [c?.accessToken, c?.refreshToken]);
547
+ }
548
+
549
+ /** The feed's findings. */
550
+ setFeed(id: string, patch: { feed?: boolean; cursor?: string | null; ready?: boolean; error?: string | null }): void {
551
+ const sets: string[] = ['feed_checked_at = ?'];
552
+ const vals: (string | number | null)[] = [nowIso()];
553
+ if (patch.feed !== undefined) {
554
+ sets.push('feed = ?');
555
+ vals.push(patch.feed ? 1 : 0);
556
+ }
557
+ if (patch.cursor !== undefined) {
558
+ sets.push('feed_cursor = ?');
559
+ vals.push(patch.cursor);
560
+ }
561
+ if (patch.ready !== undefined) {
562
+ sets.push('feed_ready = ?');
563
+ vals.push(patch.ready ? 1 : 0);
564
+ }
565
+ if (patch.error !== undefined) {
566
+ sets.push('feed_error = ?');
567
+ vals.push(patch.error === null ? null : this.safeError(id, patch.error));
568
+ }
569
+ this.#db.prepare(`update oauth_connections set ${sets.join(', ')} where id = ?`).run(...vals, id);
570
+ this.emit(id);
571
+ }
572
+
573
+ /** A cursor and whether its first catch-up is done. */
574
+ feedState(id: string): { cursor: string | null; ready: boolean } {
575
+ const r = this.#row(id);
576
+ return { cursor: r?.feed_cursor ?? null, ready: !!r?.feed_ready };
577
+ }
578
+ }
579
+
580
+ function toRecord(r: Row): ConnectionRecord {
581
+ return {
582
+ id: r.id,
583
+ name: r.name,
584
+ url: r.url,
585
+ status: r.status,
586
+ generation: Number(r.generation),
587
+ resource: r.resource || r.url,
588
+ issuer: r.issuer,
589
+ metadata: metadataOf(r.as_metadata),
590
+ clientId: r.client_id,
591
+ accessToken: r.access_token ?? null,
592
+ accessExpiresAt: r.access_expires_at ?? null,
593
+ accessLifetimeMs: r.access_lifetime_ms ?? null,
594
+ refreshToken: r.refresh_token ?? null,
595
+ feed: !!r.feed,
596
+ unknownJobs: r.unknown_jobs,
597
+ };
598
+ }
599
+
600
+ function metadataOf(raw: string): ServerMetadata | null {
601
+ const m = parseJson<ServerMetadata | null>(raw, null);
602
+ return m?.token_endpoint && m.issuer ? m : null;
603
+ }
604
+
605
+ /** Good for `forMs` more, or half its lifetime when that is shorter. Unknown expiry: good until it is refused. */
606
+ function fresh(c: ConnectionRecord, forMs: number): boolean {
607
+ if (!c.accessExpiresAt) return true;
608
+ const need = c.accessLifetimeMs ? Math.min(forMs, c.accessLifetimeMs / 2) : forMs;
609
+ return new Date(c.accessExpiresAt).getTime() - Date.now() > need;
610
+ }
611
+
612
+ function displayUrl(url: string): string {
613
+ try {
614
+ const u = new URL(url);
615
+ return `${u.protocol}//${u.host}${u.pathname}${u.search ? '?…' : ''}`;
616
+ } catch {
617
+ return '(invalid URL)';
618
+ }
619
+ }
620
+
621
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
622
+
623
+ /**
624
+ * The page the browser lands on after the authorization server: no code, state or token in it, nothing cached, no
625
+ * referrer sent on (its own URL holds the code), and a link back to the app.
626
+ */
627
+ export function callbackPage(r: { ok: boolean; message: string; returnTo: string; name: string | null }): { status: number; headers: Record<string, string>; body: string } {
628
+ const back = new URL(r.returnTo);
629
+ back.pathname = '/';
630
+ back.search = '';
631
+ if (r.name && NAME.test(r.name)) {
632
+ back.searchParams.set('connector', r.name);
633
+ back.searchParams.set('signin', r.ok ? 'connected' : 'failed');
634
+ }
635
+ const href = escapeHtml(back.toString());
636
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><title>Botons</title>${r.ok ? `<meta http-equiv="refresh" content="1; url=${href}">` : ''}<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1.25rem;color:#1d1d1f}a{color:#0b63ce}</style></head><body><h1>${r.ok ? 'Connected' : 'Not connected'}</h1><p>${escapeHtml(r.message)}</p><p><a href="${href}">Back to Botons</a></p></body></html>`;
637
+ return {
638
+ status: r.ok ? 200 : 400,
639
+ headers: {
640
+ 'content-type': 'text/html; charset=utf-8',
641
+ 'cache-control': 'no-store',
642
+ pragma: 'no-cache',
643
+ 'referrer-policy': 'no-referrer',
644
+ 'x-frame-options': 'DENY',
645
+ 'x-content-type-options': 'nosniff',
646
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
647
+ },
648
+ body,
649
+ };
650
+ }