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.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/installer/admin.mjs +6 -0
- package/installer/botons.mjs +430 -0
- package/installer/brain-clis.mjs +28 -0
- package/installer/linger.mjs +39 -0
- package/installer/output.mjs +27 -0
- package/installer/revision.mjs +30 -0
- package/installer/shell-path.mjs +66 -0
- package/installer/source-lock.json +2891 -0
- package/package.json +26 -4
- package/payload/LICENSE +21 -0
- package/payload/SECURITY.md +33 -0
- package/payload/installer/admin.mjs +6 -0
- package/payload/installer/botons.mjs +430 -0
- package/payload/installer/brain-clis.mjs +28 -0
- package/payload/installer/linger.mjs +39 -0
- package/payload/installer/output.mjs +27 -0
- package/payload/installer/revision.mjs +30 -0
- package/payload/installer/shell-path.mjs +66 -0
- package/payload/installer/source-lock.json +2891 -0
- package/payload/npm-shrinkwrap.json +51 -0
- package/payload/package.json +13 -0
- package/payload/scripts/backup.ts +47 -0
- package/payload/scripts/botons-backup.service +15 -0
- package/payload/scripts/botons-backup.timer +11 -0
- package/payload/scripts/botons.service +36 -0
- package/payload/scripts/check-browser.ts +90 -0
- package/payload/scripts/doctor.ts +790 -0
- package/payload/scripts/install-backup-timer.ts +52 -0
- package/payload/scripts/install-service.ts +206 -0
- package/payload/scripts/login.ts +17 -0
- package/payload/scripts/migrate-names.ts +47 -0
- package/payload/scripts/pair.ts +163 -0
- package/payload/scripts/restore.ts +108 -0
- package/payload/server/bin/botons +4 -0
- package/payload/server/bin/legacy/minion +4 -0
- package/payload/server/bin/legacy/tots +4 -0
- package/payload/server/src/acting.ts +16 -0
- package/payload/server/src/api-keys.ts +544 -0
- package/payload/server/src/api.ts +1484 -0
- package/payload/server/src/app.ts +546 -0
- package/payload/server/src/approvals.ts +873 -0
- package/payload/server/src/ask-rules.ts +181 -0
- package/payload/server/src/audit.ts +158 -0
- package/payload/server/src/auth.ts +497 -0
- package/payload/server/src/backup.ts +382 -0
- package/payload/server/src/brain-login.ts +75 -0
- package/payload/server/src/browser-cert.ts +67 -0
- package/payload/server/src/browser-handoff.ts +119 -0
- package/payload/server/src/browser-handover.ts +175 -0
- package/payload/server/src/browser-policy.ts +210 -0
- package/payload/server/src/browser-proxy.ts +580 -0
- package/payload/server/src/browser-request.ts +335 -0
- package/payload/server/src/browser-screenshot.ts +56 -0
- package/payload/server/src/browser-send.ts +155 -0
- package/payload/server/src/browser.ts +2164 -0
- package/payload/server/src/callback.ts +218 -0
- package/payload/server/src/capabilities.ts +90 -0
- package/payload/server/src/cards.ts +243 -0
- package/payload/server/src/cli/approve-mcp.ts +119 -0
- package/payload/server/src/cli/botons.ts +629 -0
- package/payload/server/src/cli/browser-mcp.ts +168 -0
- package/payload/server/src/cli/workspace-file.ts +78 -0
- package/payload/server/src/cli-homes.ts +38 -0
- package/payload/server/src/cli-install.ts +104 -0
- package/payload/server/src/cli-updates.ts +112 -0
- package/payload/server/src/codex-config-repair.ts +80 -0
- package/payload/server/src/config.ts +473 -0
- package/payload/server/src/connector-access.ts +158 -0
- package/payload/server/src/connector-launch.ts +203 -0
- package/payload/server/src/connectors.ts +947 -0
- package/payload/server/src/disk-usage.ts +62 -0
- package/payload/server/src/engine.ts +2328 -0
- package/payload/server/src/env-init.ts +5 -0
- package/payload/server/src/events.ts +46 -0
- package/payload/server/src/files.ts +528 -0
- package/payload/server/src/github-access.ts +52 -0
- package/payload/server/src/health.ts +117 -0
- package/payload/server/src/held-dir.ts +96 -0
- package/payload/server/src/hooks.ts +141 -0
- package/payload/server/src/http.ts +207 -0
- package/payload/server/src/index.ts +139 -0
- package/payload/server/src/install-type.ts +11 -0
- package/payload/server/src/instructions.ts +204 -0
- package/payload/server/src/isolation.ts +234 -0
- package/payload/server/src/legacy-upgrade.ts +668 -0
- package/payload/server/src/limits.ts +44 -0
- package/payload/server/src/lock.ts +75 -0
- package/payload/server/src/log.ts +137 -0
- package/payload/server/src/managed-clis.ts +65 -0
- package/payload/server/src/mcp-client.ts +229 -0
- package/payload/server/src/notify.ts +193 -0
- package/payload/server/src/oauth-connections.ts +650 -0
- package/payload/server/src/oauth.ts +311 -0
- package/payload/server/src/outbound.ts +142 -0
- package/payload/server/src/portable.ts +1669 -0
- package/payload/server/src/prompt.ts +676 -0
- package/payload/server/src/providers/api-brain.ts +612 -0
- package/payload/server/src/providers/api-history.ts +131 -0
- package/payload/server/src/providers/api-tools.ts +481 -0
- package/payload/server/src/providers/api-wire.ts +690 -0
- package/payload/server/src/providers/claude-cli.ts +620 -0
- package/payload/server/src/providers/cli-check.ts +265 -0
- package/payload/server/src/providers/cli-turn.ts +20 -0
- package/payload/server/src/providers/codex-cli.ts +552 -0
- package/payload/server/src/providers/codex-workspace.ts +61 -0
- package/payload/server/src/providers/failures.ts +143 -0
- package/payload/server/src/providers/index.ts +131 -0
- package/payload/server/src/providers/mcp-stdio.ts +147 -0
- package/payload/server/src/providers/mock.ts +94 -0
- package/payload/server/src/providers/process.ts +268 -0
- package/payload/server/src/providers/readiness.ts +43 -0
- package/payload/server/src/providers/sse.ts +73 -0
- package/payload/server/src/providers/types.ts +173 -0
- package/payload/server/src/rates.ts +128 -0
- package/payload/server/src/reactions.ts +266 -0
- package/payload/server/src/recent-logs.ts +489 -0
- package/payload/server/src/rehearsal.ts +593 -0
- package/payload/server/src/rehearsals.ts +303 -0
- package/payload/server/src/replay.ts +165 -0
- package/payload/server/src/sandbox.ts +767 -0
- package/payload/server/src/schedule.ts +214 -0
- package/payload/server/src/scheduler.ts +24 -0
- package/payload/server/src/schema.ts +882 -0
- package/payload/server/src/secrets.ts +46 -0
- package/payload/server/src/setup.ts +356 -0
- package/payload/server/src/skill-packages.ts +363 -0
- package/payload/server/src/skills.ts +527 -0
- package/payload/server/src/spend.ts +255 -0
- package/payload/server/src/ssrf.ts +126 -0
- package/payload/server/src/starter-gate.ts +168 -0
- package/payload/server/src/starters.ts +610 -0
- package/payload/server/src/storage.ts +32 -0
- package/payload/server/src/store.ts +3087 -0
- package/payload/server/src/tailnet.ts +132 -0
- package/payload/server/src/templates.ts +454 -0
- package/payload/server/src/trash.ts +772 -0
- package/payload/server/src/util.ts +97 -0
- package/payload/server/src/webhooks.ts +216 -0
- package/payload/server/src/work-feed.ts +473 -0
- package/payload/shared/app-version.d.mts +1 -0
- package/payload/shared/app-version.mjs +3 -0
- package/payload/shared/boton.ts +323 -0
- package/payload/shared/brand.ts +107 -0
- package/payload/shared/cards.ts +427 -0
- package/payload/shared/effort.ts +91 -0
- package/payload/shared/files.ts +104 -0
- package/payload/shared/handoff.ts +97 -0
- package/payload/shared/install-commands.ts +7 -0
- package/payload/shared/legacy-names.d.mts +35 -0
- package/payload/shared/legacy-names.mjs +244 -0
- package/payload/shared/live-view.ts +94 -0
- package/payload/shared/look-critter.ts +189 -0
- package/payload/shared/look-tater.ts +127 -0
- package/payload/shared/look.ts +200 -0
- package/payload/shared/reactions.ts +43 -0
- package/payload/shared/seeded.ts +34 -0
- package/payload/shared/services.ts +90 -0
- package/payload/shared/types.ts +3016 -0
- package/payload/shared/util.ts +68 -0
- package/payload/shared/versions.ts +44 -0
- package/payload/shared/wake.ts +164 -0
- package/payload/web/dist/apple-touch-icon.png +0 -0
- package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
- package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
- package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
- package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
- package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
- package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
- package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
- package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
- package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
- package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
- package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
- package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
- package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
- package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
- package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
- package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
- package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
- package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
- package/payload/web/dist/favicon.svg +1 -0
- package/payload/web/dist/icon-192.png +0 -0
- package/payload/web/dist/icon-512.png +0 -0
- package/payload/web/dist/icon.svg +1 -0
- package/payload/web/dist/index.html +67 -0
- package/payload/web/dist/manifest.webmanifest +12 -0
- package/shared/legacy-names.d.mts +35 -0
- package/shared/legacy-names.mjs +244 -0
|
@@ -0,0 +1,1484 @@
|
|
|
1
|
+
import { VERSION } from '../../shared/app-version.mjs';
|
|
2
|
+
import { cliHome, importCodexSignIn } from './cli-homes.ts';
|
|
3
|
+
import { installType } from './install-type.ts';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { CliInstaller } from './cli-install.ts';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { ReleaseChecker, botonsReleaseVersion } from './cli-updates.ts';
|
|
8
|
+
import { previewCodexRepairs, repairCodexConfig } from './codex-config-repair.ts';
|
|
9
|
+
import { BrainLogin } from './brain-login.ts';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import type {
|
|
12
|
+
RecentLogs,
|
|
13
|
+
RehearseRestoreInput,
|
|
14
|
+
ApiKeysView,
|
|
15
|
+
ApiKeyState,
|
|
16
|
+
ApiKeyTest,
|
|
17
|
+
RemoveApiKeyInput,
|
|
18
|
+
SaveApiKeyInput,
|
|
19
|
+
SpendingInput,
|
|
20
|
+
SpendingView,
|
|
21
|
+
ApprovalDecisionInput,
|
|
22
|
+
Bot,
|
|
23
|
+
ApprovalRequestInput,
|
|
24
|
+
ToolCallInput,
|
|
25
|
+
AskInput,
|
|
26
|
+
BrowserActionInput,
|
|
27
|
+
BrowserPolicyInput,
|
|
28
|
+
BrowserHandoverStart,
|
|
29
|
+
HandoverNotice,
|
|
30
|
+
ConnectorAccess,
|
|
31
|
+
ConnectorAccessInput,
|
|
32
|
+
ConnectorEnabledInput,
|
|
33
|
+
ConnectorCatalog,
|
|
34
|
+
ConnectorNotice,
|
|
35
|
+
ConnectorPolicyInput,
|
|
36
|
+
ConnectorTest,
|
|
37
|
+
DuplicateBotInput,
|
|
38
|
+
FilePreviewInput,
|
|
39
|
+
FilePreviewLink,
|
|
40
|
+
OAuthConnectionInput,
|
|
41
|
+
NewBotInput,
|
|
42
|
+
CreateRoutineInput,
|
|
43
|
+
CreateSkillInput,
|
|
44
|
+
InstallSkillInput,
|
|
45
|
+
SkillAccessInput,
|
|
46
|
+
SkillDetails,
|
|
47
|
+
Health,
|
|
48
|
+
OperationsStatus,
|
|
49
|
+
McpServerInput,
|
|
50
|
+
NotifyDelivery,
|
|
51
|
+
NotifyInput,
|
|
52
|
+
PairInput,
|
|
53
|
+
PairingRequest,
|
|
54
|
+
ApprovePairingInput,
|
|
55
|
+
ApprovedPairing,
|
|
56
|
+
PairResult,
|
|
57
|
+
Routine,
|
|
58
|
+
RoutineRun,
|
|
59
|
+
RunsAttention,
|
|
60
|
+
RunRoutineInput,
|
|
61
|
+
SaveTemplateInput,
|
|
62
|
+
SetupCheck,
|
|
63
|
+
StarterActivationInput,
|
|
64
|
+
StarterInstall,
|
|
65
|
+
TemplateReview,
|
|
66
|
+
TemplateReviewInput,
|
|
67
|
+
CreatedSidebarGroup,
|
|
68
|
+
CreateSidebarGroupInput,
|
|
69
|
+
MoveSidebarBotInput,
|
|
70
|
+
MoveSidebarGroupInput,
|
|
71
|
+
PinSidebarBotInput,
|
|
72
|
+
SidebarImportResult,
|
|
73
|
+
SidebarLayout,
|
|
74
|
+
SidebarTransfer,
|
|
75
|
+
UpdateSidebarGroupInput,
|
|
76
|
+
UpdateSidebarSectionInput,
|
|
77
|
+
SchedulePreview,
|
|
78
|
+
PinInput,
|
|
79
|
+
ReactInput,
|
|
80
|
+
ReactionImportResult,
|
|
81
|
+
ReactionTransfer,
|
|
82
|
+
SendMessageInput,
|
|
83
|
+
ServerEvent,
|
|
84
|
+
ServerInfo,
|
|
85
|
+
ShareInput,
|
|
86
|
+
StopInput,
|
|
87
|
+
StopResult,
|
|
88
|
+
ProviderSwitchInput,
|
|
89
|
+
ProviderSwitchPreview,
|
|
90
|
+
UpdateBotRequest,
|
|
91
|
+
UpdateRoutineInput,
|
|
92
|
+
UpdateSettingsInput,
|
|
93
|
+
UpdateSkillInput,
|
|
94
|
+
UpdateSetupInput,
|
|
95
|
+
PortableExport,
|
|
96
|
+
PortableImportInput,
|
|
97
|
+
PortableImportPreview,
|
|
98
|
+
PortableImportResult,
|
|
99
|
+
DeleteFilesInput,
|
|
100
|
+
InboxPinInput,
|
|
101
|
+
RestoreResult,
|
|
102
|
+
StorageUsage,
|
|
103
|
+
TrashItem,
|
|
104
|
+
TrashResult,
|
|
105
|
+
} from '../../shared/types.ts';
|
|
106
|
+
import { folderBytes } from './disk-usage.ts';
|
|
107
|
+
import { fileKind, formatSize, PREVIEW_MAX_BYTES } from '../../shared/files.ts';
|
|
108
|
+
import { ATTENTION_DAYS, BROWSER_CONNECTOR, isValidTimezone } from '../../shared/util.ts';
|
|
109
|
+
import { type ApiKeys, apiKeyProvider } from './api-keys.ts';
|
|
110
|
+
import type { Spending } from './spend.ts';
|
|
111
|
+
import type { Approvals } from './approvals.ts';
|
|
112
|
+
import type { Cards } from './cards.ts';
|
|
113
|
+
import type { Reactions } from './reactions.ts';
|
|
114
|
+
import type { Audit } from './audit.ts';
|
|
115
|
+
import type { Browser } from './browser.ts';
|
|
116
|
+
import { liveFrame, liveNotice } from '../../shared/live-view.ts';
|
|
117
|
+
import { browserUsable, drivesBrowser, NEW_BOTON_SITES, newBotonConnections } from './browser-policy.ts';
|
|
118
|
+
import { backupStatus, restorableSchemas } from './backup.ts';
|
|
119
|
+
import { botonCapabilities } from './capabilities.ts';
|
|
120
|
+
import type { Auth, Principal, RouteAccess } from './auth.ts';
|
|
121
|
+
import type { Config } from './config.ts';
|
|
122
|
+
import { connectorAccess, setConnectorAccess, setConnectorEnabled } from './connector-access.ts';
|
|
123
|
+
import type { Connectors } from './connectors.ts';
|
|
124
|
+
import type { Engine } from './engine.ts';
|
|
125
|
+
import type { EventHub } from './events.ts';
|
|
126
|
+
import type { Trash } from './trash.ts';
|
|
127
|
+
import { attachmentsOf, listFolder, receiveUpload, resolveInWorkspace, sendFile, sendPreviewError } from './files.ts';
|
|
128
|
+
import type { Callbacks } from './callback.ts';
|
|
129
|
+
import type { HealthMonitor } from './health.ts';
|
|
130
|
+
import { addHookRoutes } from './hooks.ts';
|
|
131
|
+
import { type Ctx, Router } from './http.ts';
|
|
132
|
+
import { log } from './log.ts';
|
|
133
|
+
import type { OAuthConnections } from './oauth-connections.ts';
|
|
134
|
+
import type { ProviderRegistry } from './providers/index.ts';
|
|
135
|
+
import type { RecentLogs as RecentLogBuffer } from './recent-logs.ts';
|
|
136
|
+
import type { RestoreRehearsals } from './rehearsals.ts';
|
|
137
|
+
import { classifyAddress, type Setup } from './setup.ts';
|
|
138
|
+
import { SCHEMA_VERSION } from './schema.ts';
|
|
139
|
+
import { storageStatus } from './storage.ts';
|
|
140
|
+
import { describeSchedule, nextRuns, parseSchedule, toSpec } from './schedule.ts';
|
|
141
|
+
import { checkCallbackUrl } from './ssrf.ts';
|
|
142
|
+
import type { SkillGrantChange, Skills } from './skills.ts';
|
|
143
|
+
import { flag, HttpError, type Store } from './store.ts';
|
|
144
|
+
import type { StarterGate } from './starter-gate.ts';
|
|
145
|
+
import { reviewStarter, setupCheck, starterConnections } from './starters.ts';
|
|
146
|
+
import { Templates } from './templates.ts';
|
|
147
|
+
import { Portable, PORTABLE_MAX_BYTES } from './portable.ts';
|
|
148
|
+
import { nowIso } from './util.ts';
|
|
149
|
+
|
|
150
|
+
export { VERSION };
|
|
151
|
+
/** Test pushes allowed per minute from the app. */
|
|
152
|
+
export const TEST_PUSH_LIMIT = 10;
|
|
153
|
+
const TEST_PUSH_WINDOW_MS = 60_000;
|
|
154
|
+
|
|
155
|
+
/** An ISO time, or a duration back from now ("24h", "7d", "90m"). Default 7 days. */
|
|
156
|
+
export function usageSince(raw: string | null, now = Date.now()): Date {
|
|
157
|
+
if (raw === null || raw.trim() === '') return new Date(now - 7 * 86_400_000);
|
|
158
|
+
const d = /^(\d+)\s*(m|h|d)$/i.exec(raw.trim());
|
|
159
|
+
if (d) return new Date(now - Number(d[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[d[2].toLowerCase() as 'm' | 'h' | 'd']);
|
|
160
|
+
const t = new Date(raw);
|
|
161
|
+
if (Number.isNaN(t.getTime())) throw new HttpError(400, 'since must be an ISO time or a duration such as 24h or 7d');
|
|
162
|
+
return t;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What a Boton was given, for the audit log. */
|
|
166
|
+
/** Each Boton a skill was given to or taken off, as its own grant change. */
|
|
167
|
+
function skillGrantsRecorder(audit: Audit) {
|
|
168
|
+
return (by: Principal | null, skill: string, changed: SkillGrantChange[]) => {
|
|
169
|
+
for (const m of changed) {
|
|
170
|
+
audit.record(by, {
|
|
171
|
+
action: 'boton.permissions_changed',
|
|
172
|
+
target: { kind: 'boton', id: m.botId, label: m.name },
|
|
173
|
+
details: { [m.added ? 'skillsAdded' : 'skillsRemoved']: [skill], via: 'skill', ...(m.wasEvery && { everySkill: true }) },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (changed.length) log.info("skill's Botons changed", { skill, added: changed.filter((m) => m.added).map((m) => m.botId), removed: changed.filter((m) => !m.added).map((m) => m.botId) });
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function grantsOf(bot: Bot): Record<string, string | string[] | null> {
|
|
181
|
+
return { access: bot.access, provider: bot.provider, connections: bot.connections, skills: bot.skills, askFirst: bot.askFirst, alwaysAllow: bot.alwaysAllow };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What was added to and removed from a Boton's connectors, skills and rules. */
|
|
185
|
+
function grantChanges(before: Bot, after: Bot): Record<string, string[]> {
|
|
186
|
+
const out: Record<string, string[]> = {};
|
|
187
|
+
const diff = (name: string, a: string[] | null, b: string[] | null) => {
|
|
188
|
+
if (a === null || b === null) {
|
|
189
|
+
if (a !== b) out[name] = [b === null ? 'every skill' : `only: ${b.join(', ') || 'none'}`];
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const added = b.filter((x) => !a.includes(x));
|
|
193
|
+
const removed = a.filter((x) => !b.includes(x));
|
|
194
|
+
if (added.length) out[`${name}Added`] = added;
|
|
195
|
+
if (removed.length) out[`${name}Removed`] = removed;
|
|
196
|
+
};
|
|
197
|
+
diff('connections', before.connections, after.connections);
|
|
198
|
+
diff('skills', before.skills, after.skills);
|
|
199
|
+
diff('askFirst', before.askFirst, after.askFirst);
|
|
200
|
+
diff('alwaysAllow', before.alwaysAllow, after.alwaysAllow);
|
|
201
|
+
if (before.provider !== after.provider) out.provider = [before.provider, after.provider];
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** "" if it isn't a URL. */
|
|
206
|
+
function urlOrigin(url: string): string {
|
|
207
|
+
try {
|
|
208
|
+
return `${new URL(url).origin}/…`;
|
|
209
|
+
} catch {
|
|
210
|
+
return '';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildRouter(deps: {
|
|
215
|
+
cfg: Config;
|
|
216
|
+
store: Store;
|
|
217
|
+
engine: Engine;
|
|
218
|
+
hub: EventHub;
|
|
219
|
+
providers: ProviderRegistry;
|
|
220
|
+
auth: Auth;
|
|
221
|
+
health: HealthMonitor;
|
|
222
|
+
connectors: Connectors;
|
|
223
|
+
skills: Skills;
|
|
224
|
+
approvals: Approvals;
|
|
225
|
+
cards: Cards;
|
|
226
|
+
reactions: Reactions;
|
|
227
|
+
browser: Browser;
|
|
228
|
+
oauth: OAuthConnections;
|
|
229
|
+
audit: Audit;
|
|
230
|
+
setup: Setup;
|
|
231
|
+
callbacks: Callbacks;
|
|
232
|
+
recentLogs: RecentLogBuffer;
|
|
233
|
+
rehearsals: RestoreRehearsals;
|
|
234
|
+
brainLogin: BrainLogin;
|
|
235
|
+
releases: ReleaseChecker;
|
|
236
|
+
cliInstaller: CliInstaller;
|
|
237
|
+
apiKeys: ApiKeys;
|
|
238
|
+
starterGate: StarterGate;
|
|
239
|
+
spending: Spending;
|
|
240
|
+
trash: Trash;
|
|
241
|
+
}): Router {
|
|
242
|
+
const { trash } = deps;
|
|
243
|
+
const { cfg, store, engine, hub, providers, auth, health, connectors, skills, approvals, cards, reactions, browser, oauth, audit, setup, callbacks, recentLogs, rehearsals, apiKeys, starterGate, spending } = deps;
|
|
244
|
+
const r = new Router();
|
|
245
|
+
const recordSkillGrants = skillGrantsRecorder(audit);
|
|
246
|
+
const templates = new Templates(cfg.templatesDir, store, skills, cfg.turnTimeoutMs);
|
|
247
|
+
/**
|
|
248
|
+
* What a starter's review reads, all from saved state: the brains as their last checks left them (snapshots, which
|
|
249
|
+
* run no check, however old), the connectors as they are now, and their revisions. Synchronous, so a review and an
|
|
250
|
+
* install's check of it see the same state as the grant that follows.
|
|
251
|
+
*/
|
|
252
|
+
const reviewDeps = () => ({
|
|
253
|
+
providers: providers.snapshots(),
|
|
254
|
+
takesConnectors: (id: string) => connectors.takesConnectors(id),
|
|
255
|
+
drivesBrowser: (id: string) => drivesBrowser(providers.get(id)),
|
|
256
|
+
catalog: connectors.catalog().connectors,
|
|
257
|
+
timezone: store.getSettings().timezone,
|
|
258
|
+
revision: (id: string) => starterGate.connectorRevision(id),
|
|
259
|
+
mac: (v: unknown) => starterGate.mac(v),
|
|
260
|
+
});
|
|
261
|
+
const portable = new Portable({ cfg, store, skills, connectors, oauth, browser, providers, templates, spending, app: VERSION, schema: SCHEMA_VERSION });
|
|
262
|
+
|
|
263
|
+
// A sandboxed turn's token reaches only the *Read routes, notify, share and
|
|
264
|
+
// approvals: otherwise it could leave routines or memories for the Boton's
|
|
265
|
+
// later turns, which may have Full computer access or ask other Botons.
|
|
266
|
+
const open: RouteAccess = { public: true };
|
|
267
|
+
const ownBot: RouteAccess = { boton: (p) => p.id };
|
|
268
|
+
const ownRoutine: RouteAccess = { boton: (p) => store.routineOwner(p.id) };
|
|
269
|
+
const ownMemory: RouteAccess = { boton: (p) => store.memoryOwner(p.id) };
|
|
270
|
+
const ownRunRead: RouteAccess = { boton: (p) => store.runOwner(p.id), sandboxed: true };
|
|
271
|
+
const ownBotRead: RouteAccess = { ...ownBot, sandboxed: true };
|
|
272
|
+
const ownRoutineRead: RouteAccess = { ...ownRoutine, sandboxed: true };
|
|
273
|
+
const ownApproval: RouteAccess = { boton: (p) => approvals.owner(p.id), sandboxed: true };
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A Boton sees a callback URL (often a secret capability) only as its
|
|
277
|
+
* origin. A sandboxed turn also gets webhook keys blanked: with one it could
|
|
278
|
+
* start that routine's runs, which may have Full computer access, with a payload of its choosing.
|
|
279
|
+
*/
|
|
280
|
+
const visible = (c: Ctx, routine: Routine): Routine => {
|
|
281
|
+
if (c.principal?.kind !== 'boton' || !routine.webhook) return routine;
|
|
282
|
+
const webhook = { ...routine.webhook, callbackUrl: routine.webhook.callbackUrl === null ? null : urlOrigin(routine.webhook.callbackUrl) };
|
|
283
|
+
if (c.principal.sandboxed) webhook.key = '';
|
|
284
|
+
return { ...routine, webhook };
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// A sandboxed turn sees only its own run's payload and headers: another's may be another sender's data.
|
|
288
|
+
const visibleRun = (c: Ctx, run: RoutineRun): RoutineRun => {
|
|
289
|
+
if (c.principal?.kind !== 'boton') return run;
|
|
290
|
+
let out = run;
|
|
291
|
+
if (run.meta?.callback) out = { ...out, meta: { ...run.meta, callback: { ...run.meta.callback, url: urlOrigin(run.meta.callback.url) } } };
|
|
292
|
+
if (c.principal.sandboxed && run.id !== engine.turnOrigin(c.principal.botId)?.runId) out = { ...out, payloadPreview: null, headers: null };
|
|
293
|
+
return out;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// A callback URL would let a Boton have the server post its replies anywhere.
|
|
297
|
+
const ownerOnly = async (c: Ctx, input: { webhookWorkspace?: unknown; callbackUrl?: unknown; webhookHistory?: unknown }) => {
|
|
298
|
+
if (c.principal?.kind === 'boton' && input.webhookWorkspace === false) {
|
|
299
|
+
throw new HttpError(403, `Only ${store.getSettings().ownerName} can let webhook runs use Full computer access`);
|
|
300
|
+
}
|
|
301
|
+
// Sharing the chat with a payload from outside is the owner's decision alone; a Boton may only take it back (0).
|
|
302
|
+
if (c.principal?.kind === 'boton' && input.webhookHistory !== undefined && input.webhookHistory !== 0) {
|
|
303
|
+
throw new HttpError(403, `Only ${store.getSettings().ownerName} can share the chat with webhook runs (webhookHistory)`);
|
|
304
|
+
}
|
|
305
|
+
if (input.callbackUrl === undefined) return;
|
|
306
|
+
if (c.principal?.kind === 'boton') throw new HttpError(403, `Only ${store.getSettings().ownerName} can set where run results are sent (callbackUrl)`);
|
|
307
|
+
if (typeof input.callbackUrl === 'string' && input.callbackUrl.trim()) {
|
|
308
|
+
const refused = await checkCallbackUrl(input.callbackUrl.trim(), cfg.callbacks.allow);
|
|
309
|
+
if (refused) throw new HttpError(400, refused);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Who may start a routine's runs with Full computer access, and where its results go.
|
|
314
|
+
const auditRoutine = (c: Ctx, before: Routine | null, after: Routine) => {
|
|
315
|
+
const full = (r: Routine | null) => !!r?.webhook && !r.webhookWorkspace;
|
|
316
|
+
const callback = (r: Routine | null) => !!r?.webhook?.callbackUrl;
|
|
317
|
+
const history = (r: Routine | null) => r?.webhookHistory ?? 0;
|
|
318
|
+
if (
|
|
319
|
+
full(before) === full(after) &&
|
|
320
|
+
callback(before) === callback(after) &&
|
|
321
|
+
(before?.webhook?.callbackUrl ?? null) === (after.webhook?.callbackUrl ?? null) &&
|
|
322
|
+
history(before) === history(after)
|
|
323
|
+
) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
audit.record(c.principal, {
|
|
327
|
+
action: 'routine.permissions_changed',
|
|
328
|
+
target: { kind: 'routine', id: after.id, label: after.name },
|
|
329
|
+
details: { boton: after.botId, webhookFullAccess: full(after), callback: after.webhook?.callbackUrl ? urlOrigin(after.webhook.callbackUrl) : null, webhookHistory: history(after) },
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Open, for the deploy's check: without a device token, no details.
|
|
334
|
+
r.on(
|
|
335
|
+
'GET',
|
|
336
|
+
'/api/health',
|
|
337
|
+
async (c) => {
|
|
338
|
+
const { details, ...summary } = await health.check();
|
|
339
|
+
c.json(summary.ok ? 200 : 503, (c.principal?.kind === 'device' ? { ...summary, details } : summary) satisfies Health);
|
|
340
|
+
},
|
|
341
|
+
open,
|
|
342
|
+
);
|
|
343
|
+
// Paired devices only (the default access): what failed, and the backups, for Settings. Always 200, unlike /api/health.
|
|
344
|
+
r.on('GET', '/api/operations', async (): Promise<OperationsStatus> => ({
|
|
345
|
+
health: await health.owner((id) => engine.usageLimit(id)?.until ?? null),
|
|
346
|
+
schema: SCHEMA_VERSION,
|
|
347
|
+
backups: backupStatus({ backupsDir: cfg.backupsDir, dbExists: existsSync(cfg.dbPath) }),
|
|
348
|
+
push: engine.notifier.status(),
|
|
349
|
+
storage: storageStatus({ home: cfg.home, dbPath: cfg.dbPath }),
|
|
350
|
+
install: installType(cfg.container),
|
|
351
|
+
restorableSchemas: restorableSchemas(SCHEMA_VERSION),
|
|
352
|
+
rehearsal: rehearsals.status(),
|
|
353
|
+
}));
|
|
354
|
+
// Paired devices only (the default access): rehearse restoring a backup into a private temporary folder, never the
|
|
355
|
+
// data folder. Starts it and answers 202; GET /api/operations shows it running, then how it went. One at a time,
|
|
356
|
+
// REHEARSAL_LIMIT an hour, each in the audit log (server/src/rehearsals.ts).
|
|
357
|
+
r.on('POST', '/api/backups/rehearse', async (c) => {
|
|
358
|
+
const input = await c.body<RehearseRestoreInput>();
|
|
359
|
+
c.json(202, rehearsals.start(c.principal, input?.backup ?? 'latest'));
|
|
360
|
+
});
|
|
361
|
+
// Paired devices only (the default access): known events selected from the recent log lines. Read-only, so not audited.
|
|
362
|
+
r.on('GET', '/api/logs', (c): RecentLogs => {
|
|
363
|
+
const q = c.url.searchParams;
|
|
364
|
+
const level = q.get('level');
|
|
365
|
+
if (level !== null && level !== 'info' && level !== 'warn' && level !== 'error') throw new HttpError(400, 'level must be "info", "warn" or "error"');
|
|
366
|
+
const num = (name: string) => {
|
|
367
|
+
const v = q.get(name);
|
|
368
|
+
if (v === null || v === '') return null;
|
|
369
|
+
const n = Number(v);
|
|
370
|
+
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, `${name} must be a positive whole number`);
|
|
371
|
+
return n;
|
|
372
|
+
};
|
|
373
|
+
return recentLogs.list({ level, q: q.get('q'), boton: q.get('boton'), before: num('before'), limit: num('limit') });
|
|
374
|
+
});
|
|
375
|
+
r.on(
|
|
376
|
+
'GET',
|
|
377
|
+
'/api/info',
|
|
378
|
+
(): ServerInfo => ({ version: VERSION, install: installType(cfg.container), build: cfg.build, hostname: cfg.hostname, urls: cfg.urls, publicUrl: cfg.publicUrl, hooksUrl: cfg.hooks.publicUrl, turnTimeoutMs: cfg.turnTimeoutMs }),
|
|
379
|
+
);
|
|
380
|
+
r.on('GET', '/api/settings', () => store.getSettings());
|
|
381
|
+
r.on('PATCH', '/api/settings', async (c) => {
|
|
382
|
+
const input = await c.body<UpdateSettingsInput>();
|
|
383
|
+
const hadToken = store.getSettings().ntfy.hasToken;
|
|
384
|
+
const settings = store.updateSettings(input);
|
|
385
|
+
engine.notifier.syncConfig();
|
|
386
|
+
// Where pushes go (and with what token) decides who reads them.
|
|
387
|
+
if (input.ntfy && (input.ntfy.server !== undefined || input.ntfy.topic !== undefined || input.ntfy.token !== undefined || input.ntfy.enabled !== undefined)) {
|
|
388
|
+
audit.record(c.principal, {
|
|
389
|
+
action: 'settings.notifications_changed',
|
|
390
|
+
details: { enabled: settings.ntfy.enabled, server: settings.ntfy.server, credentialChanged: input.ntfy.token !== undefined || hadToken !== settings.ntfy.hasToken },
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return settings;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// First-run setup and the access mode: paired devices only (the default), never a Boton.
|
|
397
|
+
r.on('GET', '/api/setup', (c) => setup.status(auth.remoteAddress(c.req), c.req.headers));
|
|
398
|
+
r.on('PATCH', '/api/setup', async (c) => {
|
|
399
|
+
const { changed } = setup.update(await c.body<UpdateSetupInput>(), auth.remoteAddress(c.req), c.req.headers);
|
|
400
|
+
if (changed.accessMode) audit.record(c.principal, { action: 'access_mode.changed', details: { from: changed.accessMode.from, to: changed.accessMode.to } });
|
|
401
|
+
if (changed.completed) audit.record(c.principal, { action: 'setup.completed', details: { accessMode: setup.accessMode, botons: setup.status().botons } });
|
|
402
|
+
return setup.status(auth.remoteAddress(c.req), c.req.headers);
|
|
403
|
+
});
|
|
404
|
+
r.on('GET', '/api/audit', (c) => {
|
|
405
|
+
const before = Number(c.url.searchParams.get('before') ?? 0) || undefined;
|
|
406
|
+
return audit.list({ before, limit: Number(c.url.searchParams.get('limit') ?? 100) });
|
|
407
|
+
});
|
|
408
|
+
// A test push reaches a phone, so one at a time and at most TEST_PUSH_LIMIT a minute, and each is audited (never the server, topic or token).
|
|
409
|
+
let testInFlight = false;
|
|
410
|
+
const testStarts: number[] = [];
|
|
411
|
+
r.on('POST', '/api/notify/test', async (c): Promise<NotifyDelivery> => {
|
|
412
|
+
if (!store.ntfyConfig()) throw new HttpError(400, 'Set the ntfy server and topic first');
|
|
413
|
+
if (testInFlight) throw new HttpError(409, 'A test push is already being sent');
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
while (testStarts.length && now - testStarts[0] >= TEST_PUSH_WINDOW_MS) testStarts.shift();
|
|
416
|
+
if (testStarts.length >= TEST_PUSH_LIMIT) throw new HttpError(429, `At most ${TEST_PUSH_LIMIT} test pushes a minute; try again shortly`);
|
|
417
|
+
testStarts.push(now);
|
|
418
|
+
testInFlight = true;
|
|
419
|
+
try {
|
|
420
|
+
const sent = await engine.notifier.test();
|
|
421
|
+
if (!sent) throw new HttpError(400, 'Set the ntfy server and topic first');
|
|
422
|
+
const { delivery, problem } = sent;
|
|
423
|
+
audit.record(c.principal, { action: 'notifications.tested', details: { ok: delivery.ok, status: delivery.status, problem } });
|
|
424
|
+
return delivery;
|
|
425
|
+
} finally {
|
|
426
|
+
testInFlight = false;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
r.on('POST', '/api/pause', () => engine.pauseAll(true));
|
|
430
|
+
r.on('POST', '/api/resume', () => engine.pauseAll(false));
|
|
431
|
+
r.on('GET', '/api/usage', (c) => {
|
|
432
|
+
const q = c.url.searchParams;
|
|
433
|
+
const group = q.get('group') ?? 'bot';
|
|
434
|
+
if (group !== 'bot' && group !== 'routine') throw new HttpError(400, 'group must be "bot" or "routine"');
|
|
435
|
+
const botId = q.get('bot') || undefined;
|
|
436
|
+
if (botId) store.getBot(botId);
|
|
437
|
+
return store.usage({ since: usageSince(q.get('since')), group, botId });
|
|
438
|
+
});
|
|
439
|
+
// `recheck`: the owner's "check again", limited per provider (ProviderRegistry.ownerRecheck).
|
|
440
|
+
const brainLogin = deps.brainLogin;
|
|
441
|
+
const pairedOwner = (c: Ctx) => {
|
|
442
|
+
if (c.principal?.kind !== 'device' || !c.principal.deviceId) throw new HttpError(403, 'Pair this device before signing in.');
|
|
443
|
+
};
|
|
444
|
+
r.on('GET', '/api/brain-clis', (c) => { pairedOwner(c); return { checks: deps.releases.view(), installs: ['codex-cli', 'claude-cli'].map(id => deps.cliInstaller.review(id as 'codex-cli' | 'claude-cli')), installing: deps.cliInstaller.busy(), botonsVersion: botonsReleaseVersion(fileURLToPath(new URL('../../', import.meta.url))) }; });
|
|
445
|
+
r.on('POST', '/api/brain-clis/:provider/install', async (c) => {
|
|
446
|
+
pairedOwner(c);
|
|
447
|
+
const id = c.params.provider;
|
|
448
|
+
if (id !== 'codex-cli' && id !== 'claude-cli') throw new HttpError(400, 'Choose a supported CLI.');
|
|
449
|
+
const input = await c.body<{ consent?: unknown; command?: unknown }>();
|
|
450
|
+
if (input?.consent !== true || typeof input.command !== 'string') throw new HttpError(400, 'Review the exact command and explicitly confirm it first.');
|
|
451
|
+
if (brainLogin.view().state === 'waiting') throw new HttpError(409, 'Cancel sign-in before updating a CLI.');
|
|
452
|
+
try {
|
|
453
|
+
await deps.cliInstaller.install(id, input.command, true, (phase, review) => (phase === 'started' ? audit.require.bind(audit) : audit.record.bind(audit))(c.principal, {
|
|
454
|
+
action: `brain.cli_install_${phase}`, details: {provider:id, commandHash:createHash('sha256').update(review.command).digest('hex'), package:id === 'codex-cli' ? '@openai/codex@latest' : '@anthropic-ai/claude-code@latest', command:review.command, bin:review.bin},
|
|
455
|
+
}));
|
|
456
|
+
} catch (e) { throw new HttpError(409, (e as Error).message); }
|
|
457
|
+
return { installed: true };
|
|
458
|
+
});
|
|
459
|
+
r.on('GET', '/api/brain-homes', (c) => { pairedOwner(c); return { codex: cliHome('codex').pending, claude: cliHome('claude').pending }; });
|
|
460
|
+
r.on('POST', '/api/brain-homes/codex/import', async (c) => {
|
|
461
|
+
pairedOwner(c);
|
|
462
|
+
const input = await c.body<{ consent?: unknown }>();
|
|
463
|
+
if (input?.consent !== true) throw new HttpError(400, 'Confirm using your existing sign-in first.');
|
|
464
|
+
if (brainLogin.view().state === 'waiting') throw new HttpError(409, 'Cancel sign-in before copying an existing sign-in.');
|
|
465
|
+
try {
|
|
466
|
+
importCodexSignIn(phase => audit.require(c.principal, { action: `brain.signin_import_${phase}`, details: { provider: 'codex-cli' } }));
|
|
467
|
+
} catch { throw new HttpError(409, 'Could not copy the sign-in. Sign in separately or check again.'); }
|
|
468
|
+
providers.ownerRecheck();
|
|
469
|
+
return { imported: true };
|
|
470
|
+
});
|
|
471
|
+
r.on('GET', '/api/brain-config-repairs', (c) => { pairedOwner(c); return previewCodexRepairs(); });
|
|
472
|
+
r.on('POST', '/api/brain-config-repairs', async (c) => {
|
|
473
|
+
pairedOwner(c);
|
|
474
|
+
const input = await c.body<{ consent?: unknown; id?: unknown; token?: unknown }>();
|
|
475
|
+
if (!input || input.consent !== true || typeof input.id !== 'string' || typeof input.token !== 'string') throw new HttpError(400, 'Review the repair and explicitly confirm it first.');
|
|
476
|
+
try {
|
|
477
|
+
return repairCodexConfig(input.id, input.token, (phase, repair) => audit.require(c.principal, {
|
|
478
|
+
action: `brain.config_repair_${phase}`,
|
|
479
|
+
details: { path: repair.path, target: repair.target, backup: repair.backup },
|
|
480
|
+
}));
|
|
481
|
+
} catch (error) { throw new HttpError(409, (error as Error).message); }
|
|
482
|
+
});
|
|
483
|
+
let loginRechecked = false;
|
|
484
|
+
r.on('GET', '/api/brain-login', (c) => {
|
|
485
|
+
pairedOwner(c);
|
|
486
|
+
const view = brainLogin.view();
|
|
487
|
+
if (view.state === 'finished' && !loginRechecked) { providers.get('codex-cli')?.recheck?.(); loginRechecked = true; }
|
|
488
|
+
else if (view.state !== 'finished') loginRechecked = false;
|
|
489
|
+
return view;
|
|
490
|
+
});
|
|
491
|
+
r.on('POST', '/api/brain-login', (c) => {
|
|
492
|
+
pairedOwner(c);
|
|
493
|
+
if (deps.cliInstaller.busy()) throw new HttpError(409, 'Wait for the CLI update to finish before signing in.');
|
|
494
|
+
loginRechecked = false;
|
|
495
|
+
const result = brainLogin.start();
|
|
496
|
+
audit.record(c.principal, { action: 'brain.signin_started', details: {} });
|
|
497
|
+
return result;
|
|
498
|
+
});
|
|
499
|
+
r.on('DELETE', '/api/brain-login', (c) => {
|
|
500
|
+
pairedOwner(c);
|
|
501
|
+
const result = brainLogin.cancel();
|
|
502
|
+
audit.record(c.principal, { action: 'brain.signin_cancelled', details: {} });
|
|
503
|
+
return result;
|
|
504
|
+
});
|
|
505
|
+
r.on('GET', '/api/providers', async (c) => {
|
|
506
|
+
if (['1', 'true'].includes(c.url.searchParams.get('recheck') ?? '')) providers.ownerRecheck();
|
|
507
|
+
return (await providers.infos()).map((p) => ({ ...p, usageLimit: engine.usageLimit(p.id) }));
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Paired devices only (the default access; Boton tokens get 403): the owner's API keys. No answer, event, log
|
|
511
|
+
// line or audit row holds a key: only whether there is one, where it comes from and fixed test results.
|
|
512
|
+
r.on('GET', '/api/api-keys', (): ApiKeysView => apiKeys.view());
|
|
513
|
+
r.on('PUT', '/api/api-keys/:provider', async (c): Promise<ApiKeyState> => {
|
|
514
|
+
const provider = apiKeyProvider(c.params.provider);
|
|
515
|
+
const { state, replaced } = apiKeys.save(provider, await c.body<SaveApiKeyInput>());
|
|
516
|
+
audit.record(c.principal, { action: 'api_key.saved', target: { kind: 'api_key', id: provider, label: state.label }, details: { replaced } });
|
|
517
|
+
return state;
|
|
518
|
+
});
|
|
519
|
+
// Removes the saved key only. The answer's `source` says what is used from then on: the environment's, or none.
|
|
520
|
+
r.on('DELETE', '/api/api-keys/:provider', async (c): Promise<ApiKeyState> => {
|
|
521
|
+
const provider = apiKeyProvider(c.params.provider);
|
|
522
|
+
const state = apiKeys.remove(provider, await c.body<RemoveApiKeyInput>());
|
|
523
|
+
audit.record(c.principal, { action: 'api_key.removed', target: { kind: 'api_key', id: provider, label: state.label }, details: { nowUses: state.source } });
|
|
524
|
+
return state;
|
|
525
|
+
});
|
|
526
|
+
// Lists the key's models, read-only, from the provider's fixed address: no prompt, nothing generated.
|
|
527
|
+
r.on('POST', '/api/api-keys/:provider/test', async (c): Promise<ApiKeyTest> => {
|
|
528
|
+
const provider = apiKeyProvider(c.params.provider);
|
|
529
|
+
const test = await apiKeys.test(provider);
|
|
530
|
+
audit.record(c.principal, {
|
|
531
|
+
action: 'api_key.tested',
|
|
532
|
+
target: { kind: 'api_key', id: provider, label: apiKeys.state(provider).label },
|
|
533
|
+
details: { source: test.source, outcome: test.outcome, models: test.models.length, current: test.current },
|
|
534
|
+
});
|
|
535
|
+
return test;
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
r.on('POST', '/api/schedule/preview', async (c): Promise<SchedulePreview> => {
|
|
539
|
+
const { schedule, timezone } = await c.body<{ schedule?: string; timezone?: string | null }>();
|
|
540
|
+
if (timezone !== undefined && timezone !== null && (typeof timezone !== 'string' || !isValidTimezone(timezone))) {
|
|
541
|
+
const error = typeof timezone === 'string' && !timezone.trim() ? 'Timezone is empty' : `Unknown timezone ${timezone}`;
|
|
542
|
+
return { ok: false, error, schedule: null, text: null, nextRuns: [] };
|
|
543
|
+
}
|
|
544
|
+
const tz = timezone ?? store.getSettings().timezone;
|
|
545
|
+
try {
|
|
546
|
+
const spec = toSpec(parseSchedule(String(schedule ?? ''), tz));
|
|
547
|
+
return {
|
|
548
|
+
ok: true,
|
|
549
|
+
error: null,
|
|
550
|
+
schedule: spec,
|
|
551
|
+
text: describeSchedule(spec, tz),
|
|
552
|
+
nextRuns: nextRuns(spec, tz, 3).map((d) => d.toISOString()),
|
|
553
|
+
};
|
|
554
|
+
} catch (e) {
|
|
555
|
+
return { ok: false, error: (e as Error).message, schedule: null, text: null, nextRuns: [] };
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
r.on('GET', '/api/connectors', (): ConnectorCatalog => connectors.catalog());
|
|
560
|
+
// The owner's test: lists the connector's tools from this server, calling none. Botons can't (no route access).
|
|
561
|
+
r.on('POST', '/api/connectors/:id/test', async (c): Promise<ConnectorTest> => {
|
|
562
|
+
const test = await connectors.test(c.params.id);
|
|
563
|
+
audit.record(c.principal, {
|
|
564
|
+
action: 'connector.tested',
|
|
565
|
+
target: { kind: 'connector', id: c.params.id, label: c.params.id },
|
|
566
|
+
details: { outcome: test.outcome, tools: test.toolCount },
|
|
567
|
+
});
|
|
568
|
+
return test;
|
|
569
|
+
});
|
|
570
|
+
r.on('GET', '/api/mcp-servers', () => connectors.listServers());
|
|
571
|
+
r.on('POST', '/api/mcp-servers', async (c) => {
|
|
572
|
+
const server = connectors.createServer(await c.body<McpServerInput>());
|
|
573
|
+
audit.record(c.principal, { action: 'mcp_server.created', target: { kind: 'mcp_server', id: server.name, label: server.name }, details: { transport: server.transport } });
|
|
574
|
+
c.json(201, server);
|
|
575
|
+
});
|
|
576
|
+
r.on('GET', '/api/mcp-servers/:name', (c) => connectors.getServer(c.params.name));
|
|
577
|
+
r.on('PATCH', '/api/mcp-servers/:name', async (c) => {
|
|
578
|
+
const server = connectors.updateServer(c.params.name, await c.body<McpServerInput>());
|
|
579
|
+
audit.record(c.principal, { action: 'mcp_server.changed', target: { kind: 'mcp_server', id: c.params.name, label: c.params.name } });
|
|
580
|
+
return server;
|
|
581
|
+
});
|
|
582
|
+
r.on('DELETE', '/api/mcp-servers/:name', (c) => {
|
|
583
|
+
connectors.deleteServer(c.params.name);
|
|
584
|
+
audit.record(c.principal, { action: 'mcp_server.deleted', target: { kind: 'mcp_server', id: c.params.name, label: c.params.name } });
|
|
585
|
+
return { ok: true };
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Exactly which Botons have one connector, may ask for it or can't use it, and its history; set from the connector.
|
|
589
|
+
const accessDeps = { connectors, store, audit, running: (id: string) => engine.turnsWith(id) };
|
|
590
|
+
r.on('GET', '/api/connectors/:id/access', (c): ConnectorAccess => connectorAccess(accessDeps, c.params.id));
|
|
591
|
+
r.on('PUT', '/api/connectors/:id/access', async (c): Promise<ConnectorAccess> =>
|
|
592
|
+
setConnectorAccess(accessDeps, c.params.id, await c.body<ConnectorAccessInput>(), c.principal),
|
|
593
|
+
);
|
|
594
|
+
// Off or on for every Boton at once; setup, sign-in and grants are kept either way. Owner only, like the rest here.
|
|
595
|
+
r.on('PUT', '/api/connectors/:id/enabled', async (c): Promise<ConnectorAccess> =>
|
|
596
|
+
setConnectorEnabled(accessDeps, c.params.id, await c.body<ConnectorEnabledInput>(), c.principal),
|
|
597
|
+
);
|
|
598
|
+
r.on('PATCH', '/api/connectors/:id/policy', async (c) => {
|
|
599
|
+
const policy = await c.body<ConnectorPolicyInput>();
|
|
600
|
+
const out = connectors.setPolicy(c.params.id, policy);
|
|
601
|
+
audit.record(c.principal, { action: 'connector.policy_changed', target: { kind: 'connector', id: c.params.id, label: c.params.id }, details: { onRequest: policy.onRequest, grant: policy.grant } });
|
|
602
|
+
return out;
|
|
603
|
+
});
|
|
604
|
+
r.on('GET', '/api/oauth-connections', () => ({ connections: oauth.list(), redirectUri: oauth.redirectUri }));
|
|
605
|
+
r.on('POST', '/api/oauth-connections', async (c) => {
|
|
606
|
+
const conn = oauth.create(await c.body<OAuthConnectionInput>());
|
|
607
|
+
audit.record(c.principal, { action: 'oauth.created', target: { kind: 'oauth', id: conn.id, label: conn.name }, details: { server: urlOrigin(conn.url) } });
|
|
608
|
+
c.json(201, conn);
|
|
609
|
+
});
|
|
610
|
+
r.on('GET', '/api/oauth-connections/:id', (c) => oauth.get(c.params.id));
|
|
611
|
+
r.on('PATCH', '/api/oauth-connections/:id', async (c) => oauth.update(c.params.id, await c.body<OAuthConnectionInput>()));
|
|
612
|
+
r.on('DELETE', '/api/oauth-connections/:id', (c) => {
|
|
613
|
+
const name = oauth.get(c.params.id).name;
|
|
614
|
+
oauth.delete(c.params.id);
|
|
615
|
+
connectors.forgetTest(`oauth:${name}`);
|
|
616
|
+
audit.record(c.principal, { action: 'oauth.deleted', target: { kind: 'oauth', id: c.params.id, label: name } });
|
|
617
|
+
return { ok: true };
|
|
618
|
+
});
|
|
619
|
+
// Devices only (the default): the sign-in is bound to the device that starts it.
|
|
620
|
+
r.on('POST', '/api/oauth-connections/:id/connect', async (c) => {
|
|
621
|
+
const { returnTo } = await c.body<{ returnTo?: unknown }>();
|
|
622
|
+
const started = await oauth.startConnect(c.params.id, c.principal?.kind === 'device' ? c.principal.deviceId : null, returnTo);
|
|
623
|
+
audit.record(c.principal, { action: 'oauth.signin_started', target: { kind: 'oauth', id: c.params.id, label: oauth.get(c.params.id).name } });
|
|
624
|
+
return started;
|
|
625
|
+
});
|
|
626
|
+
r.on('POST', '/api/oauth-connections/:id/check', async (c) => {
|
|
627
|
+
oauth.get(c.params.id);
|
|
628
|
+
await engine.workFeed.detect(c.params.id);
|
|
629
|
+
return oauth.get(c.params.id);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
r.on('GET', '/api/auth/status', (c) => auth.status(c.req), open);
|
|
633
|
+
r.on(
|
|
634
|
+
'POST',
|
|
635
|
+
'/api/auth/pair',
|
|
636
|
+
async (c) => {
|
|
637
|
+
const input = await c.body<PairInput>();
|
|
638
|
+
const { device, token, method, claim } = await auth.pair(c.req, input);
|
|
639
|
+
// The new device is the one acting: the one whose request the owner approved, or the owner's own tailnet device.
|
|
640
|
+
audit.record(
|
|
641
|
+
{ kind: 'device', id: device.id, label: device.label },
|
|
642
|
+
{ action: 'device.paired', target: { kind: 'device', id: device.id, label: device.label }, details: { method, claim, network: classifyAddress(auth.remoteAddress(c.req)) } },
|
|
643
|
+
);
|
|
644
|
+
c.json(201, { device, token } satisfies PairResult);
|
|
645
|
+
},
|
|
646
|
+
open,
|
|
647
|
+
);
|
|
648
|
+
// Asking grants nothing: the request pairs only once the owner approves its code (below, or `npm run pair -- --approve`).
|
|
649
|
+
r.on(
|
|
650
|
+
'POST',
|
|
651
|
+
'/api/auth/pair-requests',
|
|
652
|
+
async (c) => {
|
|
653
|
+
const { label } = await c.body<{ label?: unknown }>();
|
|
654
|
+
c.json(201, auth.requestPairing(c.req, label) satisfies PairingRequest);
|
|
655
|
+
},
|
|
656
|
+
open,
|
|
657
|
+
); r.on('POST', '/api/auth/logout', (c) => {
|
|
658
|
+
const id = c.principal?.kind === 'device' ? c.principal.deviceId : null;
|
|
659
|
+
if (id) {
|
|
660
|
+
const actor = audit.actorOf(c.principal);
|
|
661
|
+
if (auth.revokeDevice(id)) audit.record(actor, { action: 'device.revoked', target: { kind: 'device', id, label: actor.label }, details: { signedOut: true } });
|
|
662
|
+
}
|
|
663
|
+
return { ok: true };
|
|
664
|
+
});
|
|
665
|
+
r.on('GET', '/api/devices', (c) => auth.devices(c.principal?.kind === 'device' ? c.principal.deviceId : null));
|
|
666
|
+
r.on('DELETE', '/api/devices/:id', (c) => {
|
|
667
|
+
const label = auth.devices(null).find((d) => d.id === c.params.id)?.label ?? c.params.id;
|
|
668
|
+
const actor = audit.actorOf(c.principal);
|
|
669
|
+
if (!auth.revokeDevice(c.params.id)) throw new HttpError(404, `No device with id ${c.params.id}`);
|
|
670
|
+
audit.record(actor, { action: 'device.revoked', target: { kind: 'device', id: c.params.id, label } });
|
|
671
|
+
return { ok: true };
|
|
672
|
+
});
|
|
673
|
+
// Paired devices only (the default): never a Boton, and never a caller without a device token.
|
|
674
|
+
r.on('POST', '/api/pairing-requests/approve', async (c) => {
|
|
675
|
+
const input = await c.body<ApprovePairingInput>();
|
|
676
|
+
const approved = auth.approvePairing(input.code, input.label);
|
|
677
|
+
audit.record(c.principal, { action: 'pairing_request.approved', details: { label: approved.label, requestedAt: approved.createdAt } });
|
|
678
|
+
return approved satisfies ApprovedPairing;
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
r.on('GET', '/api/bots', () => store.listBots());
|
|
682
|
+
r.on('POST', '/api/bots', async (c) => {
|
|
683
|
+
const input = await c.body<NewBotInput>();
|
|
684
|
+
// A template brings its connectors and skills unless the request has its own, less the ones this server no longer has.
|
|
685
|
+
const template = input.templateId !== undefined ? templates.find(input.templateId) : null;
|
|
686
|
+
if (template?.needs) {
|
|
687
|
+
// A starter is installed only exactly as reviewed: its connectors are the owner's slot choices, its skills its own.
|
|
688
|
+
if (input.connections !== undefined || input.skills !== undefined) throw new HttpError(400, `${template.name}'s connectors are chosen per slot (starterConnectors) and its skills are its own`);
|
|
689
|
+
if (typeof input.reviewKey !== 'string') throw new HttpError(400, `Review what ${template.name} grants first (POST /api/templates/${template.id}/review)`);
|
|
690
|
+
const provider = input.provider ?? (await providers.defaultProvider());
|
|
691
|
+
// From here to the install nothing awaits, so the connectors can't change between this check and the grant.
|
|
692
|
+
const review = reviewStarter(template, { provider, access: input.access ?? 'workspace', timezone: input.timezone, connectors: input.starterConnectors }, reviewDeps());
|
|
693
|
+
// Stale first: a connector turned off or removed since the review changes the key, and the owner reviews again.
|
|
694
|
+
if (input.reviewKey !== review.reviewKey) throw new HttpError(409, `What installing ${template.name} grants changed since you reviewed it: review it again`);
|
|
695
|
+
if (review.blocked.length) throw new HttpError(400, review.blocked[0]);
|
|
696
|
+
input.provider = provider;
|
|
697
|
+
input.connections = starterConnections(template, input.starterConnectors, drivesBrowser(providers.get(provider)));
|
|
698
|
+
input.skills = template.bot.skills;
|
|
699
|
+
} else {
|
|
700
|
+
// A new Boton gets its own browser for any public site unless the request names its connectors (NEW_BOTON_SITES), or
|
|
701
|
+
// its brain can't run one (an API key brain): then a template's browser is left off too.
|
|
702
|
+
const known = new Set(template && input.connections === undefined ? connectors.catalog().connectors.map((x) => x.id) : []);
|
|
703
|
+
const brain = providers.get(input.provider ?? (await providers.defaultProvider()));
|
|
704
|
+
input.connections = newBotonConnections(input.connections, (template?.bot.connections ?? []).filter((id) => known.has(id)), drivesBrowser(brain));
|
|
705
|
+
}
|
|
706
|
+
if (template && input.skills === undefined) input.skills = template.bot.skills && template.bot.skills.filter((name) => skills.find(name));
|
|
707
|
+
if (input.connections !== undefined) input.connections = connectors.check(input.connections);
|
|
708
|
+
if (input.skills !== undefined) input.skills = skills.checkAllowlist(input.skills, null);
|
|
709
|
+
const defaultProvider = await providers.defaultProvider();
|
|
710
|
+
const bot = store.atomically(() => {
|
|
711
|
+
const created = input.templateId !== undefined
|
|
712
|
+
? templates.createBot(input, providers.ids(), defaultProvider)
|
|
713
|
+
: store.createBot(input, providers.ids(), defaultProvider);
|
|
714
|
+
if (input.dailyCapUsd !== undefined) spending.update(created.id, { dailyCapUsd: input.dailyCapUsd });
|
|
715
|
+
if (created.connections.includes(BROWSER_CONNECTOR)) browser.importSites(created.id, NEW_BOTON_SITES, created.access);
|
|
716
|
+
audit.record(c.principal, { action: 'boton.created', target: { kind: 'boton', id: created.id, label: created.name }, details: grantsOf(created) });
|
|
717
|
+
return created;
|
|
718
|
+
});
|
|
719
|
+
c.json(201, bot);
|
|
720
|
+
});
|
|
721
|
+
r.on('POST', '/api/bots/:id/duplicate', async (c) => {
|
|
722
|
+
const bot = store.duplicateBot(c.params.id, await c.body<DuplicateBotInput>());
|
|
723
|
+
audit.record(c.principal, { action: 'boton.created', target: { kind: 'boton', id: bot.id, label: bot.name }, details: { ...grantsOf(bot), copyOf: c.params.id } });
|
|
724
|
+
c.json(201, bot);
|
|
725
|
+
});
|
|
726
|
+
r.on('GET', '/api/bots/:id', (c) => store.publicBot(store.getBot(c.params.id)));
|
|
727
|
+
r.on('GET', '/api/bots/:id/capabilities', (c) => {
|
|
728
|
+
const bot = store.getBot(c.params.id);
|
|
729
|
+
const provider = providers.get(bot.provider);
|
|
730
|
+
const brain = { label: provider?.label ?? bot.provider, askFirst: !!provider?.askFirst, serverTools: !!provider?.serverTools };
|
|
731
|
+
return botonCapabilities(store.publicBot(bot), connectors.forBoton(bot), store.listRoutines(bot.id), store.getSettings().ownerName, brain, {
|
|
732
|
+
// A Boton given the browser on a server that can't run one has none: its turns go without it (connectors.ts).
|
|
733
|
+
enabled: browserUsable(browser.enabled(bot.id), browser.known),
|
|
734
|
+
sites: browser.sites(bot.id),
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
// Only the owner: what the server saved when a Boton was installed from a starter (null when it wasn't).
|
|
738
|
+
r.on('GET', '/api/bots/:id/starter', (c): StarterInstall | null => {
|
|
739
|
+
const bot = store.getBot(c.params.id);
|
|
740
|
+
const rec = store.starterRecord(bot.id);
|
|
741
|
+
return rec && { botId: bot.id, templateId: rec.templateId, templateName: rec.templateName, slots: rec.slots, installedAt: rec.installedAt, pending: rec.pending };
|
|
742
|
+
});
|
|
743
|
+
// Only the owner: a starter's setup check on a Boton installed from it, from saved state only (it runs nothing).
|
|
744
|
+
// The starter and the slots are the ones the server saved at the install, never the request's.
|
|
745
|
+
r.on('POST', '/api/bots/:id/setup-check', (c): SetupCheck => {
|
|
746
|
+
const bot = store.publicBot(store.getBot(c.params.id));
|
|
747
|
+
const rec = store.starterRecord(bot.id);
|
|
748
|
+
if (!rec) throw new HttpError(400, `${bot.name} was not installed from a starter`);
|
|
749
|
+
const template = templates.find(rec.templateId);
|
|
750
|
+
if (!template?.needs) throw new HttpError(400, `${bot.name}'s starter (${rec.templateName}) is not on this server any more`);
|
|
751
|
+
return setupCheck(template, bot, rec.slots, {
|
|
752
|
+
catalog: connectors.catalog().connectors,
|
|
753
|
+
providers: providers.snapshots(),
|
|
754
|
+
routines: store.listRoutines(bot.id),
|
|
755
|
+
runs: (id) => store.listRuns(id),
|
|
756
|
+
gate: (id) => starterGate.verdict(id, rec),
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
// The owner's notices about a Boton's connectors: removed ones (with a Botons connector to offer) and what its last turn went without.
|
|
760
|
+
r.on('GET', '/api/bots/:id/connector-notices', (c): ConnectorNotice[] => connectors.notices(store.getBot(c.params.id)));
|
|
761
|
+
r.on('DELETE', '/api/bots/:id/connector-notices/:connectorId', (c) => {
|
|
762
|
+
const bot = store.getBot(c.params.id);
|
|
763
|
+
if (!connectors.dismissNotice(bot.id, c.params.connectorId)) throw new HttpError(404, `No notice about ${c.params.connectorId} for ${bot.name}`);
|
|
764
|
+
return { ok: true };
|
|
765
|
+
});
|
|
766
|
+
r.on('PATCH', '/api/bots/:id', async (c) => {
|
|
767
|
+
const before = store.getBot(c.params.id);
|
|
768
|
+
const { switchConfirmation, switchSummary, ...input } = (await c.body<UpdateBotRequest>()) ?? {};
|
|
769
|
+
const bot = await engine.updateBot(c.params.id, input, providers.ids(), switchConfirmation, switchSummary);
|
|
770
|
+
const target = { kind: 'boton', id: bot.id, label: bot.name };
|
|
771
|
+
if (before.access !== bot.access) audit.record(c.principal, { action: 'boton.access_changed', target, details: { from: before.access, to: bot.access } });
|
|
772
|
+
const changes = grantChanges(before, bot);
|
|
773
|
+
if (Object.keys(changes).length) audit.record(c.principal, { action: 'boton.permissions_changed', target, details: changes });
|
|
774
|
+
// Its browser connector taken away, its access or brain changed: an open browser closes, or loses what it may no longer reach.
|
|
775
|
+
await browser.refresh(bot.id);
|
|
776
|
+
return bot;
|
|
777
|
+
});
|
|
778
|
+
// What moving a Boton to another provider keeps and loses, and the confirmation the update then needs. Only for
|
|
779
|
+
// paired devices: a Boton can't move itself, nor see this.
|
|
780
|
+
r.on('POST', '/api/bots/:id/switch-preview', async (c): Promise<ProviderSwitchPreview> => {
|
|
781
|
+
const input = (await c.body<ProviderSwitchInput & { connections?: unknown }>()) ?? ({} as ProviderSwitchInput);
|
|
782
|
+
const bot = store.getBot(c.params.id);
|
|
783
|
+
const connections = input.connections === undefined ? undefined : connectors.check(input.connections, bot.connections);
|
|
784
|
+
return engine.switchPreview(bot.id, input, connections);
|
|
785
|
+
});
|
|
786
|
+
// Paired devices only: a Boton can't see or raise its own caps.
|
|
787
|
+
r.on('GET', '/api/bots/:id/spending', (c): SpendingView => spending.view(store.getBot(c.params.id).id));
|
|
788
|
+
r.on('PUT', '/api/bots/:id/spending', async (c): Promise<SpendingView> => {
|
|
789
|
+
const bot = store.getBot(c.params.id);
|
|
790
|
+
const input = (await c.body<SpendingInput>()) ?? {};
|
|
791
|
+
const before = spending.view(bot.id);
|
|
792
|
+
const after = spending.update(bot.id, input);
|
|
793
|
+
// Each cap that changed, as "from → to" in dollars ("none" for no cap).
|
|
794
|
+
const cap = (v: number | null) => (v === null ? 'none' : `$${v}`);
|
|
795
|
+
const details: Record<string, string> = {};
|
|
796
|
+
if (before.dailyCapUsd !== after.dailyCapUsd) details.daily = `${cap(before.dailyCapUsd)} → ${cap(after.dailyCapUsd)}`;
|
|
797
|
+
for (const x of after.routines) {
|
|
798
|
+
const was = before.routines.find((b) => b.id === x.id)?.perRunCapUsd ?? null;
|
|
799
|
+
if (was !== x.perRunCapUsd) details[`routine:${x.id}`] = `${cap(was)} → ${cap(x.perRunCapUsd)}`;
|
|
800
|
+
}
|
|
801
|
+
if (Object.keys(details).length) audit.record(c.principal, { action: 'boton.spending_changed', target: { kind: 'boton', id: bot.id, label: bot.name }, details });
|
|
802
|
+
return after;
|
|
803
|
+
});
|
|
804
|
+
r.on('DELETE', '/api/bots/:id', async (c) => {
|
|
805
|
+
const bot = store.getBot(c.params.id);
|
|
806
|
+
engine.stop(c.params.id);
|
|
807
|
+
await browser.forget(c.params.id);
|
|
808
|
+
store.deleteBot(c.params.id);
|
|
809
|
+
spending.prune();
|
|
810
|
+
audit.record(c.principal, { action: 'boton.deleted', target: { kind: 'boton', id: bot.id, label: bot.name } });
|
|
811
|
+
return { ok: true };
|
|
812
|
+
});
|
|
813
|
+
r.on('POST', '/api/bots/:id/reset', (c) => {
|
|
814
|
+
store.resetContext(c.params.id);
|
|
815
|
+
return { ok: true };
|
|
816
|
+
});
|
|
817
|
+
r.on('POST', '/api/bots/:id/read', async (c) => {
|
|
818
|
+
const { upTo } = await c.body<{ upTo?: unknown }>();
|
|
819
|
+
if (upTo !== undefined && typeof upTo !== 'string') throw new HttpError(400, 'upTo must be a message id');
|
|
820
|
+
return store.markRead(c.params.id, upTo);
|
|
821
|
+
});
|
|
822
|
+
r.on('POST', '/api/bots/:id/stop', async (c): Promise<StopResult> => {
|
|
823
|
+
const clearQueue = flag((await c.body<StopInput>()).clearQueue, 'clearQueue', false);
|
|
824
|
+
store.getBot(c.params.id);
|
|
825
|
+
return engine.stop(c.params.id, { clearQueue });
|
|
826
|
+
});
|
|
827
|
+
r.on('POST', '/api/bots/:id/cancel-runs', (c) => engine.cancelQueuedRuns(c.params.id));
|
|
828
|
+
r.on('POST', '/api/bots/:id/pause', (c) => engine.pauseBot(c.params.id, true));
|
|
829
|
+
r.on('POST', '/api/bots/:id/resume', (c) => engine.pauseBot(c.params.id, false));
|
|
830
|
+
r.on(
|
|
831
|
+
'POST',
|
|
832
|
+
'/api/bots/:id/notify',
|
|
833
|
+
async (c) => {
|
|
834
|
+
const { text, priority } = await c.body<NotifyInput>();
|
|
835
|
+
c.json(201, await engine.notify(c.params.id, text, priority ?? 'default'));
|
|
836
|
+
},
|
|
837
|
+
ownBotRead,
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
// The owner's sidebar layout (paired devices only): every change answers with the whole layout and publishes it
|
|
841
|
+
// as sidebar.updated, so every device shows the same.
|
|
842
|
+
r.on('GET', '/api/sidebar', (): SidebarLayout => store.sidebarLayout());
|
|
843
|
+
r.on('POST', '/api/sidebar/groups', async (c) => {
|
|
844
|
+
const { id, layout } = store.createSidebarGroup((await c.body<CreateSidebarGroupInput>()) ?? {});
|
|
845
|
+
c.json(201, { groupId: id, layout } satisfies CreatedSidebarGroup);
|
|
846
|
+
});
|
|
847
|
+
r.on('PATCH', '/api/sidebar/groups/:id', async (c): Promise<SidebarLayout> => store.updateSidebarGroup(c.params.id, (await c.body<UpdateSidebarGroupInput>()) ?? {}));
|
|
848
|
+
r.on('DELETE', '/api/sidebar/groups/:id', (c): SidebarLayout => store.deleteSidebarGroup(c.params.id));
|
|
849
|
+
r.on('POST', '/api/sidebar/groups/:id/move', async (c): Promise<SidebarLayout> => store.moveSidebarGroup(c.params.id, (await c.body<MoveSidebarGroupInput>()) ?? {}));
|
|
850
|
+
r.on('POST', '/api/sidebar/bots/:id/move', async (c): Promise<SidebarLayout> => store.moveSidebarBot(c.params.id, (await c.body<MoveSidebarBotInput>()) ?? ({} as MoveSidebarBotInput)));
|
|
851
|
+
r.on('POST', '/api/sidebar/bots/:id/pin', async (c): Promise<SidebarLayout> => store.pinSidebarBot(c.params.id, (await c.body<PinSidebarBotInput>()) ?? ({} as PinSidebarBotInput)));
|
|
852
|
+
r.on('GET', '/api/sidebar/export', (): SidebarTransfer => store.exportSidebar());
|
|
853
|
+
r.on('POST', '/api/sidebar/import', async (c): Promise<SidebarImportResult> => store.importSidebar(await c.body<unknown>()));
|
|
854
|
+
r.on('PATCH', '/api/sidebar/sections/:key', async (c): Promise<SidebarLayout> => store.setSidebarSection(c.params.key, (await c.body<UpdateSidebarSectionInput>()) ?? ({} as UpdateSidebarSectionInput)));
|
|
855
|
+
|
|
856
|
+
r.on('GET', '/api/templates', () => templates.list());
|
|
857
|
+
// Only the owner: what installing a starter would grant, with which of their connectors could fill each slot.
|
|
858
|
+
r.on('POST', '/api/templates/:id/review', async (c): Promise<TemplateReview> => {
|
|
859
|
+
const template = templates.get(c.params.id);
|
|
860
|
+
const input = await c.body<TemplateReviewInput>();
|
|
861
|
+
return reviewStarter(template, input, reviewDeps());
|
|
862
|
+
});
|
|
863
|
+
r.on('POST', '/api/templates', async (c) => c.json(201, templates.save(await c.body<SaveTemplateInput>())));
|
|
864
|
+
r.on('DELETE', '/api/templates/:id', (c) => {
|
|
865
|
+
templates.delete(c.params.id);
|
|
866
|
+
return { ok: true };
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// The owner's configuration as a file of its own, without secrets, and back (portable.ts). Paired devices only.
|
|
870
|
+
r.on('GET', '/api/config/export', (c): PortableExport => {
|
|
871
|
+
const out = portable.export();
|
|
872
|
+
audit.record(c.principal, { action: 'config.exported', details: { botons: out.file.botons.length, skills: out.file.skills.length, connectors: out.file.connectors.length } });
|
|
873
|
+
return out;
|
|
874
|
+
});
|
|
875
|
+
r.on('POST', '/api/config/import/preview', async (c): Promise<PortableImportPreview> => portable.preview(await c.body<PortableImportInput>({ strictUtf8: true })), {}, { maxBody: PORTABLE_MAX_BYTES });
|
|
876
|
+
r.on(
|
|
877
|
+
'POST',
|
|
878
|
+
'/api/config/import',
|
|
879
|
+
async (c): Promise<PortableImportResult> => {
|
|
880
|
+
// Recorded in the import's own transaction: no import is kept without its audit.
|
|
881
|
+
const result = await portable.import(await c.body<PortableImportInput>({ strictUtf8: true }), (result, made) => {
|
|
882
|
+
// Each Boton's spending caps are part of what it was made with, so they are in its own record.
|
|
883
|
+
const cap = (v: number | null) => (v === null ? 'none' : `$${v}`);
|
|
884
|
+
const capsOf = new Map(result.botons.map((m) => [m.botId, m.caps]));
|
|
885
|
+
for (const bot of made) {
|
|
886
|
+
const caps = capsOf.get(bot.id);
|
|
887
|
+
const spending = caps ? { dailyCap: cap(caps.dailyCapUsd), routineCaps: caps.routines.map((r) => `${r.id} ${r.name}: ${cap(r.perRunCapUsd)} a run`) } : {};
|
|
888
|
+
audit.require(c.principal, { action: 'boton.created', target: { kind: 'boton', id: bot.id, label: bot.name }, details: { ...grantsOf(bot), ...spending, via: 'config import' } });
|
|
889
|
+
}
|
|
890
|
+
audit.require(c.principal, {
|
|
891
|
+
action: 'config.imported',
|
|
892
|
+
details: {
|
|
893
|
+
botons: result.botons.length,
|
|
894
|
+
skills: result.skills,
|
|
895
|
+
connectors: result.connectors,
|
|
896
|
+
templates: result.templates.length,
|
|
897
|
+
settings: result.settings,
|
|
898
|
+
sidebar: result.sidebar,
|
|
899
|
+
spendingCaps: result.botons.filter((m) => m.caps.dailyCapUsd !== null || m.caps.routines.length).length,
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
if (result.settings) engine.notifier.syncConfig();
|
|
904
|
+
return result;
|
|
905
|
+
},
|
|
906
|
+
{},
|
|
907
|
+
{ maxBody: PORTABLE_MAX_BYTES },
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
r.on('GET', '/api/bots/:id/team', (c) => store.team(c.params.id), ownBotRead);
|
|
911
|
+
// The token says who asks; :id is who is asked. Not for a sandboxed turn: it would carry a webhook's payload to another Boton.
|
|
912
|
+
r.on(
|
|
913
|
+
'POST',
|
|
914
|
+
'/api/bots/:id/asks',
|
|
915
|
+
async (c) => {
|
|
916
|
+
if (c.principal?.kind !== 'boton') {
|
|
917
|
+
throw new HttpError(400, 'Only a Boton asks another Boton (with `botons ask`, during its turn). To message a Boton yourself, use POST /api/bots/:id/messages.');
|
|
918
|
+
}
|
|
919
|
+
const { text } = await c.body<AskInput>();
|
|
920
|
+
c.json(202, engine.ask(c.principal.botId, c.params.id, text));
|
|
921
|
+
},
|
|
922
|
+
{ anyBoton: true },
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
// A sandboxed turn may ask too: only the owner's Allow lets its follow-up run outside the sandbox.
|
|
926
|
+
r.on(
|
|
927
|
+
'POST',
|
|
928
|
+
'/api/bots/:id/approvals',
|
|
929
|
+
async (c) => {
|
|
930
|
+
if (c.principal?.kind !== 'boton') {
|
|
931
|
+
throw new HttpError(400, 'Only a Boton asks for approval (with `botons approval request`, during its turn). Decide on requests with POST /api/approvals/:id/decide.');
|
|
932
|
+
}
|
|
933
|
+
const input = await c.body<ApprovalRequestInput>();
|
|
934
|
+
c.json(201, approvals.request(c.params.id, input, { sandboxed: c.principal.sandboxed }));
|
|
935
|
+
},
|
|
936
|
+
{ ...ownBot, sandboxed: true },
|
|
937
|
+
);
|
|
938
|
+
r.on(
|
|
939
|
+
'POST',
|
|
940
|
+
'/api/bots/:id/approvals/tool-call',
|
|
941
|
+
async (c) => {
|
|
942
|
+
if (c.principal?.kind !== 'boton') throw new HttpError(400, "Only a Boton's CLI reports a tool call its Ask first rules paused (through `botons mcp`, during its turn).");
|
|
943
|
+
return approvals.toolCall(c.params.id, await c.body<ToolCallInput>(), { sandboxed: c.principal.sandboxed });
|
|
944
|
+
},
|
|
945
|
+
{ ...ownBot, sandboxed: true },
|
|
946
|
+
);
|
|
947
|
+
// A Boton's view of connectors (ids and names only), and its requests for one; a sandboxed turn's always need a card.
|
|
948
|
+
r.on('GET', '/api/bots/:id/connectors', (c) => connectors.forBoton(store.getBot(c.params.id)), ownBotRead);
|
|
949
|
+
r.on(
|
|
950
|
+
'POST',
|
|
951
|
+
'/api/bots/:id/connector-requests',
|
|
952
|
+
async (c) => {
|
|
953
|
+
if (c.principal?.kind !== 'boton') throw new HttpError(400, 'Only a Boton asks for a connector (with `botons connector request`). Give one to a Boton in its settings.');
|
|
954
|
+
const { connector } = await c.body<{ connector?: unknown }>();
|
|
955
|
+
const result = approvals.requestConnector(c.params.id, connector, { sandboxed: c.principal.sandboxed });
|
|
956
|
+
// No card and no owner decision: the policy granted it, so the Boton that asked is the actor on record.
|
|
957
|
+
if (result.outcome === 'granted') {
|
|
958
|
+
audit.record(c.principal, {
|
|
959
|
+
action: 'connector.granted',
|
|
960
|
+
target: { kind: 'boton', id: c.params.id, label: store.getBot(c.params.id).name },
|
|
961
|
+
details: { connector: result.connector.id, connectorName: result.connector.name, grant: 'immediate' },
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
c.json(201, result);
|
|
965
|
+
},
|
|
966
|
+
{ ...ownBot, sandboxed: true },
|
|
967
|
+
);
|
|
968
|
+
r.on('GET', '/api/approvals/:id/tool-call', (c) => approvals.toolVerdict(c.params.id, c.url.searchParams.get('waiter')), ownApproval);
|
|
969
|
+
// Transcript cards (cards.ts): a Boton posts them (a webhook run's too, marked so, never a connector card); only
|
|
970
|
+
// the owner's paired devices answer them, and what an answer does is the server's, from the card as saved.
|
|
971
|
+
r.on(
|
|
972
|
+
'POST',
|
|
973
|
+
'/api/bots/:id/cards',
|
|
974
|
+
async (c) => {
|
|
975
|
+
if (c.principal?.kind !== 'boton') throw new HttpError(400, 'Only a Boton posts cards (with `botons card post`, during its turn). Answer one with POST /api/bots/:id/cards/:card/answer.');
|
|
976
|
+
c.json(201, await cards.post(c.params.id, await c.body<unknown>(), { sandboxed: c.principal.sandboxed }));
|
|
977
|
+
},
|
|
978
|
+
{ ...ownBot, sandboxed: true },
|
|
979
|
+
);
|
|
980
|
+
r.on('GET', '/api/cards/:id', (c) => cards.get(c.params.id), { boton: (p) => cards.owner(p.id), sandboxed: true });
|
|
981
|
+
r.on('POST', '/api/bots/:id/cards/:card/answer', async (c) => {
|
|
982
|
+
if (c.principal?.kind !== 'device') throw new HttpError(403, `Only ${store.getSettings().ownerName}'s paired devices can answer cards`);
|
|
983
|
+
return cards.answer(c.params.id, c.params.card, await c.body<unknown>(), c.principal);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
// The managed browser (browser.ts), which a Boton has as the built-in browser connector. Its sites, journal and
|
|
988
|
+
// kill switch are the owner's; a Boton only acts, from the running turn that was given the browser (a webhook
|
|
989
|
+
// run gets no connectors, and its token 403 here), and its own browser only.
|
|
990
|
+
const browserTarget = (id: string) => ({ kind: 'boton', id, label: store.getBot(id).name });
|
|
991
|
+
const deviceOf = (c: Ctx) => (c.principal?.kind === 'device' ? c.principal.deviceId : null);
|
|
992
|
+
r.on('GET', '/api/bots/:id/browser', (c) => browser.status(c.params.id, deviceOf(c)));
|
|
993
|
+
r.on('PUT', '/api/bots/:id/browser', async (c) => {
|
|
994
|
+
const before = browser.policy(c.params.id);
|
|
995
|
+
const status = await browser.setPolicy(c.params.id, await c.body<BrowserPolicyInput>());
|
|
996
|
+
const added = (a: string[], b: string[]) => b.filter((x) => !a.includes(x));
|
|
997
|
+
const details = { sitesAdded: added(before.sites, status.policy.sites), sitesRemoved: added(status.policy.sites, before.sites) };
|
|
998
|
+
if (details.sitesAdded.length || details.sitesRemoved.length) audit.record(c.principal, { action: 'browser.sites_changed', target: browserTarget(c.params.id), details });
|
|
999
|
+
return status;
|
|
1000
|
+
});
|
|
1001
|
+
// The kill switch: the browser connector comes off the Boton and its browser closes at once, mid-step if need be.
|
|
1002
|
+
r.on('POST', '/api/bots/:id/browser/stop', async (c) => {
|
|
1003
|
+
const before = store.getBot(c.params.id);
|
|
1004
|
+
if (before.connections.includes(BROWSER_CONNECTOR)) {
|
|
1005
|
+
const bot = await engine.updateBot(c.params.id, { connections: before.connections.filter((x) => x !== BROWSER_CONNECTOR) }, providers.ids());
|
|
1006
|
+
const changes = grantChanges(before, bot);
|
|
1007
|
+
if (Object.keys(changes).length) audit.record(c.principal, { action: 'boton.permissions_changed', target: browserTarget(bot.id), details: changes });
|
|
1008
|
+
}
|
|
1009
|
+
await browser.refresh(c.params.id, 'Stopped by the owner: the browser connector is off and the browser closed.', 'stopped', c.principal);
|
|
1010
|
+
audit.record(c.principal, { action: 'browser.stopped', target: browserTarget(c.params.id) });
|
|
1011
|
+
return browser.status(c.params.id, deviceOf(c));
|
|
1012
|
+
});
|
|
1013
|
+
// The owner's handover (browser-handover.ts): paired devices only, never a Boton's token (the default access). One
|
|
1014
|
+
// device takes the browser over and alone gets its frames and sends it input; any device can end it.
|
|
1015
|
+
r.on('POST', '/api/bots/:id/browser/handover', async (c) => {
|
|
1016
|
+
// Optional: the view's size, so the page fits it from the first frame.
|
|
1017
|
+
const start = (await c.body().catch(() => ({}))) as BrowserHandoverStart | null;
|
|
1018
|
+
c.json(201, await browser.takeOver(c.params.id, c.principal, start && typeof start === 'object' ? start : null));
|
|
1019
|
+
});
|
|
1020
|
+
r.on('GET', '/api/bots/:id/browser/handover/frame', async (c) => {
|
|
1021
|
+
const jpeg = await browser.frame(c.params.id, deviceOf(c));
|
|
1022
|
+
c.res.writeHead(200, { 'content-type': 'image/jpeg', 'content-length': String(jpeg.length), 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
1023
|
+
c.res.end(jpeg);
|
|
1024
|
+
});
|
|
1025
|
+
// The live view (browser.ts stream): length-prefixed chunks over one long response, for the driving device only (the
|
|
1026
|
+
// device token in its Authorization header, as every call). Each chunk: a kind byte (LIVE_FRAME, LIVE_NOTICE), its
|
|
1027
|
+
// length, then a frame (its CSS size and the JPEG) or a notice (JSON). Kept nowhere.
|
|
1028
|
+
r.on('GET', '/api/bots/:id/browser/handover/stream', async (c) => {
|
|
1029
|
+
const { req, res } = c;
|
|
1030
|
+
let open = true;
|
|
1031
|
+
// Resolves once the chunk has gone, or the response is gone: a frame never waits on a closed connection.
|
|
1032
|
+
const write = (chunk: Uint8Array) =>
|
|
1033
|
+
new Promise<void>((resolve) => {
|
|
1034
|
+
if (!open || res.writableEnded || res.destroyed) return resolve();
|
|
1035
|
+
if (res.write(chunk)) return resolve();
|
|
1036
|
+
const done = () => {
|
|
1037
|
+
res.off('drain', done);
|
|
1038
|
+
res.off('close', done);
|
|
1039
|
+
res.off('error', done);
|
|
1040
|
+
resolve();
|
|
1041
|
+
};
|
|
1042
|
+
res.on('drain', done);
|
|
1043
|
+
res.on('close', done);
|
|
1044
|
+
res.on('error', done);
|
|
1045
|
+
});
|
|
1046
|
+
const sink = {
|
|
1047
|
+
frame: (jpeg: Buffer, width: number, height: number) => write(liveFrame(jpeg, width, height)),
|
|
1048
|
+
notice: (n: HandoverNotice) => void write(liveNotice(n)),
|
|
1049
|
+
end: () => {
|
|
1050
|
+
open = false;
|
|
1051
|
+
if (!res.writableEnded) res.end();
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream', 'cache-control': 'no-store', 'x-accel-buffering': 'no', 'x-content-type-options': 'nosniff' });
|
|
1055
|
+
let stop: (() => void) | null = null;
|
|
1056
|
+
const closed = () => {
|
|
1057
|
+
open = false;
|
|
1058
|
+
stop?.();
|
|
1059
|
+
};
|
|
1060
|
+
req.on('close', closed);
|
|
1061
|
+
res.on('close', closed);
|
|
1062
|
+
try {
|
|
1063
|
+
stop = await browser.stream(c.params.id, deviceOf(c), sink);
|
|
1064
|
+
if (!open) stop();
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
// Only a handover that has ended (or isn't this device's) is said to have ended; any other failure just ends
|
|
1067
|
+
// this stream, and the device falls back to frames.
|
|
1068
|
+
if (err instanceof HttpError && (err.status === 409 || err.status === 403)) await write(liveNotice({ type: 'ended' }));
|
|
1069
|
+
sink.end();
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
r.on('POST', '/api/bots/:id/browser/handover/input', async (c) => browser.ownerInput(c.params.id, deviceOf(c), await c.body()));
|
|
1073
|
+
r.on('POST', '/api/bots/:id/browser/handover/finish', async (c) => {
|
|
1074
|
+
const { handover } = await c.body<{ handover?: unknown }>();
|
|
1075
|
+
await browser.giveBack(c.params.id, typeof handover === 'string' ? handover : null, 'finished', c.principal);
|
|
1076
|
+
return browser.status(c.params.id, deviceOf(c));
|
|
1077
|
+
});
|
|
1078
|
+
r.on('GET', '/api/bots/:id/browser/journal', (c) => browser.journal(c.params.id));
|
|
1079
|
+
r.on(
|
|
1080
|
+
'POST',
|
|
1081
|
+
'/api/bots/:id/browser/actions',
|
|
1082
|
+
async (c) => {
|
|
1083
|
+
if (c.principal?.kind !== 'boton') throw new HttpError(400, "Only a Boton's turn drives its browser (through its browser tools).");
|
|
1084
|
+
return browser.act(c.params.id, c.principal.tokenId, await c.body<BrowserActionInput>());
|
|
1085
|
+
},
|
|
1086
|
+
ownBot,
|
|
1087
|
+
);
|
|
1088
|
+
r.on('GET', '/api/bots/:id/approvals', (c) => approvals.list({ botId: c.params.id, status: c.url.searchParams.get('status') ?? 'all' }), ownBotRead);
|
|
1089
|
+
r.on('GET', '/api/approvals', (c) => approvals.list({ status: c.url.searchParams.get('status'), botId: c.url.searchParams.get('bot') || undefined }));
|
|
1090
|
+
r.on('GET', '/api/approvals/:id', (c) => approvals.get(c.params.id), ownApproval);
|
|
1091
|
+
// Devices only, so no Boton can approve its own request or another's.
|
|
1092
|
+
r.on('POST', '/api/approvals/:id/decide', async (c) => {
|
|
1093
|
+
if (c.principal?.kind !== 'device') throw new HttpError(403, `Only ${store.getSettings().ownerName}'s paired devices can decide on approval requests`);
|
|
1094
|
+
const input = await c.body<ApprovalDecisionInput>();
|
|
1095
|
+
const decided = approvals.decide(c.params.id, input, c.principal.deviceId);
|
|
1096
|
+
audit.record(c.principal, {
|
|
1097
|
+
action: 'approval.decided',
|
|
1098
|
+
target: { kind: 'approval', id: decided.id },
|
|
1099
|
+
details: { boton: decided.botId, kind: decided.kind, status: decided.status, always: decided.always, connector: decided.connectorId ?? undefined },
|
|
1100
|
+
});
|
|
1101
|
+
return decided;
|
|
1102
|
+
});
|
|
1103
|
+
r.on('POST', '/api/approvals/:id/cancel', (c) => approvals.cancel(c.params.id, { sandboxed: c.principal?.kind === 'boton' && c.principal.sandboxed }), ownApproval);
|
|
1104
|
+
|
|
1105
|
+
r.on('GET', '/api/bots/:id/messages', (c) =>
|
|
1106
|
+
store.listMessages(c.params.id, {
|
|
1107
|
+
before: c.url.searchParams.get('before') ?? undefined,
|
|
1108
|
+
after: c.url.searchParams.get('after') ?? undefined,
|
|
1109
|
+
limit: Number(c.url.searchParams.get('limit') ?? 100),
|
|
1110
|
+
}),
|
|
1111
|
+
);
|
|
1112
|
+
r.on('GET', '/api/bots/:id/background', (c) => store.backgroundActivity(c.params.id));
|
|
1113
|
+
// Devices only: steps can hold secrets.
|
|
1114
|
+
r.on('GET', '/api/messages/:id/steps', (c) => {
|
|
1115
|
+
const steps = store.messageSteps(c.params.id);
|
|
1116
|
+
if (!steps) throw new HttpError(404, `No turn ended with message ${c.params.id}`);
|
|
1117
|
+
return steps;
|
|
1118
|
+
});
|
|
1119
|
+
r.on('POST', '/api/bots/:id/messages', async (c) => {
|
|
1120
|
+
const { text, attachments } = await c.body<SendMessageInput>();
|
|
1121
|
+
if (text !== undefined && typeof text !== 'string') throw new HttpError(400, 'text must be a string');
|
|
1122
|
+
const files = await attachmentsOf(store.getBot(c.params.id).workspace, attachments);
|
|
1123
|
+
c.json(202, engine.sendMessage(c.params.id, text ?? '', files));
|
|
1124
|
+
});
|
|
1125
|
+
// Paired devices only (the default access): a pin decides what every new session of the Boton is given, so no
|
|
1126
|
+
// Boton token, its own included, can set one, and no message text can claim one.
|
|
1127
|
+
r.on('POST', '/api/bots/:id/messages/:messageId/pin', async (c) => {
|
|
1128
|
+
const input = await c.body<PinInput>();
|
|
1129
|
+
if (typeof input?.pinned !== 'boolean') throw new HttpError(400, 'pinned must be true or false');
|
|
1130
|
+
store.getBot(c.params.id);
|
|
1131
|
+
return store.setMessagePinned(c.params.id, c.params.messageId, input.pinned);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Reactions (reactions.ts): a paired device reacts as the owner, to the Boton's messages; the Boton's own token as
|
|
1135
|
+
// the Boton, to the owner's own messages in its chat, never in a sandboxed turn (which must not change what its
|
|
1136
|
+
// later turns know). Feedback only: no approval, card or permission reads them.
|
|
1137
|
+
const ownReaction: RouteAccess = { boton: (p) => p.id };
|
|
1138
|
+
r.on('POST', '/api/bots/:id/messages/:messageId/reactions', async (c) => reactions.react(c.params.id, c.params.messageId, (await c.body<ReactInput>())?.emoji, c.principal), ownReaction);
|
|
1139
|
+
r.on('DELETE', '/api/bots/:id/messages/:messageId/reactions/:emoji', (c) => reactions.unreact(c.params.id, c.params.messageId, c.params.emoji, c.principal), ownReaction);
|
|
1140
|
+
// A Boton's only: it has one at most. The owner names the emoji to take off.
|
|
1141
|
+
r.on(
|
|
1142
|
+
'DELETE',
|
|
1143
|
+
'/api/bots/:id/messages/:messageId/reactions',
|
|
1144
|
+
(c) => {
|
|
1145
|
+
if (c.principal?.kind !== 'boton') throw new HttpError(400, 'Name the reaction to take off: DELETE /api/bots/:id/messages/:messageId/reactions/:emoji');
|
|
1146
|
+
return reactions.unreact(c.params.id, c.params.messageId, null, c.principal);
|
|
1147
|
+
},
|
|
1148
|
+
ownReaction,
|
|
1149
|
+
);
|
|
1150
|
+
// Paired devices only (the default access).
|
|
1151
|
+
r.on('GET', '/api/reactions/export', (): ReactionTransfer => reactions.export());
|
|
1152
|
+
r.on('POST', '/api/reactions/import', async (c): Promise<ReactionImportResult> => reactions.import(await c.body<unknown>()));
|
|
1153
|
+
|
|
1154
|
+
// Devices only: a Boton reaches only `share`, which never returns a file.
|
|
1155
|
+
r.on('GET', '/api/bots/:id/files', async (c) => {
|
|
1156
|
+
const listing = await listFolder(store.getBot(c.params.id).workspace, c.url.searchParams.get('path') ?? '');
|
|
1157
|
+
if (listing.path !== 'inbox') return listing;
|
|
1158
|
+
const pins = new Set(store.inboxPins(c.params.id));
|
|
1159
|
+
return { ...listing, entries: listing.entries.map((e) => ({ ...e, pinned: pins.has(e.name) })) };
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
// Deleting, the trash and the inbox (trash.ts). Paired devices only (the default access): no Boton token, its own
|
|
1163
|
+
// included, reaches these; a Boton deletes its own files with its own tools, in its workspace, as before.
|
|
1164
|
+
r.on('POST', '/api/bots/:id/files/delete', async (c): Promise<TrashResult> => trash.delete(c.params.id, (await c.body<DeleteFilesInput>())?.paths, audit.actorOf(c.principal)));
|
|
1165
|
+
r.on('POST', '/api/bots/:id/inbox/clear', (c): Promise<TrashResult> => trash.clearInbox(c.params.id, audit.actorOf(c.principal)));
|
|
1166
|
+
r.on('POST', '/api/bots/:id/inbox/pins', async (c) => {
|
|
1167
|
+
const input = await c.body<InboxPinInput>();
|
|
1168
|
+
return trash.setPinned(c.params.id, input?.path, input?.pinned);
|
|
1169
|
+
});
|
|
1170
|
+
r.on('GET', '/api/bots/:id/trash', (c): Promise<TrashItem[]> => {
|
|
1171
|
+
store.getBot(c.params.id);
|
|
1172
|
+
return trash.list({ botId: c.params.id });
|
|
1173
|
+
});
|
|
1174
|
+
r.on('POST', '/api/bots/:id/trash/empty', (c) => {
|
|
1175
|
+
store.getBot(c.params.id);
|
|
1176
|
+
return trash.empty({ botId: c.params.id }, audit.actorOf(c.principal));
|
|
1177
|
+
});
|
|
1178
|
+
r.on('GET', '/api/trash', (): Promise<TrashItem[]> => trash.list());
|
|
1179
|
+
r.on('POST', '/api/trash/:itemId/restore', (c): Promise<RestoreResult> => trash.restore(c.params.itemId, audit.actorOf(c.principal)));
|
|
1180
|
+
r.on('DELETE', '/api/trash/:itemId', (c) => trash.purge(c.params.itemId, audit.actorOf(c.principal)));
|
|
1181
|
+
r.on('GET', '/api/bots/:id/storage', async (c) => {
|
|
1182
|
+
const { partial, ...mine } = await trash.botStorage(store.getBot(c.params.id));
|
|
1183
|
+
const shared = await trash.sharedStorage();
|
|
1184
|
+
return { ...mine, sharedBytes: shared.sharedBytes, sharedTrashItems: shared.sharedTrashItems, sharedTrashBytes: shared.sharedTrashBytes, partial: partial || shared.partial };
|
|
1185
|
+
});
|
|
1186
|
+
r.on('GET', '/api/storage', (): Promise<StorageUsage> => trash.usage());
|
|
1187
|
+
|
|
1188
|
+
// The shared folder, for the owner: listed, downloaded (never shown inline: any Boton can write it), deleted to the
|
|
1189
|
+
// trash and restored. Paired devices only (the default access); its own folder can't be deleted.
|
|
1190
|
+
r.on('GET', '/api/shared/files', (c) => listFolder(cfg.sharedDir, c.url.searchParams.get('path') ?? ''));
|
|
1191
|
+
r.on('GET', '/api/shared/files/raw', (c) => sendFile(c.req, c.res, cfg.sharedDir, c.url.searchParams.get('path') ?? '', 'download'), { urlKey: 'files' });
|
|
1192
|
+
r.on('POST', '/api/shared/files/delete', async (c): Promise<TrashResult> => trash.deleteShared((await c.body<DeleteFilesInput>())?.paths, audit.actorOf(c.principal)));
|
|
1193
|
+
r.on('GET', '/api/shared/trash', (): Promise<TrashItem[]> => trash.list({ origin: 'shared' }));
|
|
1194
|
+
r.on('POST', '/api/shared/trash/empty', (c) => trash.empty({ origin: 'shared' }, audit.actorOf(c.principal)));
|
|
1195
|
+
r.on(
|
|
1196
|
+
'GET',
|
|
1197
|
+
'/api/bots/:id/files/raw',
|
|
1198
|
+
async (c) => {
|
|
1199
|
+
const download = ['1', 'true'].includes(c.url.searchParams.get('download') ?? '');
|
|
1200
|
+
await sendFile(c.req, c.res, store.getBot(c.params.id).workspace, c.url.searchParams.get('path') ?? '', download ? 'download' : 'raw');
|
|
1201
|
+
},
|
|
1202
|
+
{ urlKey: 'files' },
|
|
1203
|
+
);
|
|
1204
|
+
// An HTML file's sandboxed preview page (see PREVIEW_CSP). Its script can read the page's URL, so the
|
|
1205
|
+
// URL holds a key for this one file, never the device's files key.
|
|
1206
|
+
r.on('POST', '/api/bots/:id/files/preview', async (c) => {
|
|
1207
|
+
const { path } = await c.body<FilePreviewInput>();
|
|
1208
|
+
const bot = store.getBot(c.params.id);
|
|
1209
|
+
const found = await resolveInWorkspace(bot.workspace, path);
|
|
1210
|
+
if (!found.stat.isFile()) throw new HttpError(400, 'That is not a file');
|
|
1211
|
+
if (fileKind(found.rel) !== 'html') throw new HttpError(400, 'Only HTML files have a preview page');
|
|
1212
|
+
// Its size now, not the listing's: the page itself refuses one this big.
|
|
1213
|
+
if (found.stat.size > PREVIEW_MAX_BYTES) throw new HttpError(413, `Too big to preview (over ${formatSize(PREVIEW_MAX_BYTES)}); download it instead`);
|
|
1214
|
+
const deviceId = c.principal?.kind === 'device' ? c.principal.deviceId : null;
|
|
1215
|
+
const signed = auth.previewKey(deviceId, bot.id, found.rel);
|
|
1216
|
+
if (!signed && auth.required) throw new HttpError(403, 'Only a paired device can open a preview');
|
|
1217
|
+
const url = `/api/bots/${encodeURIComponent(bot.id)}/files/preview?path=${encodeURIComponent(found.rel)}${signed ? `&key=${encodeURIComponent(signed.key)}` : ''}`;
|
|
1218
|
+
return { url, expiresAt: signed ? new Date(signed.expiresAt).toISOString() : null, size: found.stat.size } satisfies FilePreviewLink;
|
|
1219
|
+
});
|
|
1220
|
+
r.on(
|
|
1221
|
+
'GET',
|
|
1222
|
+
'/api/bots/:id/files/preview',
|
|
1223
|
+
async (c) => {
|
|
1224
|
+
const path = c.url.searchParams.get('path') ?? '';
|
|
1225
|
+
try {
|
|
1226
|
+
// The key before anything else, so nobody without one learns which Botons or files exist.
|
|
1227
|
+
if (!auth.previewAllowed(c.url.searchParams.get('key'), c.params.id, path)) {
|
|
1228
|
+
throw new HttpError(401, 'This preview link is not valid, or has expired. Open the file again in the app.');
|
|
1229
|
+
}
|
|
1230
|
+
await sendFile(c.req, c.res, store.getBot(c.params.id).workspace, path, 'preview');
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
// The frame (or tab) shows a page that says why, and tells the viewer, rather than raw JSON.
|
|
1233
|
+
if (err instanceof HttpError && !c.res.headersSent) return sendPreviewError(c.res, err);
|
|
1234
|
+
throw err;
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
open,
|
|
1238
|
+
);
|
|
1239
|
+
r.on('POST', '/api/bots/:id/uploads', async (c) => {
|
|
1240
|
+
const bot = store.getBot(c.params.id);
|
|
1241
|
+
const file = await receiveUpload(c.req, bot.workspace, c.url.searchParams.get('name') ?? '', bot.timezone);
|
|
1242
|
+
log.info('file uploaded', { botId: bot.id, path: file.path, size: file.size });
|
|
1243
|
+
c.json(201, file);
|
|
1244
|
+
});
|
|
1245
|
+
r.on(
|
|
1246
|
+
'POST',
|
|
1247
|
+
'/api/bots/:id/share',
|
|
1248
|
+
async (c) => {
|
|
1249
|
+
const { path, title } = await c.body<ShareInput>();
|
|
1250
|
+
c.json(201, await engine.shareFile(c.params.id, path, title));
|
|
1251
|
+
},
|
|
1252
|
+
ownBotRead,
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
r.on('GET', '/api/bots/:id/routines', (c) => store.listRoutines(c.params.id).map((r) => visible(c, r)), ownBotRead);
|
|
1256
|
+
r.on(
|
|
1257
|
+
'POST',
|
|
1258
|
+
'/api/bots/:id/routines',
|
|
1259
|
+
async (c) => {
|
|
1260
|
+
const input = await c.body<CreateRoutineInput>();
|
|
1261
|
+
await ownerOnly(c, input);
|
|
1262
|
+
if (input.skill !== undefined) input.skill = skills.checkRoutineSkill(c.params.id, input.skill);
|
|
1263
|
+
const routine = store.createRoutine(c.params.id, input);
|
|
1264
|
+
auditRoutine(c, null, routine);
|
|
1265
|
+
c.json(201, routine);
|
|
1266
|
+
},
|
|
1267
|
+
ownBot,
|
|
1268
|
+
);
|
|
1269
|
+
r.on('GET', '/api/routines/:id', (c) => visible(c, store.getRoutine(c.params.id)), ownRoutineRead);
|
|
1270
|
+
r.on(
|
|
1271
|
+
'PATCH',
|
|
1272
|
+
'/api/routines/:id',
|
|
1273
|
+
async (c) => {
|
|
1274
|
+
const input = await c.body<UpdateRoutineInput>();
|
|
1275
|
+
await ownerOnly(c, input);
|
|
1276
|
+
// Only a new skill is checked: the editor sends the current one back with
|
|
1277
|
+
// every save, and one the Boton can no longer use must not block other changes.
|
|
1278
|
+
const current = store.getRoutine(c.params.id);
|
|
1279
|
+
if (input.skill !== undefined && input.skill !== current.skill) input.skill = skills.checkRoutineSkill(current.botId, input.skill);
|
|
1280
|
+
const routine = store.updateRoutine(c.params.id, input);
|
|
1281
|
+
auditRoutine(c, current, routine);
|
|
1282
|
+
return routine;
|
|
1283
|
+
},
|
|
1284
|
+
ownRoutine,
|
|
1285
|
+
);
|
|
1286
|
+
// Only the owner, never a Boton: turning on a starter's routine, after its test run or by overriding that (starter-gate.ts).
|
|
1287
|
+
r.on('POST', '/api/routines/:id/activate', async (c) => {
|
|
1288
|
+
const input = (await c.body<StarterActivationInput>()) ?? {};
|
|
1289
|
+
const override = flag(input.override, 'override', false);
|
|
1290
|
+
return store.atomically(() => {
|
|
1291
|
+
const { routine, gate } = starterGate.activate(c.params.id, override);
|
|
1292
|
+
audit.require(c.principal, {
|
|
1293
|
+
action: 'routine.starter_activated',
|
|
1294
|
+
target: { kind: 'routine', id: routine.id, label: routine.name },
|
|
1295
|
+
details: { boton: routine.botId, override: !gate.tested, testRunId: gate.runId },
|
|
1296
|
+
});
|
|
1297
|
+
return routine;
|
|
1298
|
+
});
|
|
1299
|
+
});
|
|
1300
|
+
r.on(
|
|
1301
|
+
'DELETE',
|
|
1302
|
+
'/api/routines/:id',
|
|
1303
|
+
(c) => {
|
|
1304
|
+
store.deleteRoutine(c.params.id);
|
|
1305
|
+
spending.prune();
|
|
1306
|
+
return { ok: true };
|
|
1307
|
+
},
|
|
1308
|
+
ownRoutine,
|
|
1309
|
+
);
|
|
1310
|
+
r.on(
|
|
1311
|
+
'POST',
|
|
1312
|
+
'/api/routines/:id/run',
|
|
1313
|
+
async (c) => {
|
|
1314
|
+
const input = await c.body<RunRoutineInput>();
|
|
1315
|
+
const test = flag(input.test, 'test', input.payload !== undefined);
|
|
1316
|
+
if (!test && input.payload !== undefined) throw new HttpError(400, 'A payload makes a test run; leave test out or set it to true');
|
|
1317
|
+
const payload = input.payload === undefined || input.payload === null ? null : typeof input.payload === 'string' ? input.payload : JSON.stringify(input.payload);
|
|
1318
|
+
c.json(202, engine.triggerRoutine(c.params.id, test ? 'test' : 'manual', payload?.trim() ? payload : null));
|
|
1319
|
+
},
|
|
1320
|
+
ownRoutine,
|
|
1321
|
+
);
|
|
1322
|
+
// Only the owner (the default): it spans every Boton, payload previews included.
|
|
1323
|
+
r.on('GET', '/api/runs/attention', (): RunsAttention => store.attentionRuns(new Date(Date.now() - ATTENTION_DAYS * 86_400_000).toISOString()));
|
|
1324
|
+
// Only the owner: a retry re-runs a payload from outside, maybe with Full computer access.
|
|
1325
|
+
r.on('POST', '/api/runs/:id/retry', (c) => {
|
|
1326
|
+
const run = engine.retryRun(c.params.id);
|
|
1327
|
+
const routine = store.getRoutine(run.routineId);
|
|
1328
|
+
audit.record(c.principal, {
|
|
1329
|
+
action: 'run.retried',
|
|
1330
|
+
target: { kind: 'routine', id: routine.id, label: routine.name },
|
|
1331
|
+
details: { boton: run.botId, runId: run.meta!.retryOf!.runId, retryRunId: run.id, trigger: run.trigger },
|
|
1332
|
+
});
|
|
1333
|
+
c.json(202, run);
|
|
1334
|
+
});
|
|
1335
|
+
// Only the owner: it ends a run a webhook sender or a schedule started, maybe part-way through.
|
|
1336
|
+
r.on('POST', '/api/runs/:id/cancel', async (c) => {
|
|
1337
|
+
const before = store.getRun(c.params.id);
|
|
1338
|
+
const run = await engine.cancelRun(before.id);
|
|
1339
|
+
audit.record(c.principal, {
|
|
1340
|
+
action: 'run.cancelled',
|
|
1341
|
+
target: { kind: 'routine', id: run.routineId, label: store.findRoutineName(run.routineId) ?? run.routineId },
|
|
1342
|
+
details: { boton: run.botId, runId: run.id, trigger: run.trigger, was: before.status, status: run.status, cancelled: !!run.meta?.cancelled },
|
|
1343
|
+
});
|
|
1344
|
+
return run;
|
|
1345
|
+
});
|
|
1346
|
+
// Only the owner: it sends a run's result again, to the URL only the owner sets.
|
|
1347
|
+
r.on('POST', '/api/runs/:id/callback', (c) => {
|
|
1348
|
+
const run = callbacks.resend(c.params.id);
|
|
1349
|
+
const routine = store.getRoutine(run.routineId);
|
|
1350
|
+
audit.record(c.principal, {
|
|
1351
|
+
action: 'run.callback_resent',
|
|
1352
|
+
target: { kind: 'routine', id: routine.id, label: routine.name },
|
|
1353
|
+
details: { boton: run.botId, runId: run.id, callback: urlOrigin(run.meta!.callback!.url) },
|
|
1354
|
+
});
|
|
1355
|
+
c.json(202, run);
|
|
1356
|
+
});
|
|
1357
|
+
r.on('GET', '/api/routines/:id/runs', (c) => store.listRuns(c.params.id).map((run) => visibleRun(c, run)), ownRoutineRead);
|
|
1358
|
+
r.on(
|
|
1359
|
+
'GET',
|
|
1360
|
+
'/api/runs/:id/payload',
|
|
1361
|
+
(c) => {
|
|
1362
|
+
if (c.principal?.kind === 'boton' && c.principal.sandboxed && c.params.id !== engine.turnOrigin(c.principal.botId)?.runId) {
|
|
1363
|
+
throw new HttpError(403, "This run was started by a webhook, so it can only read its own run's payload, not another run's.");
|
|
1364
|
+
}
|
|
1365
|
+
return store.runPayload(c.params.id);
|
|
1366
|
+
},
|
|
1367
|
+
ownRunRead,
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
r.on('GET', '/api/skills', () => skills.list());
|
|
1371
|
+
r.on('POST', '/api/skills', async (c) => c.json(201, skills.create(await c.body<CreateSkillInput>())));
|
|
1372
|
+
r.on('GET', '/api/skills/:name', (c) => skills.get(c.params.name));
|
|
1373
|
+
r.on('PATCH', '/api/skills/:name', async (c) => skills.update(c.params.name, await c.body<UpdateSkillInput>()));
|
|
1374
|
+
r.on('POST', '/api/skills/:name/approve', async (c) => {
|
|
1375
|
+
const out = skills.approve(c.params.name, (await c.body<{ version?: unknown }>()).version);
|
|
1376
|
+
audit.record(c.principal, { action: 'skill.approved', target: { kind: 'skill', id: c.params.name, label: c.params.name } });
|
|
1377
|
+
return out;
|
|
1378
|
+
});
|
|
1379
|
+
r.on('DELETE', '/api/skills/:name', (c) => {
|
|
1380
|
+
skills.delete(c.params.name);
|
|
1381
|
+
return { ok: true };
|
|
1382
|
+
});
|
|
1383
|
+
// A skill's Botons, earlier texts and history; the owner only (paired devices), never a Boton.
|
|
1384
|
+
r.on('GET', '/api/skills/:name/details', (c): SkillDetails => ({
|
|
1385
|
+
skill: skills.get(c.params.name),
|
|
1386
|
+
access: skills.access(c.params.name),
|
|
1387
|
+
history: skills.history(c.params.name),
|
|
1388
|
+
audit: audit.about([{ kind: 'skill', id: c.params.name }], c.params.name, 50, 'skill'),
|
|
1389
|
+
}));
|
|
1390
|
+
r.on('PUT', '/api/skills/:name/access', async (c) => {
|
|
1391
|
+
recordSkillGrants(c.principal, c.params.name, skills.setAccess(c.params.name, await c.body<SkillAccessInput>()));
|
|
1392
|
+
return skills.access(c.params.name);
|
|
1393
|
+
});
|
|
1394
|
+
r.on('PUT', '/api/skills/:name/enabled', async (c) => {
|
|
1395
|
+
const before = skills.get(c.params.name);
|
|
1396
|
+
const out = skills.setEnabled(c.params.name, (await c.body<{ enabled?: unknown }>()).enabled);
|
|
1397
|
+
if (before.disabled !== out.disabled) audit.record(c.principal, { action: out.disabled ? 'skill.disabled' : 'skill.enabled', target: { kind: 'skill', id: out.name, label: out.name } });
|
|
1398
|
+
return out;
|
|
1399
|
+
});
|
|
1400
|
+
r.on('POST', '/api/skills/:name/restore', async (c) => {
|
|
1401
|
+
const out = skills.restore(c.params.name, await c.body<{ id?: unknown; current?: unknown }>());
|
|
1402
|
+
audit.record(c.principal, { action: 'skill.restored', target: { kind: 'skill', id: out.skill.name, label: out.skill.name }, details: { from: out.from, to: out.to } });
|
|
1403
|
+
return out.skill;
|
|
1404
|
+
});
|
|
1405
|
+
// Skill packages: folders in BOTONS_HOME/skill-packages, read as text and never run. Installing takes exactly what was reviewed.
|
|
1406
|
+
r.on('GET', '/api/skill-packages', () => skills.packages());
|
|
1407
|
+
r.on('GET', '/api/skill-packages/:folder', (c) => skills.review(c.params.folder));
|
|
1408
|
+
r.on('POST', '/api/skill-packages/:folder/install', async (c) => {
|
|
1409
|
+
const out = skills.install(c.params.folder, await c.body<InstallSkillInput>());
|
|
1410
|
+
audit.record(c.principal, {
|
|
1411
|
+
action: 'skill.installed',
|
|
1412
|
+
target: { kind: 'skill', id: out.skill.name, label: out.skill.name },
|
|
1413
|
+
// A field's value is the package's text: only its name is kept.
|
|
1414
|
+
details: { package: c.params.folder, label: out.label, version: out.skill.version, from: out.from, accepted: out.accepted.map((a) => a.replace(/^(asks for [^:]+):.*$/s, '$1')) },
|
|
1415
|
+
});
|
|
1416
|
+
recordSkillGrants(c.principal, out.skill.name, out.grants);
|
|
1417
|
+
return out.skill;
|
|
1418
|
+
});
|
|
1419
|
+
r.on('GET', '/api/bots/:id/skills', (c) => skills.forBot(store.getBot(c.params.id)), ownBotRead);
|
|
1420
|
+
r.on('GET', '/api/bots/:id/skills/:name', (c) => skills.getFor(c.params.id, c.params.name), ownBotRead);
|
|
1421
|
+
r.on('POST', '/api/bots/:id/skills', async (c) => c.json(201, skills.createByBoton(c.params.id, await c.body<CreateSkillInput>())), ownBot);
|
|
1422
|
+
r.on('PATCH', '/api/bots/:id/skills/:name', async (c) => skills.updateByBoton(c.params.id, c.params.name, await c.body<UpdateSkillInput>()), ownBot);
|
|
1423
|
+
// Kept with the turns, after the run itself drops out of the history.
|
|
1424
|
+
r.on('GET', '/api/runs/:id/steps', (c) => {
|
|
1425
|
+
const steps = store.runSteps(c.params.id);
|
|
1426
|
+
if (!steps.length && !store.findRun(c.params.id)) throw new HttpError(404, `No run with id ${c.params.id}`);
|
|
1427
|
+
return steps;
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
r.on('GET', '/api/bots/:id/memories', (c) => store.listMemories(c.params.id), ownBotRead);
|
|
1431
|
+
r.on(
|
|
1432
|
+
'POST',
|
|
1433
|
+
'/api/bots/:id/memories',
|
|
1434
|
+
async (c) => {
|
|
1435
|
+
const { text } = await c.body<{ text?: string }>();
|
|
1436
|
+
c.json(201, store.addMemory(c.params.id, String(text ?? '')));
|
|
1437
|
+
},
|
|
1438
|
+
ownBot,
|
|
1439
|
+
);
|
|
1440
|
+
r.on(
|
|
1441
|
+
'DELETE',
|
|
1442
|
+
'/api/memories/:id',
|
|
1443
|
+
(c) => {
|
|
1444
|
+
store.deleteMemory(c.params.id);
|
|
1445
|
+
return { ok: true };
|
|
1446
|
+
},
|
|
1447
|
+
ownMemory,
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
r.on('GET', '/api/events', (c) => {
|
|
1451
|
+
const { req, res } = c;
|
|
1452
|
+
res.writeHead(200, {
|
|
1453
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1454
|
+
'cache-control': 'no-store',
|
|
1455
|
+
connection: 'keep-alive',
|
|
1456
|
+
'x-accel-buffering': 'no',
|
|
1457
|
+
});
|
|
1458
|
+
const send = (ev: ServerEvent) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
1459
|
+
send({ type: 'hello', serverTime: nowIso(), build: cfg.build });
|
|
1460
|
+
// Closing: the client reconnects to the next server.
|
|
1461
|
+
if (engine.closing.aborted) return void res.end();
|
|
1462
|
+
const unsubscribe = hub.subscribe(send);
|
|
1463
|
+
// A revoke made with `npm run pair -- --revoke` is noticed at the next ping.
|
|
1464
|
+
const untrack = auth.trackStream(c.principal, () => res.end());
|
|
1465
|
+
const unstream = hub.addStream(() => res.end());
|
|
1466
|
+
// A data event, not an SSE comment: EventSource hides comments, and the web app uses pings to spot a dead connection.
|
|
1467
|
+
const ping = setInterval(
|
|
1468
|
+
() => (auth.isLive(c.principal) ? send({ type: 'ping', serverTime: nowIso() }) : res.end()),
|
|
1469
|
+
cfg.ssePingMs,
|
|
1470
|
+
);
|
|
1471
|
+
const stop = () => {
|
|
1472
|
+
clearInterval(ping);
|
|
1473
|
+
unsubscribe();
|
|
1474
|
+
untrack();
|
|
1475
|
+
unstream();
|
|
1476
|
+
};
|
|
1477
|
+
req.on('close', stop);
|
|
1478
|
+
res.on('close', stop);
|
|
1479
|
+
}, { urlKey: 'events' });
|
|
1480
|
+
|
|
1481
|
+
addHookRoutes(r, { cfg, store, engine });
|
|
1482
|
+
|
|
1483
|
+
return r;
|
|
1484
|
+
}
|