@vellumai/assistant 0.11.2-dev.202608070534.d55eaf1 → 0.11.2-dev.202608070733.a268a16
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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/openapi.yaml +12 -0
- package/package.json +1 -1
- package/src/__tests__/manual-token-reconciliation.test.ts +45 -0
- package/src/__tests__/oauth-provider-serializer.test.ts +17 -0
- package/src/__tests__/oauth-providers-routes.test.ts +1 -0
- package/src/cli/commands/oauth/providers.ts +3 -0
- package/src/oauth/byo-connection.ts +6 -1
- package/src/oauth/credential-token-resolver.ts +3 -10
- package/src/oauth/manual-token-connection.ts +29 -64
- package/src/oauth/manual-token-providers.ts +62 -0
- package/src/oauth/provider-serializer.ts +13 -0
- package/src/oauth/seed-providers.ts +35 -7
- package/src/runtime/routes/oauth-providers.ts +1 -0
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -40,3 +40,42 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
40
40
|
(CHANNEL_IDS as readonly string[]).includes(value)
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The provider key holding each channel's bot credential.
|
|
46
|
+
*
|
|
47
|
+
* Two senses of "connected" share the provider registry: a provider the user
|
|
48
|
+
* authorized so the assistant can act **as them** (`slack`, `discord`,
|
|
49
|
+
* `google`), and a bot credential letting people reach the assistant **as
|
|
50
|
+
* itself**. Nothing in a provider key says which, and the naming actively
|
|
51
|
+
* misleads: `slack` and `discord` name the user integration while their bots
|
|
52
|
+
* take a `_channel` suffix, yet `telegram` *is* the bot, because Telegram has
|
|
53
|
+
* no user-identity integration.
|
|
54
|
+
*
|
|
55
|
+
* That irregularity is why this is stated rather than derived from the key,
|
|
56
|
+
* and it is stated here because this file already owns what a channel is.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately only the key. What fields each credential requires is declared
|
|
59
|
+
* once already, per service, in the gateway's credential specs; restating it
|
|
60
|
+
* here would be a second copy of a different fact.
|
|
61
|
+
*
|
|
62
|
+
* Channels absent from this map reach the assistant without a bot credential
|
|
63
|
+
* of their own: `phone` through the voice provider, `vellum` and `platform`
|
|
64
|
+
* internally.
|
|
65
|
+
*/
|
|
66
|
+
export const CHANNEL_BOT_PROVIDER = {
|
|
67
|
+
slack: "slack_channel",
|
|
68
|
+
discord: "discord_channel",
|
|
69
|
+
telegram: "telegram",
|
|
70
|
+
} as const satisfies Partial<Record<ChannelId, string>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a provider key names a bot the assistant is reached through, rather
|
|
74
|
+
* than a grant letting it act as the user. This is the "which sense of
|
|
75
|
+
* connected" question, for any provider key.
|
|
76
|
+
*/
|
|
77
|
+
export function isChannelBotProvider(providerKey: string): boolean {
|
|
78
|
+
return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
|
|
79
|
+
providerKey,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -40,3 +40,42 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
40
40
|
(CHANNEL_IDS as readonly string[]).includes(value)
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The provider key holding each channel's bot credential.
|
|
46
|
+
*
|
|
47
|
+
* Two senses of "connected" share the provider registry: a provider the user
|
|
48
|
+
* authorized so the assistant can act **as them** (`slack`, `discord`,
|
|
49
|
+
* `google`), and a bot credential letting people reach the assistant **as
|
|
50
|
+
* itself**. Nothing in a provider key says which, and the naming actively
|
|
51
|
+
* misleads: `slack` and `discord` name the user integration while their bots
|
|
52
|
+
* take a `_channel` suffix, yet `telegram` *is* the bot, because Telegram has
|
|
53
|
+
* no user-identity integration.
|
|
54
|
+
*
|
|
55
|
+
* That irregularity is why this is stated rather than derived from the key,
|
|
56
|
+
* and it is stated here because this file already owns what a channel is.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately only the key. What fields each credential requires is declared
|
|
59
|
+
* once already, per service, in the gateway's credential specs; restating it
|
|
60
|
+
* here would be a second copy of a different fact.
|
|
61
|
+
*
|
|
62
|
+
* Channels absent from this map reach the assistant without a bot credential
|
|
63
|
+
* of their own: `phone` through the voice provider, `vellum` and `platform`
|
|
64
|
+
* internally.
|
|
65
|
+
*/
|
|
66
|
+
export const CHANNEL_BOT_PROVIDER = {
|
|
67
|
+
slack: "slack_channel",
|
|
68
|
+
discord: "discord_channel",
|
|
69
|
+
telegram: "telegram",
|
|
70
|
+
} as const satisfies Partial<Record<ChannelId, string>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a provider key names a bot the assistant is reached through, rather
|
|
74
|
+
* than a grant letting it act as the user. This is the "which sense of
|
|
75
|
+
* connected" question, for any provider key.
|
|
76
|
+
*/
|
|
77
|
+
export function isChannelBotProvider(providerKey: string): boolean {
|
|
78
|
+
return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
|
|
79
|
+
providerKey,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -40,3 +40,42 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
40
40
|
(CHANNEL_IDS as readonly string[]).includes(value)
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The provider key holding each channel's bot credential.
|
|
46
|
+
*
|
|
47
|
+
* Two senses of "connected" share the provider registry: a provider the user
|
|
48
|
+
* authorized so the assistant can act **as them** (`slack`, `discord`,
|
|
49
|
+
* `google`), and a bot credential letting people reach the assistant **as
|
|
50
|
+
* itself**. Nothing in a provider key says which, and the naming actively
|
|
51
|
+
* misleads: `slack` and `discord` name the user integration while their bots
|
|
52
|
+
* take a `_channel` suffix, yet `telegram` *is* the bot, because Telegram has
|
|
53
|
+
* no user-identity integration.
|
|
54
|
+
*
|
|
55
|
+
* That irregularity is why this is stated rather than derived from the key,
|
|
56
|
+
* and it is stated here because this file already owns what a channel is.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately only the key. What fields each credential requires is declared
|
|
59
|
+
* once already, per service, in the gateway's credential specs; restating it
|
|
60
|
+
* here would be a second copy of a different fact.
|
|
61
|
+
*
|
|
62
|
+
* Channels absent from this map reach the assistant without a bot credential
|
|
63
|
+
* of their own: `phone` through the voice provider, `vellum` and `platform`
|
|
64
|
+
* internally.
|
|
65
|
+
*/
|
|
66
|
+
export const CHANNEL_BOT_PROVIDER = {
|
|
67
|
+
slack: "slack_channel",
|
|
68
|
+
discord: "discord_channel",
|
|
69
|
+
telegram: "telegram",
|
|
70
|
+
} as const satisfies Partial<Record<ChannelId, string>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a provider key names a bot the assistant is reached through, rather
|
|
74
|
+
* than a grant letting it act as the user. This is the "which sense of
|
|
75
|
+
* connected" question, for any provider key.
|
|
76
|
+
*/
|
|
77
|
+
export function isChannelBotProvider(providerKey: string): boolean {
|
|
78
|
+
return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
|
|
79
|
+
providerKey,
|
|
80
|
+
);
|
|
81
|
+
}
|
package/openapi.yaml
CHANGED
|
@@ -22151,6 +22151,11 @@ paths:
|
|
|
22151
22151
|
anyOf:
|
|
22152
22152
|
- type: string
|
|
22153
22153
|
- type: "null"
|
|
22154
|
+
acts_as:
|
|
22155
|
+
type: string
|
|
22156
|
+
enum:
|
|
22157
|
+
- user
|
|
22158
|
+
- assistant
|
|
22154
22159
|
required:
|
|
22155
22160
|
- provider_key
|
|
22156
22161
|
- display_name
|
|
@@ -22162,6 +22167,7 @@ paths:
|
|
|
22162
22167
|
- supports_managed_mode
|
|
22163
22168
|
- managed_service_is_paid
|
|
22164
22169
|
- feature_flag
|
|
22170
|
+
- acts_as
|
|
22165
22171
|
additionalProperties: false
|
|
22166
22172
|
- type: "null"
|
|
22167
22173
|
apps:
|
|
@@ -22679,6 +22685,11 @@ paths:
|
|
|
22679
22685
|
anyOf:
|
|
22680
22686
|
- type: string
|
|
22681
22687
|
- type: "null"
|
|
22688
|
+
acts_as:
|
|
22689
|
+
type: string
|
|
22690
|
+
enum:
|
|
22691
|
+
- user
|
|
22692
|
+
- assistant
|
|
22682
22693
|
required:
|
|
22683
22694
|
- provider_key
|
|
22684
22695
|
- display_name
|
|
@@ -22690,6 +22701,7 @@ paths:
|
|
|
22690
22701
|
- supports_managed_mode
|
|
22691
22702
|
- managed_service_is_paid
|
|
22692
22703
|
- feature_flag
|
|
22704
|
+
- acts_as
|
|
22693
22705
|
additionalProperties: false
|
|
22694
22706
|
required:
|
|
22695
22707
|
- providers
|
package/package.json
CHANGED
|
@@ -139,6 +139,51 @@ describe("syncManualTokenConnection", () => {
|
|
|
139
139
|
warnings.length = 0;
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
// ---- Discord server bot: one required field ----
|
|
143
|
+
|
|
144
|
+
test("connects discord_channel once the bot token is stored", async () => {
|
|
145
|
+
// Seeding the provider without this reconciliation leaves it listed and
|
|
146
|
+
// permanently disconnected, which is worse than not listing it: the
|
|
147
|
+
// setup skill stores the token and nothing ever reflects that.
|
|
148
|
+
setCredentialResult("discord_channel", "bot_token", {
|
|
149
|
+
value: "bot-token-value",
|
|
150
|
+
unreachable: false,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await syncManualTokenConnection("discord_channel");
|
|
154
|
+
|
|
155
|
+
expect(createdConnections.map((c) => c.provider)).toContain(
|
|
156
|
+
"discord_channel",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("removes the discord_channel connection when the bot token is gone", async () => {
|
|
161
|
+
seedConnection("discord_channel");
|
|
162
|
+
setCredentialResult("discord_channel", "bot_token", {
|
|
163
|
+
value: undefined,
|
|
164
|
+
unreachable: false,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await syncManualTokenConnection("discord_channel");
|
|
168
|
+
|
|
169
|
+
expect(deletedConnectionIds).toContain("conn-discord_channel");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("leaves the discord_channel row alone when the backend is unreachable", async () => {
|
|
173
|
+
// An unreachable credential store is not evidence the token is gone, so
|
|
174
|
+
// reconciliation must not read it as a disconnection.
|
|
175
|
+
seedConnection("discord_channel");
|
|
176
|
+
setCredentialResult("discord_channel", "bot_token", {
|
|
177
|
+
value: undefined,
|
|
178
|
+
unreachable: true,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await syncManualTokenConnection("discord_channel");
|
|
182
|
+
|
|
183
|
+
expect(deletedConnectionIds).not.toContain("conn-discord_channel");
|
|
184
|
+
expect(connectionStore["discord_channel"]).toBeDefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
142
187
|
// ---- Slack: reachable backend, missing tokens -> removes row ----
|
|
143
188
|
|
|
144
189
|
test("removes slack_channel connection when bot token is missing and backend is reachable", async () => {
|
|
@@ -291,9 +291,26 @@ describe("serializeProviderSummary", () => {
|
|
|
291
291
|
supports_managed_mode: true,
|
|
292
292
|
managed_service_is_paid: false,
|
|
293
293
|
feature_flag: null,
|
|
294
|
+
acts_as: "user",
|
|
294
295
|
});
|
|
295
296
|
});
|
|
296
297
|
|
|
298
|
+
test("names which sense of connected a provider represents", () => {
|
|
299
|
+
// The provider key does not say, and the naming actively misleads:
|
|
300
|
+
// `slack` is the user integration while its bot is `slack_channel`, but
|
|
301
|
+
// `telegram` is itself the bot. A caller asking "is X connected?" needs
|
|
302
|
+
// this to answer which X it found.
|
|
303
|
+
const sense = (provider: string) =>
|
|
304
|
+
serializeProviderSummary(makeRow({ provider }))!.acts_as;
|
|
305
|
+
|
|
306
|
+
expect(sense("slack")).toBe("user");
|
|
307
|
+
expect(sense("slack_channel")).toBe("assistant");
|
|
308
|
+
expect(sense("discord")).toBe("user");
|
|
309
|
+
expect(sense("discord_channel")).toBe("assistant");
|
|
310
|
+
expect(sense("telegram")).toBe("assistant");
|
|
311
|
+
expect(sense("google")).toBe("user");
|
|
312
|
+
});
|
|
313
|
+
|
|
297
314
|
test("nullifies missing optional fields", () => {
|
|
298
315
|
const row = makeRow({
|
|
299
316
|
displayLabel: null,
|
|
@@ -13,6 +13,8 @@ const log = getCliLogger("cli");
|
|
|
13
13
|
|
|
14
14
|
interface SerializedProvider {
|
|
15
15
|
providerKey: string;
|
|
16
|
+
/** Which sense of connected: a grant to act as the user, or a bot. */
|
|
17
|
+
actsAs?: "user" | "assistant";
|
|
16
18
|
displayName?: string | null;
|
|
17
19
|
description?: string | null;
|
|
18
20
|
supportsManagedMode?: boolean;
|
|
@@ -301,6 +303,7 @@ export function registerProviderCommands(oauth: Command): void {
|
|
|
301
303
|
dashboardUrl: p.dashboard_url as string | null,
|
|
302
304
|
clientIdPlaceholder: p.client_id_placeholder as string | null,
|
|
303
305
|
featureFlag: p.feature_flag as string | null,
|
|
306
|
+
actsAs: p.acts_as as "user" | "assistant" | undefined,
|
|
304
307
|
}));
|
|
305
308
|
|
|
306
309
|
if (opts.providerKey) {
|
|
@@ -47,6 +47,11 @@ export class BYOOAuthConnection implements OAuthConnection {
|
|
|
47
47
|
async (token) => {
|
|
48
48
|
const effectiveBaseUrl = req.baseUrl ?? this.baseUrl;
|
|
49
49
|
const isTelegram = this.provider === "telegram";
|
|
50
|
+
// Discord bot tokens authenticate with the `Bot ` scheme, not
|
|
51
|
+
// `Bearer`. Sending Bearer here reaches Discord as an unusable
|
|
52
|
+
// credential and comes back 401, which reads as a revoked token.
|
|
53
|
+
const authScheme =
|
|
54
|
+
this.provider === "discord_channel" ? "Bot" : "Bearer";
|
|
50
55
|
const requestPath = isTelegram
|
|
51
56
|
? buildTelegramBotApiPath(req.path, token)
|
|
52
57
|
: req.path;
|
|
@@ -87,7 +92,7 @@ export class BYOOAuthConnection implements OAuthConnection {
|
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
if (!isTelegram) {
|
|
90
|
-
headers.set("Authorization",
|
|
95
|
+
headers.set("Authorization", `${authScheme} ${token}`);
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
const resp = await fetch(fullUrl, {
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getSecureKeyResultAsync,
|
|
20
20
|
type SecureKeyResult,
|
|
21
21
|
} from "../security/secure-keys.js";
|
|
22
|
+
import { manualTokenProvider } from "./manual-token-providers.js";
|
|
22
23
|
|
|
23
24
|
// ── Types ─────────────────────────────────────────────────────────────
|
|
24
25
|
|
|
@@ -44,16 +45,8 @@ export interface ConnectionAccessTokenResult {
|
|
|
44
45
|
* rather than assuming the OAuth access-token path.
|
|
45
46
|
*/
|
|
46
47
|
function manualTokenAccessCredentialKey(provider: string): string | null {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return credentialKey("slack_channel", "bot_token");
|
|
50
|
-
case "telegram":
|
|
51
|
-
return credentialKey("telegram", "bot_token");
|
|
52
|
-
case "sanity":
|
|
53
|
-
return credentialKey("sanity", "api_token");
|
|
54
|
-
default:
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
48
|
+
const spec = manualTokenProvider(provider);
|
|
49
|
+
return spec ? credentialKey(provider, spec.accessField) : null;
|
|
57
50
|
}
|
|
58
51
|
|
|
59
52
|
/**
|
|
@@ -12,6 +12,10 @@ import { credentialKey } from "../security/credential-key.js";
|
|
|
12
12
|
import { getSecureKeyResultAsync } from "../security/secure-keys.js";
|
|
13
13
|
import { getTelegramBotUsername } from "../telegram/bot-username.js";
|
|
14
14
|
import { getLogger } from "../util/logger.js";
|
|
15
|
+
import {
|
|
16
|
+
MANUAL_TOKEN_PROVIDERS,
|
|
17
|
+
manualTokenProvider,
|
|
18
|
+
} from "./manual-token-providers.js";
|
|
15
19
|
import {
|
|
16
20
|
createConnection,
|
|
17
21
|
deleteConnection,
|
|
@@ -135,69 +139,30 @@ export async function syncManualTokenConnection(
|
|
|
135
139
|
resolvedAccountInfo,
|
|
136
140
|
);
|
|
137
141
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
);
|
|
143
|
-
const webhookSecretResult = await getSecureKeyResultAsync(
|
|
144
|
-
credentialKey("telegram", "webhook_secret"),
|
|
145
|
-
);
|
|
146
|
-
if (botTokenResult.unreachable || webhookSecretResult.unreachable) {
|
|
147
|
-
log.warn(
|
|
148
|
-
"Skipping telegram manual-token reconciliation — credential backend unreachable",
|
|
149
|
-
);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
if (botTokenResult.value && webhookSecretResult.value) {
|
|
153
|
-
await ensureManualTokenConnection(provider, accountInfoToStore);
|
|
154
|
-
} else {
|
|
155
|
-
removeManualTokenConnection(provider);
|
|
156
|
-
}
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
case "slack_channel": {
|
|
161
|
-
const botTokenResult = await getSecureKeyResultAsync(
|
|
162
|
-
credentialKey("slack_channel", "bot_token"),
|
|
163
|
-
);
|
|
164
|
-
const appTokenResult = await getSecureKeyResultAsync(
|
|
165
|
-
credentialKey("slack_channel", "app_token"),
|
|
166
|
-
);
|
|
167
|
-
if (botTokenResult.unreachable || appTokenResult.unreachable) {
|
|
168
|
-
log.warn(
|
|
169
|
-
"Skipping slack_channel manual-token reconciliation — credential backend unreachable",
|
|
170
|
-
);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (botTokenResult.value && appTokenResult.value) {
|
|
174
|
-
await ensureManualTokenConnection(provider, accountInfoToStore);
|
|
175
|
-
} else {
|
|
176
|
-
removeManualTokenConnection(provider);
|
|
177
|
-
}
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
142
|
+
const spec = manualTokenProvider(provider);
|
|
143
|
+
if (!spec) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
180
146
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
147
|
+
// Every field must be present and readable. An unreachable backend is not
|
|
148
|
+
// evidence a credential was removed, so the row is left as it stands rather
|
|
149
|
+
// than being read as a disconnection.
|
|
150
|
+
const results = await Promise.all(
|
|
151
|
+
spec.fields.map((field) =>
|
|
152
|
+
getSecureKeyResultAsync(credentialKey(provider, field)),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
if (results.some((r) => r.unreachable)) {
|
|
156
|
+
log.warn(
|
|
157
|
+
`Skipping ${provider} manual-token reconciliation: credential backend unreachable`,
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
198
161
|
|
|
199
|
-
|
|
200
|
-
|
|
162
|
+
if (results.every((r) => r.value)) {
|
|
163
|
+
await ensureManualTokenConnection(provider, accountInfoToStore);
|
|
164
|
+
} else {
|
|
165
|
+
removeManualTokenConnection(provider);
|
|
201
166
|
}
|
|
202
167
|
}
|
|
203
168
|
|
|
@@ -214,7 +179,7 @@ export async function syncManualTokenConnection(
|
|
|
214
179
|
* connection row.
|
|
215
180
|
*/
|
|
216
181
|
export async function backfillManualTokenConnections(): Promise<void> {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
182
|
+
for (const provider of Object.keys(MANUAL_TOKEN_PROVIDERS)) {
|
|
183
|
+
await syncManualTokenConnection(provider);
|
|
184
|
+
}
|
|
220
185
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers whose credentials are pasted in rather than granted through a
|
|
3
|
+
* browser flow, and what each one stores.
|
|
4
|
+
*
|
|
5
|
+
* Two questions are answered together because they are the same fact seen from
|
|
6
|
+
* two sides, and answering them in separate tables lets them drift: which
|
|
7
|
+
* fields must be present for the provider to count as configured
|
|
8
|
+
* (reconciliation), and which single field an API request authenticates with
|
|
9
|
+
* (token resolution). Slack stores an app token and Telegram a webhook secret,
|
|
10
|
+
* and neither authenticates a request.
|
|
11
|
+
*
|
|
12
|
+
* `accessField` is named rather than taken as the first of `fields`, so
|
|
13
|
+
* reordering that list cannot silently change which secret gets sent.
|
|
14
|
+
*
|
|
15
|
+
* Channel bots are keyed off the shared channel contract, so adding a channel
|
|
16
|
+
* does not mean remembering this file exists. `sanity` is not in that contract
|
|
17
|
+
* because it is a user's own API token: manual to enter, but not a bot and not
|
|
18
|
+
* a channel.
|
|
19
|
+
*
|
|
20
|
+
* The gateway declares the same field sets for its own reading of these
|
|
21
|
+
* credentials (`gateway/src/credential-reader.ts`). One declaration serving
|
|
22
|
+
* both is worth doing and needs the specs hoisted into a shared package.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { CHANNEL_BOT_PROVIDER } from "@vellumai/service-contracts/channels";
|
|
26
|
+
|
|
27
|
+
export interface ManualTokenProviderSpec {
|
|
28
|
+
/** Every field that must be present for the provider to count as connected. */
|
|
29
|
+
fields: readonly string[];
|
|
30
|
+
/** The one field an API request authenticates with. */
|
|
31
|
+
accessField: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const MANUAL_TOKEN_PROVIDERS: Record<string, ManualTokenProviderSpec> = {
|
|
35
|
+
[CHANNEL_BOT_PROVIDER.slack]: {
|
|
36
|
+
fields: ["bot_token", "app_token"],
|
|
37
|
+
accessField: "bot_token",
|
|
38
|
+
},
|
|
39
|
+
[CHANNEL_BOT_PROVIDER.discord]: {
|
|
40
|
+
fields: ["bot_token"],
|
|
41
|
+
accessField: "bot_token",
|
|
42
|
+
},
|
|
43
|
+
[CHANNEL_BOT_PROVIDER.telegram]: {
|
|
44
|
+
fields: ["bot_token", "webhook_secret"],
|
|
45
|
+
accessField: "bot_token",
|
|
46
|
+
},
|
|
47
|
+
sanity: { fields: ["api_token"], accessField: "api_token" },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The spec for a provider, or undefined when it is not a manual-token one.
|
|
52
|
+
*
|
|
53
|
+
* Own-property lookup so an inherited key (`constructor`, `__proto__`) reads
|
|
54
|
+
* as absent rather than returning a function.
|
|
55
|
+
*/
|
|
56
|
+
export function manualTokenProvider(
|
|
57
|
+
provider: string,
|
|
58
|
+
): ManualTokenProviderSpec | undefined {
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(MANUAL_TOKEN_PROVIDERS, provider)
|
|
60
|
+
? MANUAL_TOKEN_PROVIDERS[provider]
|
|
61
|
+
: undefined;
|
|
62
|
+
}
|
|
@@ -18,6 +18,8 @@ import type { OAuthProviderRow } from "./oauth-store.js";
|
|
|
18
18
|
export type SerializedProvider = ReturnType<typeof serializeProvider> &
|
|
19
19
|
Record<string, unknown>;
|
|
20
20
|
|
|
21
|
+
import { isChannelBotProvider } from "@vellumai/service-contracts/channels";
|
|
22
|
+
|
|
21
23
|
/**
|
|
22
24
|
* Lightweight summary projection of an OAuth provider, suitable for API
|
|
23
25
|
* list responses where full detail is not needed. All keys are snake_case
|
|
@@ -34,6 +36,16 @@ export interface SerializedProviderSummary {
|
|
|
34
36
|
supports_managed_mode: boolean;
|
|
35
37
|
managed_service_is_paid: boolean;
|
|
36
38
|
feature_flag: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Which sense of "connected" this provider represents: `assistant` for a bot
|
|
41
|
+
* credential people reach the assistant through, `user` for a grant letting
|
|
42
|
+
* it act as the person who authorized it.
|
|
43
|
+
*
|
|
44
|
+
* Derived rather than stored, because the provider key does not say: `slack`
|
|
45
|
+
* and `discord` name the user integration while their bots take a `_channel`
|
|
46
|
+
* suffix, yet `telegram` is the bot.
|
|
47
|
+
*/
|
|
48
|
+
acts_as: "user" | "assistant";
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
/**
|
|
@@ -152,5 +164,6 @@ export function serializeProviderSummary(
|
|
|
152
164
|
supports_managed_mode: !!row.managedServiceConfigKey,
|
|
153
165
|
managed_service_is_paid: !!row.managedServiceIsPaid,
|
|
154
166
|
feature_flag: row.featureFlag ?? null,
|
|
167
|
+
acts_as: isChannelBotProvider(row.provider) ? "assistant" : "user",
|
|
155
168
|
};
|
|
156
169
|
}
|
|
@@ -454,16 +454,15 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
454
454
|
pingUrl: "https://discord.com/api/v10/users/@me",
|
|
455
455
|
baseUrl: "https://discord.com/api/v10",
|
|
456
456
|
displayLabel: "Discord",
|
|
457
|
-
|
|
457
|
+
// Your servers, not their contents. These scopes reach the guild list and
|
|
458
|
+
// your membership in one; reading messages is not among them. Discord
|
|
459
|
+
// documents `messages.read` as local RPC only, so requesting it would put
|
|
460
|
+
// a permission on the consent screen that grants nothing here.
|
|
461
|
+
description: "Your servers and profile",
|
|
458
462
|
dashboardUrl: "https://discord.com/developers/applications",
|
|
459
463
|
clientIdPlaceholder: null,
|
|
460
464
|
logoUrl: "https://cdn.simpleicons.org/discord",
|
|
461
|
-
defaultScopes: [
|
|
462
|
-
"identify",
|
|
463
|
-
"guilds",
|
|
464
|
-
"guilds.members.read",
|
|
465
|
-
"messages.read",
|
|
466
|
-
],
|
|
465
|
+
defaultScopes: ["identify", "guilds", "guilds.members.read"],
|
|
467
466
|
availableScopes:
|
|
468
467
|
"https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes",
|
|
469
468
|
loopbackPort: 17326,
|
|
@@ -777,6 +776,35 @@ export const PROVIDER_SEED_DATA: Record<
|
|
|
777
776
|
],
|
|
778
777
|
},
|
|
779
778
|
|
|
779
|
+
// The bot that sits in a server and talks to people there, which is a
|
|
780
|
+
// different thing from the `discord` integration above and starts at the
|
|
781
|
+
// same Discord authorize URL. Both are reasonably called "connecting
|
|
782
|
+
// Discord", so each has to be nameable on its own. The bot token is already
|
|
783
|
+
// stored under this key by `skills/discord-app-setup`; seeding the provider
|
|
784
|
+
// is what lets the product tell the two apart.
|
|
785
|
+
discord_channel: {
|
|
786
|
+
provider: "discord_channel",
|
|
787
|
+
authorizeUrl: "urn:manual-token",
|
|
788
|
+
tokenExchangeUrl: "urn:manual-token",
|
|
789
|
+
pingUrl: "https://discord.com/api/v10/users/@me",
|
|
790
|
+
baseUrl: "https://discord.com/api/v10",
|
|
791
|
+
displayLabel: "Discord Server",
|
|
792
|
+
description: "A bot in your server",
|
|
793
|
+
dashboardUrl: "https://discord.com/developers/applications",
|
|
794
|
+
clientIdPlaceholder: null,
|
|
795
|
+
requiresClientSecret: false,
|
|
796
|
+
logoUrl: "https://cdn.simpleicons.org/discord",
|
|
797
|
+
defaultScopes: [],
|
|
798
|
+
injectionTemplates: [
|
|
799
|
+
{
|
|
800
|
+
hostPattern: "discord.com",
|
|
801
|
+
injectionType: "header",
|
|
802
|
+
headerName: "Authorization",
|
|
803
|
+
valuePrefix: "Bot ",
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
},
|
|
807
|
+
|
|
780
808
|
telegram: {
|
|
781
809
|
provider: "telegram",
|
|
782
810
|
authorizeUrl: "urn:manual-token",
|