@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,77 @@
|
|
|
1
|
+
// WHAT EVE IS TOLD ABOUT THIS DEPLOYMENT'S SLACK CREDENTIALS (OPE-2231).
|
|
2
|
+
//
|
|
3
|
+
// 🔴 WHY THIS FILE HAD TO EXIST, AND THE DEFECT IT CLOSES. OE's front route is not the only thing
|
|
4
|
+
// that verifies a Slack signature. It verifies, then FORWARDS to eve's own `slackChannel` POST
|
|
5
|
+
// handler, and eve verifies AGAIN — `verifyInbound` in eve's `slackChannel.js`:
|
|
6
|
+
//
|
|
7
|
+
// signingSecret: credentials?.signingSecret ?? (credentials?.webhookVerifier ? undefined
|
|
8
|
+
// : process.env.SLACK_SIGNING_SECRET)
|
|
9
|
+
//
|
|
10
|
+
// `channels/slack.ts` passed NO `credentials`, so eve resolved the secret from `process.env` and
|
|
11
|
+
// nowhere else. Moving OE's own read to the credential store therefore produced a deployment where
|
|
12
|
+
// OE's half of the verification passed and EVE's half 401'd: a `/setup`-configured deployment with no
|
|
13
|
+
// env vars — the exact case the page exists to produce — would have acked nothing, woken nothing, and
|
|
14
|
+
// said only `unauthorized`. Eve's outbound path had the same shape: `buildSlackBinding({ botToken:
|
|
15
|
+
// credentials?.botToken })` falls back to `resolveSlackBotToken`, which throws
|
|
16
|
+
// `SLACK_BOT_TOKEN is required.`
|
|
17
|
+
//
|
|
18
|
+
// So the migration is only complete when EVE resolves through the same seam OE does. That is what
|
|
19
|
+
// these two arms are. Both are FUNCTIONS, not values, because the resolution is async and because a
|
|
20
|
+
// value read at module load would pin whatever was true at boot — before `/setup` ran.
|
|
21
|
+
//
|
|
22
|
+
// `webhookVerifier` rather than `signingSecret`: eve takes `signingSecret` as a plain string, which
|
|
23
|
+
// means reading it once at module load. Supplying the verifier instead also makes eve SKIP its
|
|
24
|
+
// `process.env.SLACK_SIGNING_SECRET` fallback entirely (see the expression above), so there is no path
|
|
25
|
+
// left on which eve and OE can disagree about which secret this request is held to.
|
|
26
|
+
|
|
27
|
+
import { slackSignatureMatches, SLACK_SIGNATURE_HEADER, SLACK_TIMESTAMP_HEADER } from "../channels/slack-signature.js";
|
|
28
|
+
import { slackBotToken, slackSigningSecret } from "./credentials.js";
|
|
29
|
+
|
|
30
|
+
/** How far a request's signed timestamp may be from now. Slack's own recommendation, and the same
|
|
31
|
+
* bound OE's front route applies — the two verifications must not differ on skew either. */
|
|
32
|
+
const MAX_SKEW_SECONDS = 60 * 5;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Eve's inbound verifier. THROWS to reject — eve catches, logs, and answers 401, so a thrown error
|
|
36
|
+
* here is the refusal. It must never return a verdict as a value.
|
|
37
|
+
*
|
|
38
|
+
* The message names no secret and no header value: a verification failure is one of three things to
|
|
39
|
+
* an attacker (no secret configured, bad signature, stale timestamp) and telling them which is free
|
|
40
|
+
* information. The three are distinguished in the thrown text only to the extent an OPERATOR reading
|
|
41
|
+
* their own logs needs, which is why it names the CONDITION and never the input.
|
|
42
|
+
*/
|
|
43
|
+
export async function verifySlackInboundForEve(request: Request, body: string): Promise<void> {
|
|
44
|
+
const secret = await slackSigningSecret();
|
|
45
|
+
if (!secret) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"SLACK_SIGNING_SECRET is not set and no signing secret is stored — this deployment cannot verify a Slack request. " +
|
|
48
|
+
"Run /setup, or set SLACK_SIGNING_SECRET.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const timestamp = request.headers.get(SLACK_TIMESTAMP_HEADER);
|
|
52
|
+
const signature = request.headers.get(SLACK_SIGNATURE_HEADER);
|
|
53
|
+
if (!timestamp || !signature) throw new Error("slack inbound: the request carries no signature headers.");
|
|
54
|
+
|
|
55
|
+
// The timestamp is INSIDE the signed basestring, which is what makes a skew check meaningful here.
|
|
56
|
+
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
|
|
57
|
+
if (!Number.isFinite(skew) || skew > MAX_SKEW_SECONDS) throw new Error("slack inbound: the signed timestamp is outside the accepted window.");
|
|
58
|
+
|
|
59
|
+
if (!slackSignatureMatches(secret, timestamp, body, signature)) throw new Error("slack inbound: the signature does not match.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Eve's outbound bot-token provider. Throws eve's OWN message when none resolves, byte for byte, so a
|
|
64
|
+
* misconfigured deployment produces the error every runbook and every existing test already names.
|
|
65
|
+
*/
|
|
66
|
+
export async function slackBotTokenForEve(): Promise<string> {
|
|
67
|
+
const token = await slackBotToken();
|
|
68
|
+
if (!token) throw new Error("SLACK_BOT_TOKEN is required.");
|
|
69
|
+
return token;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The `credentials` block for `slackChannel(...)`. One object, so the two halves cannot be wired
|
|
73
|
+
* independently and leave eve resolving one of them from the environment. */
|
|
74
|
+
export const SLACK_EVE_CREDENTIALS = {
|
|
75
|
+
webhookVerifier: verifySlackInboundForEve,
|
|
76
|
+
botToken: slackBotTokenForEve,
|
|
77
|
+
} as const;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// THE SLACK INSTALL RECORD (OPE-2231) — what a `/setup` run captured, and the ONE predicate the
|
|
2
|
+
// rest of the deployment asks about it.
|
|
3
|
+
//
|
|
4
|
+
// WHERE IT LIVES. Every field sits in the encrypted KV vault (core `credentials/kv-vault.ts`) under
|
|
5
|
+
// the reserved `slack-app:` namespace, so the outbound credential resolver refuses all of it — see
|
|
6
|
+
// `SLACK_APP_REF_NAMESPACE`'s own doc comment for why the bot token is reserved too. Four refs hold
|
|
7
|
+
// SECRET material and one holds the non-secret record:
|
|
8
|
+
//
|
|
9
|
+
// slack-app:client_secret the app's OAuth client secret (apps.manifest.create returned it)
|
|
10
|
+
// slack-app:signing_secret the app's request-signing secret (apps.manifest.create returned it)
|
|
11
|
+
// slack-app:bot_token the workspace bot token (oauth.v2.access returned it)
|
|
12
|
+
// slack-app:cookie_key the HMAC key for the claim cookie (this deployment minted it)
|
|
13
|
+
// slack-app:install the record below, as JSON (ids and timestamps only)
|
|
14
|
+
//
|
|
15
|
+
// 🔴 THE RECORD CARRIES NO SECRET, AND THAT IS WHAT MAKES IT READABLE. {@link slackInstallState} is
|
|
16
|
+
// the seam a STATUS ENDPOINT reads (OPE-2230 builds that endpoint; this function is its whole
|
|
17
|
+
// contract with Slack). A status endpoint is a place where a value gets rendered to whoever can
|
|
18
|
+
// reach it, so the thing it reads must be unable to carry one. Hence the split: ids and timestamps
|
|
19
|
+
// in `slack-app:install`, every secret under its own ref, and no function here that returns both.
|
|
20
|
+
// Adding a field to {@link SlackInstallState} means asking "would I publish this?" — if the answer
|
|
21
|
+
// is no, it belongs in its own ref instead.
|
|
22
|
+
//
|
|
23
|
+
// 🔴 THE RECORD IS THE LOCK. `/setup` is open while no record exists and closed once one does
|
|
24
|
+
// (`agent/setup/claim.ts` reads exactly this). So writing the record is the LAST act of a
|
|
25
|
+
// successful install, after both secrets are stored — a record written first would lock the page
|
|
26
|
+
// against a run that had not finished, with no way back in but the reopen call.
|
|
27
|
+
|
|
28
|
+
import { encryptedKvVault, SLACK_APP_REF_NAMESPACE, type EncryptedKvVault } from "@open-everywhere/core/agent/adapter-api.js";
|
|
29
|
+
|
|
30
|
+
/** The ref holding the app's OAuth client secret. SECRET — never rendered, never logged. */
|
|
31
|
+
export const SLACK_CLIENT_SECRET_REF = `${SLACK_APP_REF_NAMESPACE}client_secret`;
|
|
32
|
+
/** The ref holding the app's request-signing secret. SECRET. */
|
|
33
|
+
export const SLACK_SIGNING_SECRET_REF = `${SLACK_APP_REF_NAMESPACE}signing_secret`;
|
|
34
|
+
/** The ref holding the workspace bot token (`xoxb-…`). SECRET. */
|
|
35
|
+
export const SLACK_BOT_TOKEN_REF = `${SLACK_APP_REF_NAMESPACE}bot_token`;
|
|
36
|
+
/**
|
|
37
|
+
* The ref holding a PENDING app's signing secret, between the create and the OAuth callback.
|
|
38
|
+
*
|
|
39
|
+
* 🔴 WHY A SECOND REF AND NOT JUST {@link SLACK_SIGNING_SECRET_REF}. That ref is LIVE — it is what
|
|
40
|
+
* verifies every inbound Slack request. Writing the new app's secret straight into it at the create
|
|
41
|
+
* step would swap the running deployment's signing key before the install existed, so an ABANDONED
|
|
42
|
+
* run (wrong workspace, expired token, closed tab) left an installed deployment unable to verify its
|
|
43
|
+
* own workspace's requests: every cold start from then on 401s, staggered by the per-process memo, with
|
|
44
|
+
* nothing in the logs saying a different secret had been picked. The callback promotes this to the live
|
|
45
|
+
* ref immediately before it records the install, so the live secret changes only when a run COMPLETES.
|
|
46
|
+
*/
|
|
47
|
+
export const SLACK_PENDING_SIGNING_SECRET_REF = `${SLACK_APP_REF_NAMESPACE}pending_signing_secret`;
|
|
48
|
+
/** The ref holding the HMAC key for the claim cookie. SECRET, and minted here — see claim.ts. */
|
|
49
|
+
export const SLACK_COOKIE_KEY_REF = `${SLACK_APP_REF_NAMESPACE}cookie_key`;
|
|
50
|
+
/** The ref holding the one-time setup code a human reads out of the deployment's logs. SECRET. */
|
|
51
|
+
export const SLACK_SETUP_CODE_REF = `${SLACK_APP_REF_NAMESPACE}setup_code`;
|
|
52
|
+
/** The ref holding {@link SlackInstallState} as JSON. The ONLY ref here with no secret in it. */
|
|
53
|
+
export const SLACK_INSTALL_REF = `${SLACK_APP_REF_NAMESPACE}install`;
|
|
54
|
+
/** The ref holding the PENDING app record (`agent/setup/page.ts` writes and spends it). Declared here
|
|
55
|
+
* so one module owns the ref vocabulary — `reopenSlackSetup` has to clear it, and a ref it could not
|
|
56
|
+
* name is a ref it would leave behind. */
|
|
57
|
+
export const SLACK_PENDING_REF = `${SLACK_APP_REF_NAMESPACE}pending`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a completed `/setup` run recorded. IDS AND TIMESTAMPS ONLY — see the header on why.
|
|
61
|
+
*
|
|
62
|
+
* `v` guards a future shape change: an unreadable or wrong-version record reads as NO install, which
|
|
63
|
+
* fails in the safe direction (the page reopens; it does not grant anything).
|
|
64
|
+
*/
|
|
65
|
+
export interface SlackInstallState {
|
|
66
|
+
readonly v: 1;
|
|
67
|
+
/** The Slack app id (`A…`) `apps.manifest.create` returned. */
|
|
68
|
+
readonly appId: string;
|
|
69
|
+
/** The app's OAuth client id. Public by Slack's own design (it rides the authorize URL). */
|
|
70
|
+
readonly clientId: string;
|
|
71
|
+
/** The workspace (team) id (`T…`) the OAuth install landed in. */
|
|
72
|
+
readonly teamId: string;
|
|
73
|
+
/** The workspace's display name, when `oauth.v2.access` reported one. */
|
|
74
|
+
readonly teamName?: string;
|
|
75
|
+
/** The installing member's id (`U…`) — `authed_user.id` on the OAuth response. */
|
|
76
|
+
readonly installerUserId: string;
|
|
77
|
+
/** ISO-8601 instant the install completed. */
|
|
78
|
+
readonly installedAt: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Injectable seams for tests — the live default is the real encrypted KV vault. */
|
|
82
|
+
export interface SlackInstallStateDeps {
|
|
83
|
+
readonly vault?: EncryptedKvVault;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function vaultOf(deps: SlackInstallStateDeps): EncryptedKvVault {
|
|
87
|
+
return deps.vault ?? encryptedKvVault();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The recorded Slack install, or `undefined` when this deployment has none.
|
|
92
|
+
*
|
|
93
|
+
* 🔴 THIS IS THE SEAM OPE-2230's STATUS ENDPOINT READS, and the only one it should: it returns the
|
|
94
|
+
* whole non-secret truth about whether Slack is installed and where, and it can return nothing
|
|
95
|
+
* else. Call it; do not read `slack-app:install` directly, or the status endpoint and the claim lock
|
|
96
|
+
* end up with two readings of one record.
|
|
97
|
+
*
|
|
98
|
+
* FAIL-SOFT TO "NOT INSTALLED". An unreachable vault, a disarmed credential key, a malformed row or
|
|
99
|
+
* a record of an unknown version all answer `undefined`. For a STATUS READER that is the safe
|
|
100
|
+
* direction and the honest one: it reports Slack as not installed, which is true as far as this
|
|
101
|
+
* process can tell, and it cannot report an install that is not there.
|
|
102
|
+
*
|
|
103
|
+
* 🔴 THE CLAIM LOCK MUST NOT USE THIS FUNCTION. For the lock, the same collapse is a hole — a store
|
|
104
|
+
* fault would reopen `/setup`. The lock reads {@link readInstallRecord} instead, which tells the
|
|
105
|
+
* three cases apart. See its doc comment.
|
|
106
|
+
*/
|
|
107
|
+
export async function slackInstallState(deps: SlackInstallStateDeps = {}): Promise<SlackInstallState | undefined> {
|
|
108
|
+
return (await readInstallRecord(deps)).state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What a read of the install record actually found — THREE outcomes, not two.
|
|
113
|
+
*
|
|
114
|
+
* 🔴 WHY THE LOCK NEEDS THIS AND THE STATUS SEAM DOES NOT. {@link slackInstallState} collapses every
|
|
115
|
+
* unreadable case into `undefined`, which is right for a status endpoint: "no install I can see" is
|
|
116
|
+
* the honest answer whether the store is unreachable or the row is absent, and it cannot report an
|
|
117
|
+
* install that is not there. For the LOCK that collapse is a hole. The lock asks the opposite
|
|
118
|
+
* question — "may this page serve?" — and `undefined` meaning both "nothing stored" and "I could not
|
|
119
|
+
* tell" makes a store fault, a malformed row or a row from a future version REOPEN `/setup`. The
|
|
120
|
+
* setup code is in the deployment's old log lines from before the install, so a reopen is a window
|
|
121
|
+
* for whoever read them.
|
|
122
|
+
*
|
|
123
|
+
* So the lock distinguishes: `absent` is the ONLY outcome that opens the page. `unreadable` covers a
|
|
124
|
+
* throw, a non-JSON row, and a row whose `v` this build does not know — all of which mean "something
|
|
125
|
+
* IS stored and I cannot read it", which is never a reason to grant. The cost of that is a setup page
|
|
126
|
+
* that is briefly unavailable during a store outage, recoverable by a retry or the control call; the
|
|
127
|
+
* cost of the other direction is not recoverable at all.
|
|
128
|
+
*/
|
|
129
|
+
export type InstallRecordRead =
|
|
130
|
+
| { readonly kind: "present"; readonly state: SlackInstallState }
|
|
131
|
+
| { readonly kind: "absent"; readonly state: undefined }
|
|
132
|
+
| { readonly kind: "unreadable"; readonly state: undefined };
|
|
133
|
+
|
|
134
|
+
/** Read the install record, reporting WHICH of the three outcomes happened. The lock reads this. */
|
|
135
|
+
export async function readInstallRecord(deps: SlackInstallStateDeps = {}): Promise<InstallRecordRead> {
|
|
136
|
+
let raw: string | null;
|
|
137
|
+
try {
|
|
138
|
+
raw = await vaultOf(deps).read(SLACK_INSTALL_REF);
|
|
139
|
+
} catch {
|
|
140
|
+
return { kind: "unreadable", state: undefined };
|
|
141
|
+
}
|
|
142
|
+
if (raw === null) return { kind: "absent", state: undefined };
|
|
143
|
+
const state = parseInstallState(raw);
|
|
144
|
+
// A row that exists but will not parse is the `unreadable` case, NOT `absent`: something is stored.
|
|
145
|
+
if (state === undefined) return { kind: "unreadable", state: undefined };
|
|
146
|
+
return { kind: "present", state };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Parse + validate a stored record. Exported for the suite; `undefined` for anything unreadable. */
|
|
150
|
+
export function parseInstallState(raw: string): SlackInstallState | undefined {
|
|
151
|
+
let parsed: unknown;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(raw);
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
158
|
+
const r = parsed as Record<string, unknown>;
|
|
159
|
+
if (r.v !== 1) return undefined;
|
|
160
|
+
const str = (k: string): string | undefined => (typeof r[k] === "string" && (r[k] as string).length > 0 ? (r[k] as string) : undefined);
|
|
161
|
+
const appId = str("appId");
|
|
162
|
+
const clientId = str("clientId");
|
|
163
|
+
const teamId = str("teamId");
|
|
164
|
+
const installerUserId = str("installerUserId");
|
|
165
|
+
const installedAt = str("installedAt");
|
|
166
|
+
if (!appId || !clientId || !teamId || !installerUserId || !installedAt) return undefined;
|
|
167
|
+
const teamName = str("teamName");
|
|
168
|
+
return {
|
|
169
|
+
v: 1,
|
|
170
|
+
appId,
|
|
171
|
+
clientId,
|
|
172
|
+
teamId,
|
|
173
|
+
installerUserId,
|
|
174
|
+
installedAt,
|
|
175
|
+
...(teamName ? { teamName } : {}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Record a completed install. THE LAST WRITE OF A SUCCESSFUL `/setup` RUN — it is what closes the
|
|
181
|
+
* page, so every secret the install captured must already be stored when this is called.
|
|
182
|
+
*
|
|
183
|
+
* Throws whatever the vault throws (a disarmed credential key, an unreachable store). The caller
|
|
184
|
+
* must NOT swallow it: a run that stored the credentials but failed to record the install has to
|
|
185
|
+
* say so, because the page is still open and a second run would mint a second Slack app.
|
|
186
|
+
*/
|
|
187
|
+
export async function recordSlackInstall(state: SlackInstallState, deps: SlackInstallStateDeps = {}): Promise<void> {
|
|
188
|
+
await vaultOf(deps).write(SLACK_INSTALL_REF, JSON.stringify(state), { connector: "slack", createdBy: "host:slack-setup" });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* REOPEN the setup page: destroy the install record, keeping every stored credential.
|
|
193
|
+
*
|
|
194
|
+
* WHY THE LIVE CREDENTIALS STAY. A reopen is "let me run setup again", not "forget this workspace".
|
|
195
|
+
* The running deployment is still serving Slack with the stored bot token and signing secret while the
|
|
196
|
+
* admin walks the page, and a reopen that deleted them would take the agent off Slack the moment it
|
|
197
|
+
* was called. A COMPLETED second run replaces them, at its callback; an abandoned one leaves the
|
|
198
|
+
* working install untouched, which is true because the create step stages the new signing secret under
|
|
199
|
+
* {@link SLACK_PENDING_SIGNING_SECRET_REF} rather than writing the live ref.
|
|
200
|
+
*
|
|
201
|
+
* What DOES go, besides the record: the setup code (so the reopened page needs a fresh one — see
|
|
202
|
+
* below) and any stale pending-app material.
|
|
203
|
+
*
|
|
204
|
+
* THROWS on a failure to rotate the setup code or the claim cookie key, and leaves the install record
|
|
205
|
+
* in place when it does — see the body. A reopen that could not rotate must not open the page.
|
|
206
|
+
*
|
|
207
|
+
* NO REOPEN HISTORY SURVIVES THIS, deliberately. The record IS the lock, so reopening means
|
|
208
|
+
* destroying it, and a field recording "reopened at" would have to live on a record that no longer
|
|
209
|
+
* exists. The audit row is where the history lives (`slack.setup_reopened`, audit/action-registry.ts)
|
|
210
|
+
* — a hash-chained row naming the admin who called it, which is a better home for that fact than a
|
|
211
|
+
* field on a row the next install overwrites.
|
|
212
|
+
*
|
|
213
|
+
* Returns true iff a record went (false when the page was already open) — idempotent either way.
|
|
214
|
+
*/
|
|
215
|
+
export async function reopenSlackSetup(deps: SlackInstallStateDeps = {}): Promise<boolean> {
|
|
216
|
+
const vault = vaultOf(deps);
|
|
217
|
+
return vault.withLock(SLACK_INSTALL_REF, async () => {
|
|
218
|
+
// 🔴 THE CLAIM CREDENTIALS DIE BEFORE THE LOCK DOES, and the order is the whole safety property.
|
|
219
|
+
//
|
|
220
|
+
// The record IS the lock, so deleting it is what opens the page. An earlier draft deleted it FIRST
|
|
221
|
+
// and then rotated these two with `.catch(() => undefined)`, returning `true` either way — the
|
|
222
|
+
// exact inverse of the rule `setup/page.ts` states for the install path. That left a real hole: if
|
|
223
|
+
// the setup-code delete failed, or the lambda was cut between the two, the page was OPEN with the
|
|
224
|
+
// PRE-INSTALL code still matching, and that code is in the deployment's log lines from before the
|
|
225
|
+
// install. Whoever read them could then claim the run.
|
|
226
|
+
//
|
|
227
|
+
// So: rotate first, open second, and let a rotation failure THROW. `vault.delete` answers `false`
|
|
228
|
+
// for an absent row rather than throwing, so the only thing that propagates here is a real store
|
|
229
|
+
// failure — and on one the record survives, the page stays shut, and the control verb reports the
|
|
230
|
+
// error instead of a reopen that left the door open.
|
|
231
|
+
//
|
|
232
|
+
// `slackSetupCode` returns the STORED value when one exists, so dropping the ref is what makes the
|
|
233
|
+
// next boot mint and log a fresh code. The cookie key goes with it: a claim cookie minted before
|
|
234
|
+
// the reopen is signed with that key, so leaving it would let a browser still holding one walk
|
|
235
|
+
// straight past the code prompt and undo the rotation.
|
|
236
|
+
await vault.delete(SLACK_SETUP_CODE_REF);
|
|
237
|
+
await vault.delete(SLACK_COOKIE_KEY_REF);
|
|
238
|
+
|
|
239
|
+
const reopened = await vault.delete(SLACK_INSTALL_REF);
|
|
240
|
+
|
|
241
|
+
// Stale pending-app material, AFTER the record. These two are not credentials and not the lock:
|
|
242
|
+
// the worst a leftover does is offer the install screen for an app the previous run abandoned, and
|
|
243
|
+
// a new run overwrites both. So a failure here is swallowed rather than failing the reopen.
|
|
244
|
+
await vault.delete(SLACK_PENDING_SIGNING_SECRET_REF).catch(() => undefined);
|
|
245
|
+
await vault.delete(SLACK_PENDING_REF).catch(() => undefined);
|
|
246
|
+
return reopened;
|
|
247
|
+
});
|
|
248
|
+
}
|