@open-everywhere/provider-slack 0.1.3 → 0.1.5
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/NOTICE +34 -4
- 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 +44 -7
- 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 +94 -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 +4 -1
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +40 -7
- 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 +87 -0
- package/dist/agent/status.js.map +1 -0
- package/package.json +38 -4
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// WHO MAY RUN `/setup` (OPE-2231) — the claim, the cookie, and the lock.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM. `/setup` hands whoever completes it a Slack app wired to THIS deployment and a bot
|
|
4
|
+
// token for the workspace that installs it. An unclaimed setup page is first-come: the first
|
|
5
|
+
// stranger to find a fresh deployment owns the agent. So the page needs an identity assertion before
|
|
6
|
+
// it will talk, and the deployment has nothing to assert one with — no users, no admins, no surface
|
|
7
|
+
// (Slack is what setup is for), and no operator-set secret on the hosted path, where the one env
|
|
8
|
+
// value the deploy button asks for is the credential encryption key.
|
|
9
|
+
//
|
|
10
|
+
// THE ANSWER, AND IT IS THE RATIFIED ONE (OPE-2231; "No other auth design"). The deployment proves
|
|
11
|
+
// it owns its own LOGS, which is a thing only its operator can read:
|
|
12
|
+
//
|
|
13
|
+
// 1. On boot, while no install is recorded, the deployment mints a one-time SETUP CODE and logs it
|
|
14
|
+
// once (`vercel logs` / `docker logs`; a coding agent reads it). The code lives in the vault, so
|
|
15
|
+
// every instance logs the same one and a restart does not invalidate a code in use.
|
|
16
|
+
// 2. `/setup` asks for the code once. A match sets a short-lived signed COOKIE; every later step
|
|
17
|
+
// in the run presents the cookie, so the code is typed once and never rides a URL.
|
|
18
|
+
// 3. The page LOCKS once an install is recorded (install-state.ts) and answers 404 from then on.
|
|
19
|
+
// An admin reopens it with the control call (`reopen_slack_setup`), never by presenting a code.
|
|
20
|
+
//
|
|
21
|
+
// 🔴 WHAT THIS IS AND IS NOT WORTH. The trust root is READ ACCESS TO THE DEPLOYMENT'S LOGS — a
|
|
22
|
+
// PLATFORM permission (`vercel logs`, `docker logs`), not any role this product defines. It is the
|
|
23
|
+
// right root for the hosted path, because whoever holds it can already read every secret the platform
|
|
24
|
+
// holds. It is honestly weaker than an account login in one way worth stating: a platform where log
|
|
25
|
+
// access is granted more widely than deploy access grants setup to everyone with log access, for as
|
|
26
|
+
// long as the page is open. Note which permission that is — it is NOT the OE `admin` role that the
|
|
27
|
+
// reopen verb requires, and the two sets need not overlap.
|
|
28
|
+
//
|
|
29
|
+
// TWO THINGS BOUND THAT WINDOW, and both are load-bearing:
|
|
30
|
+
// • the page closes at the first successful install, and {@link announceSlackSetupCode} stops
|
|
31
|
+
// EMITTING a code from then on. The logs are append-only history, so a post-install deployment's
|
|
32
|
+
// OLD log lines still contain the code it emitted before the install — which is why the INSTALL
|
|
33
|
+
// ITSELF destroys that code and the claim cookie key (`setup/page.ts`, before it records the
|
|
34
|
+
// install), and why a reopen rotates both BEFORE it deletes the record (`reopenSlackSetup`, which
|
|
35
|
+
// throws rather than opening the page if it cannot). The strings in those old log lines are dead
|
|
36
|
+
// the moment the install completes, and a reopen can only ever be entered with a code minted
|
|
37
|
+
// after it.
|
|
38
|
+
// • the lock FAILS CLOSED. Only an absent install record opens the page; a store fault, an
|
|
39
|
+
// unparseable row or a record from a future build all keep it shut. See {@link slackSetupOpen}.
|
|
40
|
+
// • the page is also closed on a deployment configured the OLD way, by environment variable. That
|
|
41
|
+
// one is not a nicety: without it every existing deployment in the world would upgrade into an
|
|
42
|
+
// open setup page it never asked for. See {@link slackSetupOpen}.
|
|
43
|
+
//
|
|
44
|
+
// 🔴 THE CODE IS A SECRET AND THE LOG LINE IS ITS ONLY RENDERING. It is never put in an error, never
|
|
45
|
+
// echoed back by the form, never written to the audit detail, and never returned by any function
|
|
46
|
+
// here but the two that exist to mint or compare it. A failed code attempt renders the same generic
|
|
47
|
+
// page a right-but-locked attempt does.
|
|
48
|
+
//
|
|
49
|
+
// WHY A SECOND COOKIE CODEC AND NOT core's PAGE-TOKEN CODEC. `credentials/page-token.ts` signs a
|
|
50
|
+
// capability bound to ONE ROW in `credential_intake` — its claim set is `{v, aud, org, id, rb, exp}`
|
|
51
|
+
// and `id`/`rb` are the row and the principal the act is attributed to. A setup cookie binds no row
|
|
52
|
+
// and no principal (there is no principal yet — that is what setup produces), rides a `Set-Cookie`
|
|
53
|
+
// rather than a URL, and is presented many times rather than consumed once. Bending the row codec
|
|
54
|
+
// around that would mean two meanings for `id` and a THIRD audience in a closed union whose N×N
|
|
55
|
+
// replay matrix is a suite (`webhook-reveal-test.ts`), for a token that has no row to replay onto.
|
|
56
|
+
// So: its own codec, its own key, and the two cannot verify each other's tokens in either direction
|
|
57
|
+
// because neither key is the other's and neither payload satisfies the other's shape check.
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
encryptedKvVault,
|
|
61
|
+
type EncryptedKvVault,
|
|
62
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
63
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
64
|
+
import { SLACK_COOKIE_KEY_REF, SLACK_SETUP_CODE_REF, readInstallRecord, type SlackInstallStateDeps } from "./install-state.js";
|
|
65
|
+
import { slackSecretEnvPresent } from "./credentials.js";
|
|
66
|
+
|
|
67
|
+
/** The cookie the page sets once the code matches. `__Host-` pins it to this exact host with no
|
|
68
|
+
* domain and a `/` path, which a subdomain cannot overwrite. */
|
|
69
|
+
export const SETUP_COOKIE_NAME = "__Host-oe-setup";
|
|
70
|
+
|
|
71
|
+
/** How long a claimed run stays claimed. Long enough to walk the page and Slack's OAuth consent,
|
|
72
|
+
* short enough that an unattended browser on a shared machine is not a standing claim. */
|
|
73
|
+
export const SETUP_COOKIE_TTL_MS = 30 * 60 * 1000;
|
|
74
|
+
|
|
75
|
+
/** The setup code's length in bytes before encoding — 16 bytes is 128 bits, which is not guessable
|
|
76
|
+
* and still short enough for a human to copy out of a log line in one go. */
|
|
77
|
+
const SETUP_CODE_BYTES = 16;
|
|
78
|
+
|
|
79
|
+
/** The cookie HMAC key's length in bytes. Nobody types this one, so it is a full 256-bit key. */
|
|
80
|
+
const COOKIE_KEY_BYTES = 32;
|
|
81
|
+
|
|
82
|
+
/** Injectable seams for tests. `now` is the clock the cookie's expiry is read against. */
|
|
83
|
+
export interface SlackClaimDeps extends SlackInstallStateDeps {
|
|
84
|
+
readonly vault?: EncryptedKvVault;
|
|
85
|
+
readonly now?: () => number;
|
|
86
|
+
/** Override `process.env` — the lock reads it to close on an env-configured deployment. */
|
|
87
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
88
|
+
/**
|
|
89
|
+
* Use the lock-verdict cache even though a vault is injected. TEST SEAM ONLY.
|
|
90
|
+
*
|
|
91
|
+
* 🔴 SAME REASON AS `SlackCredentialDeps.memo`. An injected vault normally bypasses the cache, which
|
|
92
|
+
* the suite leans on — a test writes an install record and the next call sees it. That also made
|
|
93
|
+
* {@link LOCK_TTL_MS} undrivable by any test, and a TTL nobody can drive is a claim nobody has
|
|
94
|
+
* checked. This one gates a PUBLIC route, so it is the last place to take that on faith. Nothing in
|
|
95
|
+
* `agent/**` passes it.
|
|
96
|
+
*/
|
|
97
|
+
readonly lockCache?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function vaultOf(deps: SlackClaimDeps): EncryptedKvVault {
|
|
101
|
+
return deps.vault ?? encryptedKvVault();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- mint-on-first-use, into the Slack app's own namespace ------------------------------------------
|
|
105
|
+
//
|
|
106
|
+
// WHY NOT core's `getOrMintSelfSecret`. That function's contract is "an env var of the same NAME is
|
|
107
|
+
// the announced on-ramp", which is right for a secret an operator might already hold — and wrong for
|
|
108
|
+
// these two. Nobody sets a setup code or a cookie key by hand: there is nothing to migrate from, and
|
|
109
|
+
// an env name for an auto-minted value is a knob whose only use is to weaken it. So these mint into
|
|
110
|
+
// `slack-app:` beside the rest of the bundle's material, with no env name at all, and this helper
|
|
111
|
+
// carries the SAME race discipline that function documents: a per-key lock, a read-back rather than
|
|
112
|
+
// trust in the local candidate, and no memo of a fresh mint (the vault is last-write-wins with no
|
|
113
|
+
// put-if-absent, so two cold processes can briefly diverge and the next read converges).
|
|
114
|
+
async function mintedSecret(ref: string, bytes: number, vault: EncryptedKvVault, now: () => number, cache: boolean): Promise<string> {
|
|
115
|
+
// 🔴 AN EXISTING ROW IS MEMOISED BRIEFLY, A FRESH MINT NEVER IS, and both halves matter.
|
|
116
|
+
//
|
|
117
|
+
// WHY CACHE AT ALL. Both callers sit on the UNAUTHENTICATED `/setup` route: `verifySetupCookie`
|
|
118
|
+
// resolves the cookie key for any request carrying a non-empty cookie, and `setupCodeMatches` resolves
|
|
119
|
+
// the code for every claim POST. Uncached, that is one `stored_credential` SELECT plus one decrypt per
|
|
120
|
+
// request for as long as the page is open — the same exposure `LOCK_TTL_MS` and the credential
|
|
121
|
+
// resolver's `MISS_TTL_MS` exist to remove, left open on the one route most exposed to it.
|
|
122
|
+
//
|
|
123
|
+
// WHY NOT A FRESH MINT. A mint reflects only what THIS process wrote, not what a concurrent racer's
|
|
124
|
+
// later write did (the vault is last-write-wins with no put-if-absent). Caching it would pin a value
|
|
125
|
+
// that lost the race; leaving it uncached means the next resolution re-reads and converges.
|
|
126
|
+
const cached = cache ? secretCache.get(ref) : undefined;
|
|
127
|
+
if (cached !== undefined && cached.until > now()) return cached.value;
|
|
128
|
+
|
|
129
|
+
return vault.withLock(ref, async () => {
|
|
130
|
+
const stored = await vault.read(ref);
|
|
131
|
+
if (stored !== null) {
|
|
132
|
+
if (cache) secretCache.set(ref, { value: stored, until: now() + SECRET_TTL_MS });
|
|
133
|
+
return stored;
|
|
134
|
+
}
|
|
135
|
+
const minted = randomBytes(bytes).toString("base64url");
|
|
136
|
+
await vault.write(ref, minted, { connector: "slack", createdBy: "host:slack-setup" });
|
|
137
|
+
const confirmed = await vault.read(ref);
|
|
138
|
+
return confirmed ?? minted;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** How long a read-back claim secret is reused. Matched to {@link LOCK_TTL_MS}: a reopen's rotation has
|
|
143
|
+
* to take effect without a restart, and five seconds is the staleness this page already accepts. */
|
|
144
|
+
const SECRET_TTL_MS = 5_000;
|
|
145
|
+
|
|
146
|
+
/** Read-back claim secrets and when each stops being trusted. Bypassed for an injected vault. */
|
|
147
|
+
const secretCache = new Map<string, { readonly value: string; readonly until: number }>();
|
|
148
|
+
|
|
149
|
+
/** Test seam: forget cached claim secrets. */
|
|
150
|
+
export function resetSlackClaimSecretCache(): void {
|
|
151
|
+
secretCache.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The HMAC key for the claim cookie, minting it on first use. Never logged, never rendered. A full
|
|
155
|
+
* 32 bytes: it keys an HMAC rather than being typed by a human, so there is no reason to be short. */
|
|
156
|
+
async function cookieKey(deps: SlackClaimDeps): Promise<string> {
|
|
157
|
+
return mintedSecret(SLACK_COOKIE_KEY_REF, COOKIE_KEY_BYTES, vaultOf(deps), deps.now ?? Date.now, deps.vault === undefined);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The deployment's setup code, minting it on first use. SECRET — the ONLY place it may be rendered
|
|
162
|
+
* is the boot log line in {@link announceSlackSetupCode}.
|
|
163
|
+
*/
|
|
164
|
+
export async function slackSetupCode(deps: SlackClaimDeps = {}): Promise<string> {
|
|
165
|
+
return mintedSecret(SLACK_SETUP_CODE_REF, SETUP_CODE_BYTES, vaultOf(deps), deps.now ?? Date.now, deps.vault === undefined);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- the lock ---------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Is `/setup` OPEN right now? True only while NO install is recorded.
|
|
172
|
+
*
|
|
173
|
+
* Every route on the page calls this FIRST and 404s on false, so a locked deployment's `/setup` is
|
|
174
|
+
* indistinguishable from a deployment that never had the page — the same posture
|
|
175
|
+
* `credentials/intake-page.ts` takes on a disarmed vault.
|
|
176
|
+
*/
|
|
177
|
+
export async function slackSetupOpen(deps: SlackClaimDeps = {}): Promise<boolean> {
|
|
178
|
+
// The cached verdict, while it is still inside its TTL. Skipped for an injected vault (a test
|
|
179
|
+
// driving a specific store state), and the env check below is a property read either way.
|
|
180
|
+
const cacheApplies = deps.vault === undefined || deps.lockCache === true;
|
|
181
|
+
if (cacheApplies && lockCache !== null && lockCache.until > (deps.now ?? Date.now)()) return lockCache.open;
|
|
182
|
+
|
|
183
|
+
// 🔴 AN ENV-CONFIGURED DEPLOYMENT IS CLOSED, and this arm is not a convenience — without it the
|
|
184
|
+
// whole upgrade population gets an OPEN setup page and a live claim code in its logs, forever.
|
|
185
|
+
//
|
|
186
|
+
// The lock is "an install record exists". A deployment that was configured the old way — the two
|
|
187
|
+
// env vars, set by hand — has never run `/setup`, so it has no record. Before this check, landing
|
|
188
|
+
// this feature would have opened `/setup` on every such deployment and logged a one-time code on
|
|
189
|
+
// every cold start indefinitely, with no install ever arriving to close it. Anyone who could read
|
|
190
|
+
// the deployment's logs could then install the app into a workspace THEY control, overwrite the
|
|
191
|
+
// stored credentials, lock the page behind themselves, and have `slackInstallFacts()` publish their
|
|
192
|
+
// workspace as this deployment's install.
|
|
193
|
+
//
|
|
194
|
+
// Closing on env presence costs nothing a real `/setup` operator wants: the resolver is env-first,
|
|
195
|
+
// so a deployment with either var set would IGNORE everything a run stored anyway — the page would
|
|
196
|
+
// have been a no-op with a credential write. An operator who wants to migrate onto the store unsets
|
|
197
|
+
// the vars and redeploys, which is what the runbook says and what makes the run meaningful.
|
|
198
|
+
// Screen 2's shadow notice still covers the case where one var is set after an install.
|
|
199
|
+
const env = slackSecretEnvPresent({ ...(deps.env ? { env: deps.env } : {}) });
|
|
200
|
+
if (env.botToken || env.signingSecret) return false;
|
|
201
|
+
|
|
202
|
+
// 🔴 ONLY AN ABSENT ROW OPENS THE PAGE, and the earlier draft got this backwards. It read through
|
|
203
|
+
// `slackInstallState`, which collapses a store fault, a malformed row and an unknown record version
|
|
204
|
+
// into `undefined` — so any of those REOPENED `/setup`. That is not a safe default here: the setup
|
|
205
|
+
// code sits in the deployment's log lines from before the install, so a reopen is a window for
|
|
206
|
+
// whoever read them. The cost of failing closed is a page briefly unavailable during a store
|
|
207
|
+
// outage, recoverable by a retry or the control call; the other direction is not recoverable.
|
|
208
|
+
// `readInstallRecord` tells the three cases apart — see its doc comment.
|
|
209
|
+
const read = await readInstallRecord(deps);
|
|
210
|
+
const open = read.kind === "absent";
|
|
211
|
+
|
|
212
|
+
// Cached briefly, for the same reason the credential resolver caches a miss: this predicate runs on
|
|
213
|
+
// EVERY request to a PUBLIC route, so an uncached read let anyone who knew the host drive one
|
|
214
|
+
// `stored_credential` SELECT per request. A few seconds is enough to collapse that while keeping the
|
|
215
|
+
// page responsive to a `/setup` run or a reopen that just changed the answer.
|
|
216
|
+
if (cacheApplies) lockCache = { open, until: (deps.now ?? Date.now)() + LOCK_TTL_MS };
|
|
217
|
+
return open;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** How long the lock verdict is reused. Short, because a reopen must take effect without a restart. */
|
|
221
|
+
const LOCK_TTL_MS = 5_000;
|
|
222
|
+
|
|
223
|
+
/** The last lock verdict and when it stops being trusted. Bypassed entirely for an injected vault. */
|
|
224
|
+
let lockCache: { readonly open: boolean; readonly until: number } | null = null;
|
|
225
|
+
|
|
226
|
+
/** Test seam: forget the cached lock verdict. */
|
|
227
|
+
export function resetSlackSetupLockCache(): void {
|
|
228
|
+
lockCache = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The boot notice (core `boot/notices.ts`): log the setup code ONCE per process, while the page is
|
|
233
|
+
* open. Silent whenever {@link slackSetupOpen} is false — an install is recorded, OR this deployment
|
|
234
|
+
* is configured by environment variable and would ignore anything a run stored. Both cases would be
|
|
235
|
+
* emitting a live claim credential for a page that will refuse every request.
|
|
236
|
+
*
|
|
237
|
+
* 🔴 THIS IS THE ONE PLACE THE CODE IS RENDERED. It goes to the log and nowhere else: not to the
|
|
238
|
+
* audit detail (`appendAudit` details are read back by admin views), not into an error message, not
|
|
239
|
+
* into the page. Minting it is also what this call does first, so the code exists from the first
|
|
240
|
+
* boot rather than from the first visit.
|
|
241
|
+
*/
|
|
242
|
+
export async function announceSlackSetupCode(deps: SlackClaimDeps = {}): Promise<void> {
|
|
243
|
+
if (!(await slackSetupOpen(deps))) return;
|
|
244
|
+
const code = await slackSetupCode(deps);
|
|
245
|
+
console.log(
|
|
246
|
+
`[open-everywhere:slack-setup] Slack is not installed on this deployment. Open /setup and enter this one-time setup code: ${code}\n` +
|
|
247
|
+
`[open-everywhere:slack-setup] The code stops being logged once the Slack install completes, and /setup locks at that point.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- the cookie codec -------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/** The signed cookie's claim set. No row, no principal — see the header on why it is not a page
|
|
254
|
+
* token. `exp` is a hard epoch-ms wall; `v` guards a grammar change. */
|
|
255
|
+
interface SetupCookieClaims {
|
|
256
|
+
readonly v: 1;
|
|
257
|
+
readonly exp: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function b64urlEncode(buf: Buffer): string {
|
|
261
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function b64urlDecode(s: string): Buffer {
|
|
265
|
+
// Reject anything outside the base64url alphabet BEFORE decoding (Buffer is lax), so a malformed
|
|
266
|
+
// cookie fails cleanly rather than decoding to attacker-shaped bytes.
|
|
267
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s)) return Buffer.alloc(0);
|
|
268
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sign(secret: string, payloadB64: string): string {
|
|
272
|
+
return b64urlEncode(createHmac("sha256", secret).update(payloadB64, "utf8").digest());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Mint the signed cookie VALUE for a run that just presented the right code. CAPABILITY-BEARING. */
|
|
276
|
+
export async function mintSetupCookie(deps: SlackClaimDeps = {}): Promise<string> {
|
|
277
|
+
const now = (deps.now ?? Date.now)();
|
|
278
|
+
const claims: SetupCookieClaims = { v: 1, exp: now + SETUP_COOKIE_TTL_MS };
|
|
279
|
+
const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(claims), "utf8"));
|
|
280
|
+
return `${payloadB64}.${sign(await cookieKey(deps), payloadB64)}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Verify a presented cookie value. True only for a well-formed, correctly-signed, unexpired one. */
|
|
284
|
+
export async function verifySetupCookie(value: string | undefined, deps: SlackClaimDeps = {}): Promise<boolean> {
|
|
285
|
+
if (typeof value !== "string") return false;
|
|
286
|
+
const dot = value.indexOf(".");
|
|
287
|
+
if (dot <= 0 || dot === value.length - 1) return false;
|
|
288
|
+
const payloadB64 = value.slice(0, dot);
|
|
289
|
+
const sigB64 = value.slice(dot + 1);
|
|
290
|
+
|
|
291
|
+
const expected = sign(await cookieKey(deps), payloadB64);
|
|
292
|
+
const a = Buffer.from(sigB64);
|
|
293
|
+
const b = Buffer.from(expected);
|
|
294
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
|
|
295
|
+
|
|
296
|
+
let parsed: unknown;
|
|
297
|
+
try {
|
|
298
|
+
const raw = b64urlDecode(payloadB64);
|
|
299
|
+
if (raw.length === 0) return false;
|
|
300
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
if (typeof parsed !== "object" || parsed === null) return false;
|
|
305
|
+
const c = parsed as Record<string, unknown>;
|
|
306
|
+
if (c.v !== 1) return false;
|
|
307
|
+
if (typeof c.exp !== "number" || !Number.isFinite(c.exp)) return false;
|
|
308
|
+
return c.exp > (deps.now ?? Date.now)();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Does `presented` equal the deployment's setup code? CONSTANT-TIME, length-prechecked.
|
|
313
|
+
*
|
|
314
|
+
* Never reports WHY it said no, and the caller never tells the holder either: a wrong code and a
|
|
315
|
+
* right code on a locked deployment render the same page.
|
|
316
|
+
*/
|
|
317
|
+
export async function setupCodeMatches(presented: string, deps: SlackClaimDeps = {}): Promise<boolean> {
|
|
318
|
+
const a = Buffer.from(presented.trim());
|
|
319
|
+
const b = Buffer.from(await slackSetupCode(deps));
|
|
320
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The `Set-Cookie` header value for a claimed run. `HttpOnly` (no script reads it), `Secure` (the
|
|
324
|
+
* page is HTTPS-only), `SameSite=Lax` — Lax rather than Strict because the run RETURNS from Slack's
|
|
325
|
+
* OAuth consent as a top-level cross-site GET, which Strict would strip the cookie from, breaking
|
|
326
|
+
* the callback for the very person who claimed the run. */
|
|
327
|
+
export function setupCookieHeader(value: string): string {
|
|
328
|
+
return `${SETUP_COOKIE_NAME}=${value}; Path=/; Max-Age=${Math.floor(SETUP_COOKIE_TTL_MS / 1000)}; HttpOnly; Secure; SameSite=Lax`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Read the setup cookie out of a request's `Cookie` header, or `undefined`. */
|
|
332
|
+
export function setupCookieFrom(req: Request): string | undefined {
|
|
333
|
+
const header = req.headers.get("cookie");
|
|
334
|
+
if (!header) return undefined;
|
|
335
|
+
for (const part of header.split(";")) {
|
|
336
|
+
const eq = part.indexOf("=");
|
|
337
|
+
if (eq <= 0) continue;
|
|
338
|
+
if (part.slice(0, eq).trim() !== SETUP_COOKIE_NAME) continue;
|
|
339
|
+
const value = part.slice(eq + 1).trim();
|
|
340
|
+
return value.length > 0 ? value : undefined;
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// WHERE THE SLACK BUNDLE'S TWO SECRETS COME FROM (OPE-2231) — the signing secret that verifies
|
|
2
|
+
// Slack's requests, and the bot token that signs ours.
|
|
3
|
+
//
|
|
4
|
+
// Before this module there was one answer: `process.env.SLACK_SIGNING_SECRET` and
|
|
5
|
+
// `process.env.SLACK_BOT_TOKEN`, read inline at each call site. A `/setup` run now captures both
|
|
6
|
+
// into the credential store, so there are two homes and an order is needed.
|
|
7
|
+
//
|
|
8
|
+
// 🔴 THE ORDER IS ENV-FIRST, AND IT IS THE OPPOSITE OF `getOperatorSecret`'s. Core's operator-secret
|
|
9
|
+
// resolver puts the db store first and treats env as a fallback on its way out (OPE-408: "db-over-env
|
|
10
|
+
// is the DEFAULT home for secrets"). This module inverts that, deliberately, on the OPE-2231 ruling
|
|
11
|
+
// ("env as fallback (env wins if set, for existing installs)"). The reason is the migration, not a
|
|
12
|
+
// disagreement about where secrets belong:
|
|
13
|
+
//
|
|
14
|
+
// • EVERY deployment that exists today holds these two in env and works. This change must not be
|
|
15
|
+
// able to alter which Slack app a working deployment speaks as. Store-first could: a `/setup` run
|
|
16
|
+
// somebody started and abandoned, or a second workspace's install, would silently take over a
|
|
17
|
+
// live deployment's Slack identity at the next cold start, with a 401 loop as the symptom and
|
|
18
|
+
// nothing in the logs saying a different secret had been picked.
|
|
19
|
+
// • Env-first cannot do that. A deployment with the env vars set resolves to exactly the value it
|
|
20
|
+
// resolved to before this commit, every time. It reads the store AT MOST ONCE PER NAME PER
|
|
21
|
+
// PROCESS — only to decide whether to warn that the env var is shadowing something — and never
|
|
22
|
+
// again, so the per-request hot path for an existing install costs the same property read it
|
|
23
|
+
// always did.
|
|
24
|
+
// • A fresh hosted install sets neither env var (the deploy button asks for one value, the
|
|
25
|
+
// credential encryption key), so it reads the store, which is where `/setup` put them.
|
|
26
|
+
//
|
|
27
|
+
// The cost, stated plainly: an operator who runs `/setup` on a deployment that still has the env vars
|
|
28
|
+
// set will find the stored credentials ignored. {@link slackCredentialSourceNotice} is what makes that
|
|
29
|
+
// visible instead of mysterious — the page says it before the run, and the resolver logs it once.
|
|
30
|
+
//
|
|
31
|
+
// MEMOISED PER PROCESS WITH A SHORT TTL, store reads only. The signature check runs on every inbound
|
|
32
|
+
// Slack request and the bot token on every outbound call, so a vault round trip per request is not
|
|
33
|
+
// acceptable.
|
|
34
|
+
//
|
|
35
|
+
// 🔴 THE TTL IS NOT A TIDINESS CHOICE — A LIFETIME MEMO WAS A CORRECTNESS BUG. An earlier draft cached
|
|
36
|
+
// a store hit for the process's lifetime, on the same reasoning core's `self-secrets.ts` gives for its
|
|
37
|
+
// own memo. That reasoning does not transfer, because `self-secrets` holds values this deployment
|
|
38
|
+
// minted for ITSELF and only rotation changes: nothing re-mints them behind a warm process. These two
|
|
39
|
+
// DO change behind one — `reopen_slack_setup` followed by a second `/setup` run replaces both, by
|
|
40
|
+
// design. With a lifetime memo every warm instance kept serving the OLD signing secret and the OLD
|
|
41
|
+
// bot token after that second install, so the deployment verified the previous app's signature and
|
|
42
|
+
// posted with the previous app's token until every instance happened to recycle: a 401 loop that
|
|
43
|
+
// healed at different times on different instances, with nothing in the logs naming the cause.
|
|
44
|
+
//
|
|
45
|
+
// A TTL bounds that to {@link HIT_TTL_MS} without giving up the per-request cost, which is what the
|
|
46
|
+
// memo was for. It is deliberately not a cache invalidated by the reopen path: a reopen runs in ONE
|
|
47
|
+
// process and these memos live in all of them, so nothing a reopen does can reach them. Only time can. An env hit is not memoised because it is already a
|
|
48
|
+
// property read. A MISS is remembered only BRIEFLY ({@link MISS_TTL_MS}) — long enough that an
|
|
49
|
+
// unauthenticated POST to the public Slack route cannot drive one database query per request, short
|
|
50
|
+
// enough that a process which booted before `/setup` finished still picks the credentials up without a
|
|
51
|
+
// restart.
|
|
52
|
+
|
|
53
|
+
import { encryptedKvVault, type EncryptedKvVault } from "@open-everywhere/core/agent/adapter-api.js";
|
|
54
|
+
import { SLACK_BOT_TOKEN_REF, SLACK_SIGNING_SECRET_REF } from "./install-state.js";
|
|
55
|
+
|
|
56
|
+
/** The env var holding the app's request-signing secret — the pre-OPE-2231 home, still first. */
|
|
57
|
+
const SLACK_SIGNING_SECRET_ENV = "SLACK_SIGNING_SECRET";
|
|
58
|
+
/** The env var holding the workspace bot token — the pre-OPE-2231 home, still first. */
|
|
59
|
+
const SLACK_BOT_TOKEN_ENV = "SLACK_BOT_TOKEN";
|
|
60
|
+
|
|
61
|
+
/** Which home answered. Reported by {@link resolveSlackSecret} for the suite and the notice. */
|
|
62
|
+
export type SlackSecretSource = "env" | "store";
|
|
63
|
+
|
|
64
|
+
/** Injectable seams for tests — the live defaults are the real vault and `process.env`. */
|
|
65
|
+
export interface SlackCredentialDeps {
|
|
66
|
+
readonly vault?: EncryptedKvVault;
|
|
67
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
68
|
+
/** The clock the hit and miss TTLs are read against. Tests advance it to prove a cached value stops
|
|
69
|
+
* being reused; the live default is `Date.now`. */
|
|
70
|
+
readonly now?: () => number;
|
|
71
|
+
/**
|
|
72
|
+
* Use the per-process memo even though a vault is injected. TEST SEAM ONLY.
|
|
73
|
+
*
|
|
74
|
+
* 🔴 WHY IT HAS TO EXIST. An injected vault normally bypasses the memo, which is a contract the suite
|
|
75
|
+
* leans on — a test rewrites a row and the next call sees it. But that also made {@link HIT_TTL_MS}
|
|
76
|
+
* UNTESTABLE: the only path that memoises was the one no test can hand a store to. A TTL nobody can
|
|
77
|
+
* drive is a claim nobody has checked, and this one is the fix for a real defect (see the header), so
|
|
78
|
+
* the seam is the smaller cost. Nothing in `agent/**` passes it.
|
|
79
|
+
*/
|
|
80
|
+
readonly memo?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A resolved secret and the home it came from. */
|
|
84
|
+
export interface ResolvedSlackSecret {
|
|
85
|
+
readonly value: string;
|
|
86
|
+
readonly source: SlackSecretSource;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* How long a resolved STORE value is reused. Bounds how long a warm instance can keep serving the
|
|
91
|
+
* credentials a `reopen_slack_setup` + second `/setup` run replaced — see the header. Thirty seconds
|
|
92
|
+
* keeps the per-request cost at zero in the steady state while making a reinstall take effect on its
|
|
93
|
+
* own, which a lifetime memo never did.
|
|
94
|
+
*/
|
|
95
|
+
const HIT_TTL_MS = 30_000;
|
|
96
|
+
|
|
97
|
+
/** A resolved store value and when it stops being trusted. */
|
|
98
|
+
const memo = new Map<string, { readonly value: string; readonly until: number }>();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How long a store MISS is remembered. A miss cannot be cached for the process's life — a process
|
|
102
|
+
* that boots while `/setup` is still running has to pick the credentials up without a restart — but it
|
|
103
|
+
* must not be re-asked on every request either.
|
|
104
|
+
*
|
|
105
|
+
* 🔴 WHY A MISS NEEDS CACHING AT ALL. The signature check runs through this resolver on every inbound
|
|
106
|
+
* request to the PUBLIC Slack webhook route. On a deployment with neither an env var nor a stored value
|
|
107
|
+
* — which is every deployment for the whole window `/setup` is open — an uncached miss meant one
|
|
108
|
+
* `stored_credential` SELECT per request, before the 401. Anyone who knew the host could drive one
|
|
109
|
+
* database query per request by POSTing garbage. Thirty seconds bounds that at two queries a minute
|
|
110
|
+
* while keeping "a mid-`/setup` process picks them up without a restart" true, one TTL later.
|
|
111
|
+
*/
|
|
112
|
+
const MISS_TTL_MS = 30_000;
|
|
113
|
+
|
|
114
|
+
/** When each ref's miss stops being trusted, epoch ms. Absent ⇒ no remembered miss. */
|
|
115
|
+
const missUntil = new Map<string, number>();
|
|
116
|
+
|
|
117
|
+
/** Test seam: forget memoised store resolutions, hits and misses alike. */
|
|
118
|
+
export function resetSlackCredentialMemo(): void {
|
|
119
|
+
memo.clear();
|
|
120
|
+
missUntil.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const noticed = new Set<string>();
|
|
124
|
+
|
|
125
|
+
/** The names whose env-shadows-store CHECK has run in this process, whether or not it warned. See the
|
|
126
|
+
* resolver: keying the one-shot off `noticed` alone made the no-stored-value case re-read the vault
|
|
127
|
+
* on every single request. */
|
|
128
|
+
const shadowChecked = new Set<string>();
|
|
129
|
+
|
|
130
|
+
/** Test seam: forget which names have already logged the shadowing notice, and which have been
|
|
131
|
+
* checked at all. */
|
|
132
|
+
export function resetSlackCredentialNotices(): void {
|
|
133
|
+
noticed.clear();
|
|
134
|
+
shadowChecked.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The sentence an operator needs when the env var is set AND the store holds a value for the same
|
|
139
|
+
* secret: the env var is winning and the stored value is inert. `undefined` when there is nothing to
|
|
140
|
+
* say. The `/setup` page renders this before a run so the operator learns it BEFORE capturing
|
|
141
|
+
* credentials the deployment will ignore, and {@link resolveSlackSecret} logs it once per name.
|
|
142
|
+
*/
|
|
143
|
+
function slackCredentialSourceNotice(envName: string): string {
|
|
144
|
+
return (
|
|
145
|
+
`[open-everywhere:slack-setup] ${envName} is set in the environment, so this deployment uses the environment value and ` +
|
|
146
|
+
`IGNORES the one in the credential store. That is the documented order (OPE-2231): env wins, so landing this change cannot ` +
|
|
147
|
+
`alter which Slack app an existing deployment speaks as. To use the stored value instead, unset ${envName} and redeploy.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve one Slack secret: env if set, else the credential store, else `undefined`.
|
|
153
|
+
*
|
|
154
|
+
* `undefined` is a real answer, not an error — it means this deployment has not been through `/setup`
|
|
155
|
+
* and holds no env var either. Each caller fails in its own way (the front route 401s; an outbound
|
|
156
|
+
* call throws the message eve's primitive always threw), which is what keeps this function free of
|
|
157
|
+
* any opinion about what a missing credential should do.
|
|
158
|
+
*/
|
|
159
|
+
export async function resolveSlackSecret(
|
|
160
|
+
envName: string,
|
|
161
|
+
fromEnv: string,
|
|
162
|
+
ref: string,
|
|
163
|
+
deps: SlackCredentialDeps = {},
|
|
164
|
+
): Promise<ResolvedSlackSecret | undefined> {
|
|
165
|
+
// 🔴 THE ENV VALUE IS PASSED IN, NOT READ HERE, and that is a gate's requirement rather than a
|
|
166
|
+
// style choice. `lint:env-docs` resolves `env[SOME_CONST]` through the const so every variable the
|
|
167
|
+
// runtime reads can be named in `scripts/env-read-allowlist.ts`; a read through a PARAMETER
|
|
168
|
+
// (`env[envName]`) resolves to nothing, so the gate cannot tell which variable it reaches and
|
|
169
|
+
// refuses the site. The two public helpers below each do their own read with a module-level const
|
|
170
|
+
// as the key, which keeps both names visible to the allowlist — and leaves this function with no
|
|
171
|
+
// environment access at all.
|
|
172
|
+
|
|
173
|
+
// An injected vault means a test driving a specific store state — bypass the memo entirely, so a
|
|
174
|
+
// test can rewrite a row and see the effect without reaching for a reset.
|
|
175
|
+
const vault = deps.vault;
|
|
176
|
+
const now = (deps.now ?? Date.now)();
|
|
177
|
+
// The memo applies on the live path, and on an injected vault only when a test opts in — see
|
|
178
|
+
// `deps.memo`.
|
|
179
|
+
const memoApplies = vault === undefined || deps.memo === true;
|
|
180
|
+
const cached = memoApplies ? memo.get(ref) : undefined;
|
|
181
|
+
const memoised = cached !== undefined && cached.until > now ? cached.value : undefined;
|
|
182
|
+
|
|
183
|
+
if (fromEnv.length > 0) {
|
|
184
|
+
// Only say the env is shadowing a stored value when one is ACTUALLY stored — the notice is for
|
|
185
|
+
// the operator who ran `/setup` and is about to wonder why nothing changed, not for every
|
|
186
|
+
// deployment that has always used env.
|
|
187
|
+
//
|
|
188
|
+
// 🔴 THE CHECK IS MARKED DONE WHETHER OR NOT IT WARNED, and that is the whole reason this is a
|
|
189
|
+
// separate set from `noticed`. Keying the guard off "have I warned?" meant a deployment with the
|
|
190
|
+
// env var set and NOTHING stored — the common case, every pre-OPE-2231 install — never recorded
|
|
191
|
+
// that it had looked, so it re-read the vault on EVERY resolution: one store round trip per
|
|
192
|
+
// inbound Slack request, for an answer that was always going to be the env value. One read per
|
|
193
|
+
// name per process, then never again.
|
|
194
|
+
if (!shadowChecked.has(envName)) {
|
|
195
|
+
shadowChecked.add(envName);
|
|
196
|
+
const stored = memoised ?? (await readStored(ref, vault)); // best-effort; a store failure says nothing
|
|
197
|
+
if (stored !== undefined) {
|
|
198
|
+
noticed.add(envName);
|
|
199
|
+
console.warn(slackCredentialSourceNotice(envName));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { value: fromEnv, source: "env" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (memoised !== undefined) return { value: memoised, source: "store" };
|
|
206
|
+
|
|
207
|
+
// A remembered MISS, still inside its TTL — answer without touching the store. Skipped entirely for
|
|
208
|
+
// an injected vault (a test driving a specific store state).
|
|
209
|
+
if (memoApplies) {
|
|
210
|
+
const until = missUntil.get(ref);
|
|
211
|
+
if (until !== undefined && until > now) return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const stored = await readStored(ref, vault);
|
|
215
|
+
if (stored === undefined) {
|
|
216
|
+
if (memoApplies) missUntil.set(ref, now + MISS_TTL_MS);
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
if (memoApplies) {
|
|
220
|
+
memo.set(ref, { value: stored, until: now + HIT_TTL_MS });
|
|
221
|
+
missUntil.delete(ref);
|
|
222
|
+
}
|
|
223
|
+
return { value: stored, source: "store" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Read one ref out of the vault, fail-soft to `undefined`. A disarmed credential key, an
|
|
227
|
+
* unreachable store and an absent row are one answer here on purpose: all three mean "the store has
|
|
228
|
+
* nothing for me", and the caller's next step is the same for each. */
|
|
229
|
+
async function readStored(ref: string, injected: EncryptedKvVault | undefined): Promise<string | undefined> {
|
|
230
|
+
try {
|
|
231
|
+
const value = await (injected ?? encryptedKvVault()).read(ref);
|
|
232
|
+
return value === null || value.length === 0 ? undefined : value;
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The app's request-signing secret, or `undefined` when this deployment holds none. */
|
|
239
|
+
export async function slackSigningSecret(deps: SlackCredentialDeps = {}): Promise<string | undefined> {
|
|
240
|
+
const env = deps.env ?? process.env;
|
|
241
|
+
const fromEnv = (env[SLACK_SIGNING_SECRET_ENV] ?? "").trim();
|
|
242
|
+
return (await resolveSlackSecret(SLACK_SIGNING_SECRET_ENV, fromEnv, SLACK_SIGNING_SECRET_REF, deps))?.value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The workspace bot token, or `undefined` when this deployment holds none. */
|
|
246
|
+
export async function slackBotToken(deps: SlackCredentialDeps = {}): Promise<string | undefined> {
|
|
247
|
+
const env = deps.env ?? process.env;
|
|
248
|
+
const fromEnv = (env[SLACK_BOT_TOKEN_ENV] ?? "").trim();
|
|
249
|
+
return (await resolveSlackSecret(SLACK_BOT_TOKEN_ENV, fromEnv, SLACK_BOT_TOKEN_REF, deps))?.value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Is `name`'s env var set, for the ONE caller that needs to ask without resolving — the `/setup`
|
|
254
|
+
* page's "this env var will shadow what you are about to store" notice.
|
|
255
|
+
*
|
|
256
|
+
* Unrolled rather than looped over the two names for the same reason {@link resolveSlackSecret} takes
|
|
257
|
+
* its env value as a parameter: the gate resolves a const key and nothing else.
|
|
258
|
+
*/
|
|
259
|
+
export function slackSecretEnvPresent(deps: SlackCredentialDeps = {}): { readonly signingSecret: boolean; readonly botToken: boolean } {
|
|
260
|
+
const env = deps.env ?? process.env;
|
|
261
|
+
return {
|
|
262
|
+
signingSecret: (env[SLACK_SIGNING_SECRET_ENV] ?? "").trim().length > 0,
|
|
263
|
+
botToken: (env[SLACK_BOT_TOKEN_ENV] ?? "").trim().length > 0,
|
|
264
|
+
};
|
|
265
|
+
}
|