@open-everywhere/provider-slack 0.1.3 → 0.1.4
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/agent/access/slack-attachment-fetch.ts +3 -1
- package/agent/app-manifest.ts +48 -10
- package/agent/channels/slack-commands.ts +5 -1
- package/agent/channels/slack-events.ts +13 -4
- package/agent/channels/slack-front.ts +21 -6
- package/agent/channels/slack-read.ts +9 -3
- package/agent/channels/slack-setup-page.ts +20 -0
- package/agent/channels/slack-web-api.ts +34 -7
- package/agent/channels/slack.ts +11 -3
- package/agent/contributions.ts +1 -0
- package/agent/docs-generated.ts +1 -1
- package/agent/ops.ts +29 -61
- package/agent/register.ts +27 -1
- package/agent/setup/claim.ts +343 -0
- package/agent/setup/credentials.ts +265 -0
- package/agent/setup/eve-credentials.ts +77 -0
- package/agent/setup/install-state.ts +248 -0
- package/agent/setup/page.ts +579 -0
- package/agent/setup/slack-api-setup.ts +176 -0
- package/agent/status.ts +81 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
- package/dist/agent/access/slack-attachment-fetch.js +3 -1
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
- package/dist/agent/app-manifest.d.ts +4 -4
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +48 -10
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +5 -1
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +1 -1
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +13 -4
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-front.d.ts +1 -1
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +20 -5
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-read.d.ts +6 -1
- package/dist/agent/channels/slack-read.d.ts.map +1 -1
- package/dist/agent/channels/slack-read.js +9 -3
- package/dist/agent/channels/slack-read.js.map +1 -1
- package/dist/agent/channels/slack-setup-page.d.ts +3 -0
- package/dist/agent/channels/slack-setup-page.d.ts.map +1 -0
- package/dist/agent/channels/slack-setup-page.js +13 -0
- package/dist/agent/channels/slack-setup-page.js.map +1 -0
- package/dist/agent/channels/slack-web-api.d.ts +3 -0
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
- package/dist/agent/channels/slack-web-api.js +26 -6
- package/dist/agent/channels/slack-web-api.js.map +1 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +11 -3
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/contributions.d.ts +5 -0
- package/dist/agent/contributions.d.ts.map +1 -1
- package/dist/agent/contributions.js +1 -0
- package/dist/agent/contributions.js.map +1 -1
- package/dist/agent/docs-generated.js +1 -1
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +29 -47
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +26 -2
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/setup/claim.d.ts +73 -0
- package/dist/agent/setup/claim.d.ts.map +1 -0
- package/dist/agent/setup/claim.js +298 -0
- package/dist/agent/setup/claim.js.map +1 -0
- package/dist/agent/setup/credentials.d.ts +56 -0
- package/dist/agent/setup/credentials.d.ts.map +1 -0
- package/dist/agent/setup/credentials.js +214 -0
- package/dist/agent/setup/credentials.js.map +1 -0
- package/dist/agent/setup/eve-credentials.d.ts +22 -0
- package/dist/agent/setup/eve-credentials.d.ts.map +1 -0
- package/dist/agent/setup/eve-credentials.js +73 -0
- package/dist/agent/setup/eve-credentials.js.map +1 -0
- package/dist/agent/setup/install-state.d.ts +139 -0
- package/dist/agent/setup/install-state.d.ts.map +1 -0
- package/dist/agent/setup/install-state.js +196 -0
- package/dist/agent/setup/install-state.js.map +1 -0
- package/dist/agent/setup/page.d.ts +29 -0
- package/dist/agent/setup/page.d.ts.map +1 -0
- package/dist/agent/setup/page.js +460 -0
- package/dist/agent/setup/page.js.map +1 -0
- package/dist/agent/setup/slack-api-setup.d.ts +62 -0
- package/dist/agent/setup/slack-api-setup.d.ts.map +1 -0
- package/dist/agent/setup/slack-api-setup.js +138 -0
- package/dist/agent/setup/slack-api-setup.js.map +1 -0
- package/dist/agent/status.d.ts +4 -0
- package/dist/agent/status.d.ts.map +1 -0
- package/dist/agent/status.js +79 -0
- package/dist/agent/status.js.map +1 -0
- package/package.json +36 -3
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
// `/setup` — THE AGENT-LED SLACK INSTALL (OPE-2231), as four screens on two routes.
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT REPLACES. Before this, standing a deployment up on Slack meant: fetch the manifest from an
|
|
4
|
+
// ops tool, paste it into api.slack.com, click through five screens, copy two secrets back out, set
|
|
5
|
+
// them as environment variables, redeploy, then hand-edit two config files. Every step moved a
|
|
6
|
+
// SECRET through a human's clipboard and a hosting dashboard. Here the secrets go browser → Slack →
|
|
7
|
+
// this deployment and stop; the operator pastes ONE short-lived token and reads back two config
|
|
8
|
+
// lines that contain no secret at all.
|
|
9
|
+
//
|
|
10
|
+
// THE ROUTES. Two, and the split is the OAuth round trip rather than taste:
|
|
11
|
+
//
|
|
12
|
+
// GET /setup render whichever screen this deployment is up to
|
|
13
|
+
// POST /setup screen 1 submits the setup code; screen 2 submits the config token
|
|
14
|
+
// GET /setup/callback Slack's install redirect lands here with `code` + `state`
|
|
15
|
+
//
|
|
16
|
+
// THE SCREENS, and what the deployment has to be true for each:
|
|
17
|
+
//
|
|
18
|
+
// 1. CLAIM no valid cookie → ask for the setup code (claim.ts)
|
|
19
|
+
// 2. CREATE cookie, no pending app → ask for the app configuration token
|
|
20
|
+
// 3. INSTALL cookie, a pending app → the one link to Slack's authorize URL
|
|
21
|
+
// 4. DONE rendered by the callback → the two config edits, filled in, and "DM the bot"
|
|
22
|
+
//
|
|
23
|
+
// 🔴 EVERY FAILURE REACHABLE WITHOUT THE CLAIM COOKIE RENDERS ONE OF TWO PAGES, and neither says
|
|
24
|
+
// which. A locked deployment, an absent credential key, a wrong setup code, a missing cookie, a
|
|
25
|
+
// replayed `state` and a Slack refusal are not distinguishable from outside — the posture
|
|
26
|
+
// `credentials/intake-page.ts` takes, for the same reason: an unauthenticated holder must not be able
|
|
27
|
+
// to probe this route for what exists. The two are a 404 (this route is not available to you) and a
|
|
28
|
+
// Slack-refusal page carrying Slack's own error CODE (the operator needs that one to act, and Slack's
|
|
29
|
+
// codes are a closed vocabulary that discloses nothing about this deployment).
|
|
30
|
+
//
|
|
31
|
+
// A CLAIMED run also sees two 500s, and the scoping above is deliberate rather than an oversight: both
|
|
32
|
+
// name a DEPLOYMENT MISCONFIGURATION this operator has to fix (no discoverable public URL; the
|
|
33
|
+
// credential store unwritable) and neither says anything about Slack or about what is stored. They are
|
|
34
|
+
// reachable only with a valid cookie, so no prober reaches them.
|
|
35
|
+
//
|
|
36
|
+
// 🔴 NO SECRET IS EVER RENDERED, RE-RENDERED OR ECHOED. The form fields are write-only: a submitted
|
|
37
|
+
// setup code and a submitted config token are compared or spent and then dropped, and a re-rendered
|
|
38
|
+
// form after a failure comes back EMPTY. Screen 4's copy-paste block is ids only. The page sends
|
|
39
|
+
// `cache-control: no-store` and `referrer-policy: no-referrer` through core's shared chrome. Screen 3
|
|
40
|
+
// carries the one OUTBOUND LINK on any page using that chrome (Slack's authorize URL), and the header
|
|
41
|
+
// is what keeps THIS page's own URL out of the `Referer` that click sends to slack.com — not, as an
|
|
42
|
+
// earlier draft of this comment said, what keeps the authorize URL itself secret; that URL goes to
|
|
43
|
+
// Slack by design.
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
BUTTON_STYLE,
|
|
47
|
+
CODE_BLOCK_STYLE,
|
|
48
|
+
appendAudit,
|
|
49
|
+
credentialKeyArmed,
|
|
50
|
+
deploymentOrg,
|
|
51
|
+
encryptedKvVault,
|
|
52
|
+
escapeHtml,
|
|
53
|
+
pageBaseUrl,
|
|
54
|
+
securePageResponse,
|
|
55
|
+
SLACK_APP_REF_NAMESPACE,
|
|
56
|
+
type EncryptedKvVault,
|
|
57
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
58
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
59
|
+
import { SLACK_DEPLOY_HOST_PLACEHOLDER, slackAppManifestTemplate, slackAppNameProblem } from "../app-manifest.js";
|
|
60
|
+
import {
|
|
61
|
+
SETUP_COOKIE_NAME,
|
|
62
|
+
mintSetupCookie,
|
|
63
|
+
setupCodeMatches,
|
|
64
|
+
setupCookieFrom,
|
|
65
|
+
setupCookieHeader,
|
|
66
|
+
slackSetupOpen,
|
|
67
|
+
verifySetupCookie,
|
|
68
|
+
type SlackClaimDeps,
|
|
69
|
+
} from "./claim.js";
|
|
70
|
+
import {
|
|
71
|
+
SLACK_BOT_TOKEN_REF,
|
|
72
|
+
SLACK_CLIENT_SECRET_REF,
|
|
73
|
+
SLACK_COOKIE_KEY_REF,
|
|
74
|
+
SLACK_PENDING_REF,
|
|
75
|
+
SLACK_PENDING_SIGNING_SECRET_REF,
|
|
76
|
+
SLACK_SETUP_CODE_REF,
|
|
77
|
+
SLACK_SIGNING_SECRET_REF,
|
|
78
|
+
recordSlackInstall,
|
|
79
|
+
type SlackInstallState,
|
|
80
|
+
} from "./install-state.js";
|
|
81
|
+
import { createSlackApp, exchangeSlackOauthCode, SlackSetupApiError } from "./slack-api-setup.js";
|
|
82
|
+
|
|
83
|
+
/** The page's route path. */
|
|
84
|
+
export const SLACK_SETUP_PATH = "/setup";
|
|
85
|
+
/** The OAuth install redirect's path. Declared in the shipped manifest's `oauth_config.redirect_urls`. */
|
|
86
|
+
export const SLACK_SETUP_CALLBACK_PATH = "/setup/callback";
|
|
87
|
+
|
|
88
|
+
/** Form field names. One letter each, because they ride a POST body and read nowhere else. */
|
|
89
|
+
const FIELD_CODE = "c";
|
|
90
|
+
const FIELD_CONFIG_TOKEN = "t";
|
|
91
|
+
const FIELD_APP_NAME = "n";
|
|
92
|
+
const FIELD_ACTION = "a";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The audit action a completed install writes.
|
|
96
|
+
*
|
|
97
|
+
* 🔴 THE EXISTING VOCABULARY, NOT A NEW NAME. `surface.app_installed` is already declared and already
|
|
98
|
+
* means "this app was installed on a surface" — the Discord bundle emits it off Slack's
|
|
99
|
+
* `APPLICATION_AUTHORIZED` equivalent. A `/setup` install is that same fact with a different
|
|
100
|
+
* provenance (this deployment did it, rather than a vendor webhook telling us), and the repo's
|
|
101
|
+
* one-word-one-meaning rule says a second spelling for one fact is how a wrong claim survives review.
|
|
102
|
+
*/
|
|
103
|
+
const SLACK_SETUP_INSTALLED = "surface.app_installed";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The app `apps.manifest.create` made, between the create and the OAuth callback.
|
|
107
|
+
*
|
|
108
|
+
* 🔴 NO CLIENT SECRET HERE. The secret went straight into its own vault ref on screen 2; this record
|
|
109
|
+
* carries only what the callback needs to FIND it, plus the `state` nonce. A pending record is the
|
|
110
|
+
* one thing on this page's path that an abandoned run leaves behind, so it is also the thing most
|
|
111
|
+
* likely to be read by a later run — keeping it secret-free means a stale one discloses nothing.
|
|
112
|
+
*/
|
|
113
|
+
interface PendingSlackApp {
|
|
114
|
+
readonly v: 1;
|
|
115
|
+
readonly appId: string;
|
|
116
|
+
readonly clientId: string;
|
|
117
|
+
/** The CSRF nonce the authorize URL carries and the callback must match. */
|
|
118
|
+
readonly state: string;
|
|
119
|
+
readonly startedAt: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Injectable seams for the suite. */
|
|
123
|
+
export interface SlackSetupPageDeps extends SlackClaimDeps {
|
|
124
|
+
readonly vault?: EncryptedKvVault;
|
|
125
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
126
|
+
readonly createApp?: typeof createSlackApp;
|
|
127
|
+
readonly exchangeCode?: typeof exchangeSlackOauthCode;
|
|
128
|
+
readonly keyArmed?: () => boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function vaultOf(deps: SlackSetupPageDeps): EncryptedKvVault {
|
|
132
|
+
return deps.vault ?? encryptedKvVault();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- the two failure renders ------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** The ONE generic refusal. Identical copy for every reason — see the header. */
|
|
138
|
+
function notAvailable(): Response {
|
|
139
|
+
return securePageResponse(
|
|
140
|
+
404,
|
|
141
|
+
"Not available",
|
|
142
|
+
`<p>This page is not available on this deployment.</p>` +
|
|
143
|
+
`<p>If Slack is already installed here, setup is closed. An administrator can reopen it with the ` +
|
|
144
|
+
`<code>reopen_slack_setup</code> control call.</p>`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Slack said no. Carries Slack's own error code and nothing of ours. */
|
|
149
|
+
function slackRefused(code: string): Response {
|
|
150
|
+
return securePageResponse(
|
|
151
|
+
400,
|
|
152
|
+
"Slack refused the request",
|
|
153
|
+
`<p>Slack answered: <code>${escapeHtml(code)}</code></p>` +
|
|
154
|
+
`<p>The most common cause is an app configuration token that has expired — they last about 12 hours. ` +
|
|
155
|
+
`Get a fresh one at <code>api.slack.com/apps</code> → "Your App Configuration Tokens", then start again.</p>` +
|
|
156
|
+
`<p><a href="${escapeHtml(SLACK_SETUP_PATH)}">Back to setup</a></p>`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- the deployment's own host --------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* This deployment's public base URL, or `undefined` when it cannot be discovered.
|
|
164
|
+
*
|
|
165
|
+
* Undiscoverable is a REFUSAL here, not a placeholder: a manifest created with the wrong host gives
|
|
166
|
+
* Slack a request URL that answers nothing, and the operator finds out when the app silently never
|
|
167
|
+
* delivers an event. Core owns the precedence (`OE_PUBLIC_BASE_URL` → `VERCEL_PROJECT_PRODUCTION_URL`
|
|
168
|
+
* → `VERCEL_URL`), so this page cannot answer a different host than the links core mints.
|
|
169
|
+
*/
|
|
170
|
+
function baseUrl(deps: SlackSetupPageDeps): string | undefined {
|
|
171
|
+
return pageBaseUrl(deps.env ?? process.env);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The manifest, with this deployment's host substituted for the shipped placeholder. */
|
|
175
|
+
function manifestForHost(host: string, appName: string | undefined): string {
|
|
176
|
+
const template = slackAppManifestTemplate(appName ? { appName } : {});
|
|
177
|
+
// The placeholder appears in four request URLs; a global replace is the substitution.
|
|
178
|
+
return template.split(SLACK_DEPLOY_HOST_PLACEHOLDER).join(host);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The bare host (no scheme) the manifest's `https://…` URLs are built from. */
|
|
182
|
+
function manifestHost(base: string): string {
|
|
183
|
+
return base.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- the four screens -----------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** Screen 1 — ask for the setup code. `retry` re-renders after a wrong code, with an EMPTY field. */
|
|
189
|
+
function claimScreen(retry: boolean): Response {
|
|
190
|
+
return securePageResponse(
|
|
191
|
+
200,
|
|
192
|
+
"Set up Open Everywhere",
|
|
193
|
+
(retry ? `<p style="color:#b91c1c">That code did not match. Check the deployment's logs and try again.</p>` : "") +
|
|
194
|
+
`<p>This deployment logged a one-time setup code when it started. Find it in the deployment's logs ` +
|
|
195
|
+
`(<code>vercel logs</code>, <code>docker logs</code>) and paste it here.</p>` +
|
|
196
|
+
`<form method="post" action="${escapeHtml(SLACK_SETUP_PATH)}">` +
|
|
197
|
+
`<input type="hidden" name="${FIELD_ACTION}" value="claim">` +
|
|
198
|
+
`<p><input name="${FIELD_CODE}" type="password" autocomplete="off" required style="width:100%;padding:.5rem;font:inherit" ` +
|
|
199
|
+
`placeholder="setup code"></p>` +
|
|
200
|
+
`<button type="submit" style="${BUTTON_STYLE}">Continue</button>` +
|
|
201
|
+
`</form>`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Screen 2 — ask for the app configuration token. */
|
|
206
|
+
function createScreen(host: string): Response {
|
|
207
|
+
return securePageResponse(
|
|
208
|
+
200,
|
|
209
|
+
"Create the Slack app",
|
|
210
|
+
`<p>Open <code>api.slack.com/apps</code>, scroll to <strong>Your App Configuration Tokens</strong>, ` +
|
|
211
|
+
`and generate one for the workspace that will host the agent. Paste it below. It needs the ` +
|
|
212
|
+
`<code>app_configurations:write</code> scope and lasts about 12 hours.</p>` +
|
|
213
|
+
`<p>Open Everywhere will create the Slack app for <code>${escapeHtml(host)}</code> and keep its ` +
|
|
214
|
+
`credentials in this deployment's own encrypted store. The configuration token is used once and never stored.</p>` +
|
|
215
|
+
`<form method="post" action="${escapeHtml(SLACK_SETUP_PATH)}">` +
|
|
216
|
+
`<input type="hidden" name="${FIELD_ACTION}" value="create">` +
|
|
217
|
+
`<p><input name="${FIELD_CONFIG_TOKEN}" type="password" autocomplete="off" required style="width:100%;padding:.5rem;font:inherit" ` +
|
|
218
|
+
`placeholder="xoxe.xoxp-… (app configuration token)"></p>` +
|
|
219
|
+
`<p><label>App name (optional)<br><input name="${FIELD_APP_NAME}" type="text" autocomplete="off" ` +
|
|
220
|
+
`style="width:100%;padding:.5rem;font:inherit" placeholder="Open Everywhere"></label></p>` +
|
|
221
|
+
`<button type="submit" style="${BUTTON_STYLE}">Create the Slack app</button>` +
|
|
222
|
+
`</form>`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Screen 3 — the one link that installs the app. */
|
|
227
|
+
function installScreen(authorizeUrl: string): Response {
|
|
228
|
+
return securePageResponse(
|
|
229
|
+
200,
|
|
230
|
+
"Install to your workspace",
|
|
231
|
+
`<p>The Slack app exists. Installing it is the step that gives Open Everywhere a bot token for ` +
|
|
232
|
+
`your workspace, and it has to be done by someone who can approve an app there.</p>` +
|
|
233
|
+
`<p><a href="${escapeHtml(authorizeUrl)}" style="${BUTTON_STYLE};display:inline-block;text-decoration:none">` +
|
|
234
|
+
`Install to Slack</a></p>` +
|
|
235
|
+
`<p>Slack will list the permissions and bring you back here.</p>`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Screen 4 — the two config edits, filled in, and the last instruction. */
|
|
240
|
+
function doneScreen(state: SlackInstallState): Response {
|
|
241
|
+
const surfaceLine = ` defineSurface({ key: "slack/${state.teamId}", trust: "internal" }),`;
|
|
242
|
+
const adminLine = ` admins: ["slack/${state.teamId}/${state.installerUserId}"],`;
|
|
243
|
+
return securePageResponse(
|
|
244
|
+
200,
|
|
245
|
+
"Slack is installed",
|
|
246
|
+
`<p>Open Everywhere is installed in <strong>${escapeHtml(state.teamName ?? state.teamId)}</strong>. ` +
|
|
247
|
+
`Its credentials are in this deployment's encrypted store; nothing else needs setting.</p>` +
|
|
248
|
+
`<p>Two config edits are left. They are not secrets — they say which workspace this deployment ` +
|
|
249
|
+
`trusts, and who administers it — so they belong in your git repo and deploy with it.</p>` +
|
|
250
|
+
`<p><strong>1.</strong> In <code>agent/identity/surfaces.ts</code>, inside the surfaces array:</p>` +
|
|
251
|
+
`<pre style="${CODE_BLOCK_STYLE}">${escapeHtml(surfaceLine)}</pre>` +
|
|
252
|
+
`<p><strong>2.</strong> In <code>agent/memory/config/domains/org.ts</code>, replace the <code>admins</code> line:</p>` +
|
|
253
|
+
`<pre style="${CODE_BLOCK_STYLE}">${escapeHtml(adminLine)}</pre>` +
|
|
254
|
+
`<p><strong>Then DM the bot in Slack.</strong> Ask it to make those two edits and it will open a ` +
|
|
255
|
+
`pull request against your config repo — the agent configuring itself. Or make them by hand and ` +
|
|
256
|
+
`push; either way the deploy that lands them is the last step.</p>` +
|
|
257
|
+
`<p>This page is now closed. An administrator can reopen it with the <code>reopen_slack_setup</code> ` +
|
|
258
|
+
`control call.</p>`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- the pending record ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
async function readPending(deps: SlackSetupPageDeps): Promise<PendingSlackApp | undefined> {
|
|
265
|
+
let raw: string | null;
|
|
266
|
+
try {
|
|
267
|
+
raw = await vaultOf(deps).read(SLACK_PENDING_REF);
|
|
268
|
+
} catch {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
if (raw === null) return undefined;
|
|
272
|
+
let parsed: unknown;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(raw);
|
|
275
|
+
} catch {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
279
|
+
const r = parsed as Record<string, unknown>;
|
|
280
|
+
if (r.v !== 1) return undefined;
|
|
281
|
+
const str = (k: string): string | undefined => (typeof r[k] === "string" && (r[k] as string).length > 0 ? (r[k] as string) : undefined);
|
|
282
|
+
const appId = str("appId");
|
|
283
|
+
const clientId = str("clientId");
|
|
284
|
+
const state = str("state");
|
|
285
|
+
const startedAt = str("startedAt");
|
|
286
|
+
if (!appId || !clientId || !state || !startedAt) return undefined;
|
|
287
|
+
return { v: 1, appId, clientId, state, startedAt };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The authorize URL for a pending app, rebuilt from the app's own client id rather than stored.
|
|
291
|
+
* Slack's `oauth_authorize_url` is a composition of public facts, so recomposing it keeps the
|
|
292
|
+
* pending record free of anything that looks like a capability. */
|
|
293
|
+
function authorizeUrlFor(pending: PendingSlackApp, base: string, scopes: string): string {
|
|
294
|
+
const u = new URL("https://slack.com/oauth/v2/authorize");
|
|
295
|
+
u.searchParams.set("client_id", pending.clientId);
|
|
296
|
+
u.searchParams.set("scope", scopes);
|
|
297
|
+
u.searchParams.set("redirect_uri", `${base}${SLACK_SETUP_CALLBACK_PATH}`);
|
|
298
|
+
u.searchParams.set("state", pending.state);
|
|
299
|
+
return u.toString();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The bot scopes the shipped manifest declares, as the comma-joined string an authorize URL wants.
|
|
303
|
+
* Read OUT of the manifest rather than listed again here: two lists would drift, and the drift is a
|
|
304
|
+
* workspace that installed with fewer scopes than the app needs. */
|
|
305
|
+
function manifestBotScopes(): string {
|
|
306
|
+
const parsed = JSON.parse(slackAppManifestTemplate()) as {
|
|
307
|
+
oauth_config?: { scopes?: { bot?: readonly string[] } };
|
|
308
|
+
};
|
|
309
|
+
const bot = parsed.oauth_config?.scopes?.bot ?? [];
|
|
310
|
+
return bot.join(",");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- the env-shadowing warning: DELETED, because the page can no longer be reached in that state ---
|
|
314
|
+
//
|
|
315
|
+
// An earlier draft warned on screen 2 that a set `SLACK_BOT_TOKEN` / `SLACK_SIGNING_SECRET` would
|
|
316
|
+
// shadow whatever the run was about to store. That warning is now unreachable, and it was the wrong
|
|
317
|
+
// remedy anyway: `slackSetupOpen` (claim.ts) CLOSES the page entirely when either variable is set,
|
|
318
|
+
// because the resolver is env-first and a run on such a deployment would write credentials nothing
|
|
319
|
+
// would ever read. A warning the operator can click past is worse than a page that refuses — it ends
|
|
320
|
+
// with a Slack app created, credentials stored, the page locked, and no behaviour change at all.
|
|
321
|
+
//
|
|
322
|
+
// The resolver still carries the warning for the case this page cannot cover: a var set AFTER a
|
|
323
|
+
// completed run, which it logs once per name (agent/setup/credentials.ts).
|
|
324
|
+
|
|
325
|
+
// --- GET /setup -----------------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/** Render whichever screen this deployment is up to. Every refusal is {@link notAvailable}. */
|
|
328
|
+
export async function handleSlackSetupGet(req: Request, deps: SlackSetupPageDeps = {}): Promise<Response> {
|
|
329
|
+
if (!(deps.keyArmed ?? credentialKeyArmed)()) return notAvailable();
|
|
330
|
+
if (!(await slackSetupOpen(deps))) return notAvailable();
|
|
331
|
+
|
|
332
|
+
if (!(await verifySetupCookie(setupCookieFrom(req), deps))) return claimScreen(false);
|
|
333
|
+
|
|
334
|
+
const base = baseUrl(deps);
|
|
335
|
+
if (base === undefined) {
|
|
336
|
+
return securePageResponse(
|
|
337
|
+
500,
|
|
338
|
+
"This deployment does not know its own URL",
|
|
339
|
+
`<p>Open Everywhere could not work out the public URL to put in the Slack app, so it will not ` +
|
|
340
|
+
`create one that points at the wrong place.</p>` +
|
|
341
|
+
`<p>Set <code>OE_PUBLIC_BASE_URL</code> to this deployment's public URL and redeploy.</p>`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const pending = await readPending(deps);
|
|
346
|
+
if (pending) return installScreen(authorizeUrlFor(pending, base, manifestBotScopes()));
|
|
347
|
+
return createScreen(manifestHost(base));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// --- POST /setup ----------------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/** Screen 1 and screen 2's submits. */
|
|
353
|
+
export async function handleSlackSetupPost(req: Request, deps: SlackSetupPageDeps = {}): Promise<Response> {
|
|
354
|
+
if (!(deps.keyArmed ?? credentialKeyArmed)()) return notAvailable();
|
|
355
|
+
if (!(await slackSetupOpen(deps))) return notAvailable();
|
|
356
|
+
|
|
357
|
+
let form: URLSearchParams;
|
|
358
|
+
try {
|
|
359
|
+
form = new URLSearchParams(await req.text());
|
|
360
|
+
} catch {
|
|
361
|
+
return notAvailable();
|
|
362
|
+
}
|
|
363
|
+
const action = form.get(FIELD_ACTION) ?? "";
|
|
364
|
+
|
|
365
|
+
if (action === "claim") {
|
|
366
|
+
const presented = form.get(FIELD_CODE) ?? "";
|
|
367
|
+
// An EMPTY submit re-renders without a verdict — a genuine mistake, and not an attempt worth
|
|
368
|
+
// a "did not match" line (the same behaviour the credential-intake form takes on an empty paste).
|
|
369
|
+
if (presented.trim().length === 0) return claimScreen(false);
|
|
370
|
+
if (!(await setupCodeMatches(presented, deps))) return claimScreen(true);
|
|
371
|
+
const cookie = await mintSetupCookie(deps);
|
|
372
|
+
// 303 so the browser re-GETs the page: the claimed run then renders from the one place that
|
|
373
|
+
// decides which screen is next, rather than this handler guessing.
|
|
374
|
+
return new Response(null, {
|
|
375
|
+
status: 303,
|
|
376
|
+
headers: { location: SLACK_SETUP_PATH, "set-cookie": setupCookieHeader(cookie), "cache-control": "no-store" },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Every other action needs the claim.
|
|
381
|
+
if (!(await verifySetupCookie(setupCookieFrom(req), deps))) return notAvailable();
|
|
382
|
+
|
|
383
|
+
if (action !== "create") return notAvailable();
|
|
384
|
+
|
|
385
|
+
const base = baseUrl(deps);
|
|
386
|
+
if (base === undefined) return notAvailable();
|
|
387
|
+
|
|
388
|
+
const configToken = (form.get(FIELD_CONFIG_TOKEN) ?? "").trim();
|
|
389
|
+
if (configToken.length === 0) return createScreen(manifestHost(base));
|
|
390
|
+
|
|
391
|
+
const rawName = (form.get(FIELD_APP_NAME) ?? "").trim();
|
|
392
|
+
if (rawName.length > 0) {
|
|
393
|
+
const problem = slackAppNameProblem(rawName);
|
|
394
|
+
// Slack's own limit, checked here so the refusal names the field rather than coming back as an
|
|
395
|
+
// `invalid_manifest` the operator has to decode.
|
|
396
|
+
if (problem) return slackRefused(`app_name: ${problem}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let created;
|
|
400
|
+
try {
|
|
401
|
+
created = await (deps.createApp ?? createSlackApp)(
|
|
402
|
+
{ configToken, manifest: manifestForHost(manifestHost(base), rawName.length > 0 ? rawName : undefined) },
|
|
403
|
+
{},
|
|
404
|
+
);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return slackRefused(err instanceof SlackSetupApiError ? err.code : "request_failed");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// STORE THE SECRETS FIRST, THEN THE POINTER. A pending record naming an app whose client secret
|
|
410
|
+
// never landed is a run that can only fail at the callback, with the operator holding a Slack app
|
|
411
|
+
// nothing can install. The vault write throws on a disarmed key, and that throw must reach the
|
|
412
|
+
// operator rather than be swallowed into a pending record.
|
|
413
|
+
const vault = vaultOf(deps);
|
|
414
|
+
try {
|
|
415
|
+
await vault.write(SLACK_CLIENT_SECRET_REF, created.clientSecret, { connector: "slack", createdBy: "host:slack-setup" });
|
|
416
|
+
// 🔴 STAGED, NOT LIVE. `SLACK_SIGNING_SECRET_REF` is what verifies every inbound Slack request, so
|
|
417
|
+
// writing it here would swap a running deployment's signing key at the moment somebody pressed
|
|
418
|
+
// "Create the Slack app" — before OAuth, before the install record. An abandoned run then left an
|
|
419
|
+
// installed deployment unable to verify its own workspace. The callback promotes this.
|
|
420
|
+
await vault.write(SLACK_PENDING_SIGNING_SECRET_REF, created.signingSecret, { connector: "slack", createdBy: "host:slack-setup" });
|
|
421
|
+
const pending: PendingSlackApp = {
|
|
422
|
+
v: 1,
|
|
423
|
+
appId: created.appId,
|
|
424
|
+
clientId: created.clientId,
|
|
425
|
+
state: randomBytes(16).toString("base64url"),
|
|
426
|
+
startedAt: new Date().toISOString(),
|
|
427
|
+
};
|
|
428
|
+
await vault.write(SLACK_PENDING_REF, JSON.stringify(pending), { connector: "slack", createdBy: "host:slack-setup" });
|
|
429
|
+
} catch {
|
|
430
|
+
return securePageResponse(
|
|
431
|
+
500,
|
|
432
|
+
"Could not store the Slack credentials",
|
|
433
|
+
`<p>The Slack app was created, but this deployment could not write its credentials to the ` +
|
|
434
|
+
`encrypted store. Check that <code>OE_CREDENTIAL_ENCRYPTION_KEY</code> is set and that the ` +
|
|
435
|
+
`database is reachable, then start again — you will need a fresh app configuration token.</p>`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return new Response(null, { status: 303, headers: { location: SLACK_SETUP_PATH, "cache-control": "no-store" } });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// --- GET /setup/callback --------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Slack's install redirect. Takes `code` + `state`, exchanges the code, records the install, and
|
|
446
|
+
* renders screen 4.
|
|
447
|
+
*
|
|
448
|
+
* This is the ONE render that happens after the lock closes, and it has to be: recording the install
|
|
449
|
+
* IS closing the page, and the operator still needs the two config lines. So the handler does its
|
|
450
|
+
* work and renders, rather than redirecting to a `/setup` that would now 404.
|
|
451
|
+
*/
|
|
452
|
+
export async function handleSlackSetupCallback(req: Request, deps: SlackSetupPageDeps = {}): Promise<Response> {
|
|
453
|
+
if (!(deps.keyArmed ?? credentialKeyArmed)()) return notAvailable();
|
|
454
|
+
if (!(await slackSetupOpen(deps))) return notAvailable();
|
|
455
|
+
if (!(await verifySetupCookie(setupCookieFrom(req), deps))) return notAvailable();
|
|
456
|
+
|
|
457
|
+
const url = new URL(req.url);
|
|
458
|
+
const code = url.searchParams.get("code") ?? "";
|
|
459
|
+
const presentedState = url.searchParams.get("state") ?? "";
|
|
460
|
+
if (code.length === 0) {
|
|
461
|
+
// Slack puts its own refusal here when the installer declined or the app was misconfigured.
|
|
462
|
+
return slackRefused(url.searchParams.get("error") ?? "no_code");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const pending = await readPending(deps);
|
|
466
|
+
if (!pending) return notAvailable();
|
|
467
|
+
|
|
468
|
+
// THE CSRF WALL. Constant-time, length-prechecked: the nonce was minted by this deployment for
|
|
469
|
+
// this run, so a callback carrying a different one is somebody else's redirect landing in this
|
|
470
|
+
// operator's claimed session.
|
|
471
|
+
const a = Buffer.from(presentedState);
|
|
472
|
+
const b = Buffer.from(pending.state);
|
|
473
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return notAvailable();
|
|
474
|
+
|
|
475
|
+
const base = baseUrl(deps);
|
|
476
|
+
if (base === undefined) return notAvailable();
|
|
477
|
+
|
|
478
|
+
const vault = vaultOf(deps);
|
|
479
|
+
let clientSecret: string | null;
|
|
480
|
+
try {
|
|
481
|
+
clientSecret = await vault.read(SLACK_CLIENT_SECRET_REF);
|
|
482
|
+
} catch {
|
|
483
|
+
return notAvailable();
|
|
484
|
+
}
|
|
485
|
+
if (clientSecret === null) return notAvailable();
|
|
486
|
+
|
|
487
|
+
let install;
|
|
488
|
+
try {
|
|
489
|
+
install = await (deps.exchangeCode ?? exchangeSlackOauthCode)(
|
|
490
|
+
{
|
|
491
|
+
clientId: pending.clientId,
|
|
492
|
+
clientSecret,
|
|
493
|
+
code,
|
|
494
|
+
// The SAME `redirect_uri` the authorize step sent — Slack compares them.
|
|
495
|
+
redirectUri: `${base}${SLACK_SETUP_CALLBACK_PATH}`,
|
|
496
|
+
},
|
|
497
|
+
{},
|
|
498
|
+
);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
return slackRefused(err instanceof SlackSetupApiError ? err.code : "request_failed");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const state: SlackInstallState = {
|
|
504
|
+
v: 1,
|
|
505
|
+
appId: pending.appId,
|
|
506
|
+
clientId: pending.clientId,
|
|
507
|
+
teamId: install.teamId,
|
|
508
|
+
installerUserId: install.installerUserId,
|
|
509
|
+
installedAt: new Date().toISOString(),
|
|
510
|
+
...(install.teamName ? { teamName: install.teamName } : {}),
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
// ORDER IS LOAD-BEARING, for the second time on this page, and this is the ONLY point at which a
|
|
515
|
+
// run changes what the live deployment speaks as:
|
|
516
|
+
// 1. PROMOTE the staged signing secret to the live ref. Everything before this moment left the
|
|
517
|
+
// running deployment's inbound verification exactly as it was.
|
|
518
|
+
// 2. the bot token;
|
|
519
|
+
// 3. the install record LAST, because the record is the lock — written first, it would close the
|
|
520
|
+
// page on a deployment that cannot talk to Slack, recoverable only by the control call.
|
|
521
|
+
const staged = await vault.read(SLACK_PENDING_SIGNING_SECRET_REF);
|
|
522
|
+
if (staged === null) return notAvailable();
|
|
523
|
+
await vault.write(SLACK_SIGNING_SECRET_REF, staged, { connector: "slack", createdBy: "host:slack-setup" });
|
|
524
|
+
await vault.write(SLACK_BOT_TOKEN_REF, install.botToken, { connector: "slack", createdBy: "host:slack-setup" });
|
|
525
|
+
// 🔴 THE CODE AND THE COOKIE KEY DIE HERE, BEFORE THE RECORD. The install is what closes the page,
|
|
526
|
+
// so this is the last moment at which either is of any use — and both are live credentials sitting
|
|
527
|
+
// in the deployment's log lines (the code) or in a browser (a cookie minted during this run). Left
|
|
528
|
+
// in place they are what makes a later reopen, or a lock that failed open, exploitable by whoever
|
|
529
|
+
// read those logs. Dropping them means the page can only ever be re-entered with a code this
|
|
530
|
+
// deployment mints AFTER the reopen. Before `recordSlackInstall`, so a crash between the two leaves
|
|
531
|
+
// the page open with the OLD code already dead rather than still valid.
|
|
532
|
+
//
|
|
533
|
+
// THE COST OF THAT ORDER, stated because the 500 below has to carry it: a crash here — or a failed
|
|
534
|
+
// record write — leaves the page open with NO usable code, because the replacement is minted
|
|
535
|
+
// silently in the store and logged only by the once-per-process boot notice. The operator's
|
|
536
|
+
// recovery is a restart, which the 500 says.
|
|
537
|
+
await vault.delete(SLACK_SETUP_CODE_REF).catch(() => undefined);
|
|
538
|
+
await vault.delete(SLACK_COOKIE_KEY_REF).catch(() => undefined);
|
|
539
|
+
await recordSlackInstall(state, deps);
|
|
540
|
+
} catch {
|
|
541
|
+
// 🔴 THIS PAGE CANNOT TELL THEM TO "TRY AGAIN", because the two lines above destroyed the only
|
|
542
|
+
// credentials that re-enter it. The code is minted and logged ONCE PER PROCESS by the boot notice,
|
|
543
|
+
// so a replacement exists in the store but has been logged nowhere — on a long-lived container the
|
|
544
|
+
// operator would meet "that code did not match" against a value nothing can show them. A restart is
|
|
545
|
+
// the recovery, and saying so here is the difference between a bad minute and a support ticket.
|
|
546
|
+
return securePageResponse(
|
|
547
|
+
500,
|
|
548
|
+
"Could not store the Slack install",
|
|
549
|
+
`<p>Slack authorized the install, but this deployment could not write it to the encrypted store. ` +
|
|
550
|
+
`Check that <code>OE_CREDENTIAL_ENCRYPTION_KEY</code> is set and the database is reachable.</p>` +
|
|
551
|
+
`<p><strong>Then restart or redeploy this deployment before trying again.</strong> This run has ` +
|
|
552
|
+
`already used up its one-time setup code and its claim cookie, and a fresh code is logged only ` +
|
|
553
|
+
`when the deployment starts. Without a restart, <code>/setup</code> will refuse every code you have.</p>` +
|
|
554
|
+
`<p>The Slack app itself was created and installed — you do not need a new app configuration ` +
|
|
555
|
+
`token for the retry, but you will need to install again.</p>`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// The pending record is spent. A failure to clear it is not worth failing the install over: the page
|
|
560
|
+
// is locked now, so nothing reads it again until a reopen, and a reopen's next run overwrites it.
|
|
561
|
+
await vault.delete(SLACK_PENDING_REF).catch(() => undefined);
|
|
562
|
+
await vault.delete(SLACK_PENDING_SIGNING_SECRET_REF).catch(() => undefined);
|
|
563
|
+
|
|
564
|
+
// 🔴 THE ROW CARRIES IDS, NEVER A CREDENTIAL. Audit details are read back by admin views, so this
|
|
565
|
+
// is the same line the status endpoint could publish: which app, which workspace, which installer.
|
|
566
|
+
await appendAudit({
|
|
567
|
+
org: deploymentOrg(),
|
|
568
|
+
platform: "slack",
|
|
569
|
+
workspace: state.teamId,
|
|
570
|
+
action: SLACK_SETUP_INSTALLED,
|
|
571
|
+
detail: { appId: state.appId, teamId: state.teamId, installerUserId: state.installerUserId },
|
|
572
|
+
}).catch(() => undefined);
|
|
573
|
+
|
|
574
|
+
const res = doneScreen(state);
|
|
575
|
+
// Clear the claim: the run is over, and a cookie that outlived it is a standing claim on a page
|
|
576
|
+
// that would only reopen by an admin's control call.
|
|
577
|
+
res.headers.append("set-cookie", `${SETUP_COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`);
|
|
578
|
+
return res;
|
|
579
|
+
}
|