pi-roundtable-mcp 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +15 -1
- package/package.json +3 -3
- package/src/connectors/connector-commands.ts +1 -29
- package/src/connectors/connector-input.ts +34 -0
- package/src/connectors/connector-registry.ts +4 -1
- package/src/connectors/connectors-plugin.ts +45 -0
- package/src/index.ts +13 -1
- package/src/remote-mcp/grant-operations.ts +240 -0
- package/src/remote-mcp/mcp-grant-flow.ts +28 -55
- package/src/remote-mcp/messages.ts +3 -0
- package/src/remote-mcp/remote-mcp-plugin.ts +4 -1
- package/src/remote-mcp/remote-mcp-service.ts +90 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.28.0] - 2026-10-31
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `CONNECTORS` adds, re-describes and removes connectors (`add`, `describe`, `remove`) with the rules of `/<root> connector add`: no token is no authentication, a token alone is a bearer token, a header name sends the token in that header. Refusals are `ConnectorError`s in the configured wording, now marked `refusal: true`. `ConnectorInput`, `ConnectorError` and `displayUrl` are exported.
|
|
13
|
+
- `REMOTE_MCP` manages channel grants as `/<root> mcp` does: `operations`, `bundles(client)` with each channel's names, operations and recent audit, `revoke`, `describe`, `rotate` (the new URL returned once) and `authorize(client, input)` under the same channel checks: a server text or announcement channel the acting owner may manage, with the permissions behind each operation held by them and the bot. Refusals are `RemoteMcpError`s (`refusal: true`) in the configured wording; the new `operationsRequired` message refuses an empty or unknown operation choice.
|
|
14
|
+
- The Discord commands and the services share one implementation of these operations.
|
|
15
|
+
|
|
8
16
|
## [0.27.1] - 2026-10-31
|
|
9
17
|
|
|
10
18
|
- Release in lockstep with pi-roundtable 0.27.1; no package-specific behavior changes. The core peer range stays `>=0.27.0 <0.28.0`.
|
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ The package name and public API are unchanged from the standalone repository.
|
|
|
20
20
|
## What you need
|
|
21
21
|
|
|
22
22
|
- [Bun](https://bun.sh/docs/installation) 1.4.4 or newer.
|
|
23
|
-
- A running pi-roundtable host (`pi-roundtable` is a peer dependency, `>=0.
|
|
23
|
+
- A running pi-roundtable host (`pi-roundtable` is a peer dependency, `>=0.28.0 <0.29.0`), with its PostgreSQL.
|
|
24
24
|
- For `mcpConnectors`: a [ContextForge](#contextforge) gateway that you run yourself.
|
|
25
25
|
- For `remoteMcp`: an HTTPS address that reaches the host's `public` listener, such as a tunnel; this is the host's `http.publicUrl`.
|
|
26
26
|
|
|
@@ -113,6 +113,12 @@ It provides the `CONNECTORS` service (`Connectors`):
|
|
|
113
113
|
| `token` | The bearer token that the virtual servers' URLs expect; one per process, valid for a year |
|
|
114
114
|
| `resolve(serverName)` | Reads one virtual server's URL and tools by name, for servers the host manages itself rather than as connectors |
|
|
115
115
|
| `admin` | `gateways()` and `servers()`: what ContextForge holds, upstream gateways with their state and every virtual server with its tool names, for a host's status view |
|
|
116
|
+
| `add({ name, url, description, header?, token? })` | Adds a connector as `/<root> connector add` does: no token is no authentication, a token alone is sent as `Authorization: Bearer`, and a header name sends the token in that header. The token goes to ContextForge only; the result, `{ connector, skipped }`, never holds it |
|
|
117
|
+
| `describe(name, description)` | Changes a connector's purpose |
|
|
118
|
+
| `remove(name)` | Deletes a connector, its virtual server, and its upstream server with its token |
|
|
119
|
+
|
|
120
|
+
A refusal from `add`, `describe` or `remove` is a `ConnectorError` whose message is the configured wording, ready to show the owner; it also carries `refusal: true` for a host that does not import the class.
|
|
121
|
+
`list()` returns each connector's full URL, which may hold a credential in its path or query: show the owner `displayUrl(url, unreadable)` (exported), which keeps only the scheme and host.
|
|
116
122
|
|
|
117
123
|
The plugin reads the stored connectors, and resolves each one's server in ContextForge, in its `preflight`, which the host runs after every plugin is set up and before any service starts or any listener opens: during `setup` `list()`, `servers()` and `profileSources()` are empty and `version` is 0, and `version` changes once the connectors are loaded. Read them from a service's `start`, or later (a turn, a command, a request); no command or turn arrives before they are loaded. `resolve(serverName)` and `token` work from setup. A host's `Roundtable.check()` runs no preflight, so it needs neither the database nor ContextForge. The runtime's startup tool probe runs before this preflight, so it does not see the connectors' servers: a `requiredTools` entry that only a connector's server provides fails the start; the first real session connects to them instead.
|
|
118
124
|
The plugin does not decide which agent uses which connector.
|
|
@@ -208,6 +214,14 @@ The plugin provides the `REMOTE_MCP` service (`RemoteMcpService`) for a host tha
|
|
|
208
214
|
| --- | --- |
|
|
209
215
|
| `grants` | `bundles()` and `grants(bundleId?)`, read only: every bundle, and the channel grants of one bundle or of all |
|
|
210
216
|
| `describeGrant(client, grant)` | One grant as lines of text for a Discord message: the name the agent sees, where the channel is, its purpose, and the allowed operations, in the plugin's wording |
|
|
217
|
+
| `operations` | Every operation a grant may allow, `{ id, label }`, in order |
|
|
218
|
+
| `bundles(client)` | Every bundle with its channels: names (Discord's current ones, or the last known when the bot cannot see the channel), the name and purpose the agent sees, operations, and each channel's recent audit entries |
|
|
219
|
+
| `revoke(bundle, channelId)` | Removes a channel from a bundle |
|
|
220
|
+
| `describe(bundle, channelId, { displayName?, description? })` | Changes the channel name and purpose that the agent sees |
|
|
221
|
+
| `rotate(bundle)` | Replaces a bundle's MCP URL and returns `{ url }`, this once; only its hash is kept |
|
|
222
|
+
| `authorize(client, { bundle, channelId, userId, operations, displayName?, description? })` | Adds a channel to a bundle, creating the bundle when it does not exist, with the checks `/<root> mcp authorize` makes: a server text or announcement channel that `userId`, the acting owner's Discord ID, may manage, with the permissions behind each operation held by them and the bot. A new bundle's URL is in the result this once |
|
|
223
|
+
|
|
224
|
+
A refusal from these is a `RemoteMcpError` whose message is the configured wording; it also carries `refusal: true`.
|
|
211
225
|
|
|
212
226
|
#### `ChannelGrantStore`
|
|
213
227
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "MCP connectors through ContextForge and a remote MCP endpoint for pi-roundtable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"typebox": "1.3.37"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"pi-roundtable": ">=0.
|
|
43
|
+
"pi-roundtable": ">=0.28.0 <0.29.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@biomejs/biome": "2.5.15",
|
|
47
47
|
"@types/bun": "1.4.3",
|
|
48
|
-
"pi-roundtable": "0.
|
|
48
|
+
"pi-roundtable": "0.28.0",
|
|
49
49
|
"typescript": "7.0.2"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -20,12 +20,12 @@ import {
|
|
|
20
20
|
plain,
|
|
21
21
|
replyWithPanels,
|
|
22
22
|
} from "pi-roundtable/discord";
|
|
23
|
+
import { authFrom, displayUrl } from "./connector-input.ts";
|
|
23
24
|
import {
|
|
24
25
|
type Connector,
|
|
25
26
|
ConnectorError,
|
|
26
27
|
type ConnectorRegistry,
|
|
27
28
|
} from "./connector-registry.ts";
|
|
28
|
-
import type { UpstreamAuth } from "./contextforge.ts";
|
|
29
29
|
import type { ConnectorMessages } from "./messages.ts";
|
|
30
30
|
|
|
31
31
|
export const CONNECTOR_MODAL_ID = "rtmcp:connector:add";
|
|
@@ -38,7 +38,6 @@ const FIELD = {
|
|
|
38
38
|
token: "token",
|
|
39
39
|
} as const;
|
|
40
40
|
|
|
41
|
-
const HEADER_NAME = /^[A-Za-z0-9-]{1,100}$/;
|
|
42
41
|
const TOOL_PREVIEW = 12;
|
|
43
42
|
|
|
44
43
|
function connectorGroup(text: ConnectorMessages) {
|
|
@@ -137,33 +136,6 @@ export function connectorModal(text: ConnectorMessages): ModalBuilder {
|
|
|
137
136
|
);
|
|
138
137
|
}
|
|
139
138
|
|
|
140
|
-
function authFrom(
|
|
141
|
-
header: string,
|
|
142
|
-
token: string,
|
|
143
|
-
text: ConnectorMessages,
|
|
144
|
-
): UpstreamAuth {
|
|
145
|
-
if (!token) {
|
|
146
|
-
if (header) throw new OwnerFacingError(text.headerNeedsToken);
|
|
147
|
-
return { type: "none" };
|
|
148
|
-
}
|
|
149
|
-
if (!header) return { type: "bearer", token };
|
|
150
|
-
if (!HEADER_NAME.test(header)) throw new OwnerFacingError(text.headerRule);
|
|
151
|
-
return { type: "header", name: header, value: token };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Scheme and host only: some servers take their credential in the path, query, or user part. */
|
|
155
|
-
export function displayUrl(url: string, unreadable: string): string {
|
|
156
|
-
const parsed = URL.parse(url);
|
|
157
|
-
if (!parsed) return unreadable;
|
|
158
|
-
const hidden =
|
|
159
|
-
parsed.pathname !== "/" ||
|
|
160
|
-
parsed.search ||
|
|
161
|
-
parsed.hash ||
|
|
162
|
-
parsed.username ||
|
|
163
|
-
parsed.password;
|
|
164
|
-
return `${parsed.origin}${hidden ? "/…" : ""}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
139
|
function section(connector: Connector, text: ConnectorMessages): string {
|
|
168
140
|
const tools = connector.server?.tools ?? [];
|
|
169
141
|
const listed = tools
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ConnectorError, type NewConnector } from "./connector-registry.ts";
|
|
2
|
+
import type { ConnectorMessages } from "./messages.ts";
|
|
3
|
+
|
|
4
|
+
type UpstreamAuth = NewConnector["auth"];
|
|
5
|
+
|
|
6
|
+
const HEADER_NAME = /^[A-Za-z0-9-]{1,100}$/;
|
|
7
|
+
|
|
8
|
+
/** How a new connector authenticates upstream: no token is none, a token alone is a bearer token, a header name sends the token in that header. */
|
|
9
|
+
export function authFrom(
|
|
10
|
+
header: string,
|
|
11
|
+
token: string,
|
|
12
|
+
text: ConnectorMessages,
|
|
13
|
+
): UpstreamAuth {
|
|
14
|
+
if (!token) {
|
|
15
|
+
if (header) throw new ConnectorError(text.headerNeedsToken);
|
|
16
|
+
return { type: "none" };
|
|
17
|
+
}
|
|
18
|
+
if (!header) return { type: "bearer", token };
|
|
19
|
+
if (!HEADER_NAME.test(header)) throw new ConnectorError(text.headerRule);
|
|
20
|
+
return { type: "header", name: header, value: token };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Scheme and host only: some servers take their credential in the path, query, or user part. */
|
|
24
|
+
export function displayUrl(url: string, unreadable: string): string {
|
|
25
|
+
const parsed = URL.parse(url);
|
|
26
|
+
if (!parsed) return unreadable;
|
|
27
|
+
const hidden =
|
|
28
|
+
parsed.pathname !== "/" ||
|
|
29
|
+
parsed.search ||
|
|
30
|
+
parsed.hash ||
|
|
31
|
+
parsed.username ||
|
|
32
|
+
parsed.password;
|
|
33
|
+
return `${parsed.origin}${hidden ? "/…" : ""}`;
|
|
34
|
+
}
|
|
@@ -16,7 +16,10 @@ export const DEFAULT_MAX_TOOL_NAME = 45;
|
|
|
16
16
|
export const DEFAULT_SERVER_PREFIX = "roundtable-conn-";
|
|
17
17
|
|
|
18
18
|
/** A request the owner can fix; the message is shown to the owner as is. */
|
|
19
|
-
export class ConnectorError extends Error {
|
|
19
|
+
export class ConnectorError extends Error {
|
|
20
|
+
/** Marks the error as one whose message the owner reads, for a host that does not know this class. */
|
|
21
|
+
readonly refusal = true;
|
|
22
|
+
}
|
|
20
23
|
|
|
21
24
|
export interface Connector {
|
|
22
25
|
name: string;
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { DISCORD } from "pi-roundtable/discord";
|
|
8
8
|
import type { VirtualServer } from "pi-roundtable/kit";
|
|
9
9
|
import { connectorCommands } from "./connector-commands.ts";
|
|
10
|
+
import { authFrom } from "./connector-input.ts";
|
|
10
11
|
import {
|
|
11
12
|
type Connector,
|
|
12
13
|
type ConnectorProfileSource,
|
|
@@ -58,6 +59,49 @@ export interface Connectors {
|
|
|
58
59
|
resolve(serverName: string): Promise<VirtualServer>;
|
|
59
60
|
/** What ContextForge holds, upstream gateways and virtual servers, for a host's status view. */
|
|
60
61
|
readonly admin: Pick<ContextForgeAdmin, "gateways" | "servers">;
|
|
62
|
+
/**
|
|
63
|
+
* Adds a connector as `/<root> connector add` does. No token is no authentication, a token alone
|
|
64
|
+
* is sent as `Authorization: Bearer`, and a header name sends the token in that header. The
|
|
65
|
+
* token goes to ContextForge only; nothing returned holds it. A refusal is a `ConnectorError`.
|
|
66
|
+
*/
|
|
67
|
+
add(
|
|
68
|
+
input: ConnectorInput,
|
|
69
|
+
): Promise<{ connector: Connector; skipped: string[] }>;
|
|
70
|
+
/** Changes a connector's purpose; a refusal is a `ConnectorError`. */
|
|
71
|
+
describe(name: string, description: string): Promise<Connector>;
|
|
72
|
+
/** Deletes a connector, its virtual server, and its upstream server with its token. */
|
|
73
|
+
remove(name: string): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A new connector as the owner gives it; `header` and `token` are optional. */
|
|
77
|
+
export interface ConnectorInput {
|
|
78
|
+
name: string;
|
|
79
|
+
url: string;
|
|
80
|
+
description: string;
|
|
81
|
+
header?: string;
|
|
82
|
+
token?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The registry's management, with the header and token rules of the Discord form. */
|
|
86
|
+
export function connectorManagement(
|
|
87
|
+
registry: Pick<ConnectorRegistry, "add" | "describe" | "remove">,
|
|
88
|
+
text: ConnectorMessages,
|
|
89
|
+
): Pick<Connectors, "add" | "describe" | "remove"> {
|
|
90
|
+
return {
|
|
91
|
+
add: async (input) =>
|
|
92
|
+
registry.add({
|
|
93
|
+
name: input.name.trim(),
|
|
94
|
+
url: input.url.trim(),
|
|
95
|
+
description: input.description.trim(),
|
|
96
|
+
auth: authFrom(
|
|
97
|
+
input.header?.trim() ?? "",
|
|
98
|
+
input.token?.trim() ?? "",
|
|
99
|
+
text,
|
|
100
|
+
),
|
|
101
|
+
}),
|
|
102
|
+
describe: (name, description) => registry.describe(name, description),
|
|
103
|
+
remove: (name) => registry.remove(name),
|
|
104
|
+
};
|
|
61
105
|
}
|
|
62
106
|
|
|
63
107
|
export const CONNECTORS = serviceKey<Connectors>(
|
|
@@ -123,6 +167,7 @@ export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
|
|
|
123
167
|
gateways: () => admin.gateways(),
|
|
124
168
|
servers: () => admin.servers(),
|
|
125
169
|
},
|
|
170
|
+
...connectorManagement(registry, text),
|
|
126
171
|
});
|
|
127
172
|
// Nothing is added but what preflight reads.
|
|
128
173
|
load = () => registry.load();
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// The package's one entry: two plugins, their options, and the service the connectors plugin provides.
|
|
2
2
|
|
|
3
|
+
export { displayUrl } from "./connectors/connector-input.ts";
|
|
3
4
|
export type {
|
|
4
5
|
Connector,
|
|
5
6
|
ConnectorProfileSource,
|
|
6
7
|
NewConnector,
|
|
7
8
|
} from "./connectors/connector-registry.ts";
|
|
9
|
+
export { ConnectorError } from "./connectors/connector-registry.ts";
|
|
8
10
|
export type {
|
|
11
|
+
ConnectorInput,
|
|
9
12
|
Connectors,
|
|
10
13
|
ContextForgeOptions,
|
|
11
14
|
McpConnectorsOptions,
|
|
@@ -18,6 +21,12 @@ export type {
|
|
|
18
21
|
ChannelGrant,
|
|
19
22
|
} from "./remote-mcp/channel-grants.ts";
|
|
20
23
|
export { ChannelGrantStore } from "./remote-mcp/channel-grants.ts";
|
|
24
|
+
export type {
|
|
25
|
+
Authorized,
|
|
26
|
+
AuthorizeInput,
|
|
27
|
+
BundleView,
|
|
28
|
+
GrantView,
|
|
29
|
+
} from "./remote-mcp/grant-operations.ts";
|
|
21
30
|
export type {
|
|
22
31
|
RemoteMcpMessages,
|
|
23
32
|
RemoteToolNames,
|
|
@@ -30,4 +39,7 @@ export type {
|
|
|
30
39
|
} from "./remote-mcp/remote-mcp-plugin.ts";
|
|
31
40
|
export { remoteMcp } from "./remote-mcp/remote-mcp-plugin.ts";
|
|
32
41
|
export type { RemoteMcpService } from "./remote-mcp/remote-mcp-service.ts";
|
|
33
|
-
export {
|
|
42
|
+
export {
|
|
43
|
+
REMOTE_MCP,
|
|
44
|
+
RemoteMcpError,
|
|
45
|
+
} from "./remote-mcp/remote-mcp-service.ts";
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { Client } from "discord.js";
|
|
2
|
+
import {
|
|
3
|
+
type ChannelOperation,
|
|
4
|
+
fetchManagedChannel,
|
|
5
|
+
isChannelOperation,
|
|
6
|
+
type ManagedChannel,
|
|
7
|
+
OwnerFacingError,
|
|
8
|
+
} from "pi-roundtable/discord";
|
|
9
|
+
import {
|
|
10
|
+
type ChannelAudit,
|
|
11
|
+
type ChannelBundle,
|
|
12
|
+
type ChannelGrant,
|
|
13
|
+
type ChannelGrantStore,
|
|
14
|
+
newChannelEndpoint,
|
|
15
|
+
} from "./channel-grants.ts";
|
|
16
|
+
import { managedChannel } from "./mcp-grant-panels.ts";
|
|
17
|
+
import type { RemoteMcpMessages } from "./messages.ts";
|
|
18
|
+
|
|
19
|
+
/** One channel of a bundle, with where it is now and its recent audit entries. */
|
|
20
|
+
export interface GrantView {
|
|
21
|
+
channelId: string;
|
|
22
|
+
guildId: string;
|
|
23
|
+
/** The server and channel names Discord gives now, or the last known ones when it cannot be fetched. */
|
|
24
|
+
guildName: string;
|
|
25
|
+
channelName: string;
|
|
26
|
+
/** False when the bot cannot see the channel now. */
|
|
27
|
+
visible: boolean;
|
|
28
|
+
displayName: string;
|
|
29
|
+
description: string;
|
|
30
|
+
operations: ChannelOperation[];
|
|
31
|
+
authorizedAt: Date;
|
|
32
|
+
audit: ChannelAudit[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BundleView {
|
|
36
|
+
name: string;
|
|
37
|
+
defaultOperations: ChannelOperation[];
|
|
38
|
+
grants: GrantView[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A channel to add to a bundle, by the person who authorizes it. */
|
|
42
|
+
export interface AuthorizeInput {
|
|
43
|
+
bundle: string;
|
|
44
|
+
channelId: string;
|
|
45
|
+
/** The authorizing owner's Discord user ID; they must be able to manage the channel. */
|
|
46
|
+
userId: string;
|
|
47
|
+
operations: readonly string[];
|
|
48
|
+
displayName?: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Authorized {
|
|
53
|
+
bundle: string;
|
|
54
|
+
channelName: string;
|
|
55
|
+
operations: ChannelOperation[];
|
|
56
|
+
/** The bundle's new MCP URL, present only when this created the bundle; shown once. */
|
|
57
|
+
url?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CHANNEL_ID = /^\d{1,20}$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What `/<root> mcp` and the `REMOTE_MCP` service both do to the owner's bundles, with the same
|
|
64
|
+
* checks. A refusal is an `OwnerFacingError` in the configured wording.
|
|
65
|
+
*/
|
|
66
|
+
export class GrantOperations {
|
|
67
|
+
readonly #grants: ChannelGrantStore;
|
|
68
|
+
readonly #publicUrl: string;
|
|
69
|
+
readonly #root: string;
|
|
70
|
+
readonly #text: RemoteMcpMessages;
|
|
71
|
+
readonly #quote: (name: string) => string;
|
|
72
|
+
|
|
73
|
+
/** `quote` readies a name for a refusal's text, such as Discord's markdown escaping. */
|
|
74
|
+
constructor(
|
|
75
|
+
grants: ChannelGrantStore,
|
|
76
|
+
publicUrl: string,
|
|
77
|
+
root: string,
|
|
78
|
+
text: RemoteMcpMessages,
|
|
79
|
+
quote: (name: string) => string = (name) => name,
|
|
80
|
+
) {
|
|
81
|
+
this.#grants = grants;
|
|
82
|
+
this.#publicUrl = publicUrl;
|
|
83
|
+
this.#root = root;
|
|
84
|
+
this.#text = text;
|
|
85
|
+
this.#quote = quote;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The named bundle; refuses one that does not exist. */
|
|
89
|
+
async bundle(name: string): Promise<ChannelBundle> {
|
|
90
|
+
const bundle = await this.#grants.bundleByName(name.trim());
|
|
91
|
+
if (!bundle)
|
|
92
|
+
throw new OwnerFacingError(
|
|
93
|
+
this.#text.noBundle(this.#quote(name.trim()), this.#root),
|
|
94
|
+
);
|
|
95
|
+
return bundle;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Refuses anything but a channel ID. */
|
|
99
|
+
channelId(channelId: string): string {
|
|
100
|
+
const id = channelId.trim();
|
|
101
|
+
if (!CHANNEL_ID.test(id))
|
|
102
|
+
throw new OwnerFacingError(this.#text.channelIdRule);
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every bundle with its channels and each channel's recent audit entries. */
|
|
107
|
+
async bundles(client: Client): Promise<BundleView[]> {
|
|
108
|
+
const [bundles, grants] = await Promise.all([
|
|
109
|
+
this.#grants.bundles(),
|
|
110
|
+
this.#grants.grants(),
|
|
111
|
+
]);
|
|
112
|
+
return Promise.all(
|
|
113
|
+
bundles.map(async (bundle) => ({
|
|
114
|
+
name: bundle.name,
|
|
115
|
+
defaultOperations: bundle.defaultOperations,
|
|
116
|
+
grants: await Promise.all(
|
|
117
|
+
grants
|
|
118
|
+
.filter((g) => g.bundleId === bundle.id)
|
|
119
|
+
.map((g) => this.#grantView(client, g)),
|
|
120
|
+
),
|
|
121
|
+
})),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolves false when the channel was not in the bundle. */
|
|
126
|
+
async revoke(bundle: ChannelBundle, channelId: string): Promise<boolean> {
|
|
127
|
+
return this.#grants.revoke(bundle.id, channelId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Changes the name and purpose an agent sees; resolves false when the channel is not in the bundle. */
|
|
131
|
+
async describe(
|
|
132
|
+
bundle: ChannelBundle,
|
|
133
|
+
channelId: string,
|
|
134
|
+
change: { displayName?: string; description?: string },
|
|
135
|
+
): Promise<boolean> {
|
|
136
|
+
const displayName = change.displayName?.trim();
|
|
137
|
+
const description = change.description?.trim();
|
|
138
|
+
if (displayName === undefined && description === undefined)
|
|
139
|
+
throw new OwnerFacingError(this.#text.nameOrDescription);
|
|
140
|
+
return this.#grants.describe(bundle.id, channelId, {
|
|
141
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
142
|
+
...(description === undefined ? {} : { description }),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Replaces the bundle's URL; the new one is returned this once and only its hash is kept. */
|
|
147
|
+
async rotate(bundleId: string): Promise<string> {
|
|
148
|
+
const endpoint = newChannelEndpoint(this.#publicUrl);
|
|
149
|
+
await this.#grants.rotateToken(bundleId, endpoint.tokenHash);
|
|
150
|
+
return endpoint.url;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Refuses a bundle name the flow would not take. */
|
|
154
|
+
bundleName(name: string): string {
|
|
155
|
+
const trimmed = name.trim();
|
|
156
|
+
if (!trimmed || trimmed.length > 80)
|
|
157
|
+
throw new OwnerFacingError(this.#text.bundleNameRule);
|
|
158
|
+
return trimmed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Refuses an empty choice or an operation that is not one. */
|
|
162
|
+
operations(operations: readonly string[]): ChannelOperation[] {
|
|
163
|
+
const chosen = operations.filter(isChannelOperation);
|
|
164
|
+
if (chosen.length === 0 || chosen.length !== operations.length)
|
|
165
|
+
throw new OwnerFacingError(this.#text.operationsRequired);
|
|
166
|
+
return [...new Set(chosen)];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Adds the channel to the bundle, creating the bundle when it does not exist, or replaces its
|
|
171
|
+
* grant there. The channel must be one the user may manage, and the user and the bot must both
|
|
172
|
+
* hold the permissions behind each operation.
|
|
173
|
+
*/
|
|
174
|
+
async authorize(
|
|
175
|
+
client: Client,
|
|
176
|
+
input: Omit<AuthorizeInput, "operations"> & {
|
|
177
|
+
operations: ChannelOperation[];
|
|
178
|
+
},
|
|
179
|
+
): Promise<Authorized & { channel: ManagedChannel; created: boolean }> {
|
|
180
|
+
const channel = await managedChannel(
|
|
181
|
+
client,
|
|
182
|
+
input.channelId,
|
|
183
|
+
input.userId,
|
|
184
|
+
input.operations,
|
|
185
|
+
this.#text,
|
|
186
|
+
);
|
|
187
|
+
const endpoint = newChannelEndpoint(this.#publicUrl);
|
|
188
|
+
const { bundle, created } = await this.#grants.ensureBundle(
|
|
189
|
+
input.bundle,
|
|
190
|
+
endpoint.tokenHash,
|
|
191
|
+
input.operations,
|
|
192
|
+
);
|
|
193
|
+
const existing = await this.#grants.grant(bundle.id, channel.id);
|
|
194
|
+
await this.#grants.save({
|
|
195
|
+
bundleId: bundle.id,
|
|
196
|
+
channelId: channel.id,
|
|
197
|
+
guildId: channel.guildId,
|
|
198
|
+
operations: input.operations,
|
|
199
|
+
displayName: input.displayName || existing?.displayName || channel.name,
|
|
200
|
+
description: input.description || existing?.description || "",
|
|
201
|
+
guildName: channel.guild.name,
|
|
202
|
+
channelName: channel.name,
|
|
203
|
+
authorizedBy: input.userId,
|
|
204
|
+
authorizedAt: new Date(),
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
bundle: bundle.name,
|
|
208
|
+
channelName: channel.name,
|
|
209
|
+
operations: input.operations,
|
|
210
|
+
channel,
|
|
211
|
+
created,
|
|
212
|
+
...(created ? { url: endpoint.url } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async #grantView(client: Client, grant: ChannelGrant): Promise<GrantView> {
|
|
217
|
+
let guildName = grant.guildName;
|
|
218
|
+
let channelName = grant.channelName;
|
|
219
|
+
let visible = true;
|
|
220
|
+
try {
|
|
221
|
+
const channel = await fetchManagedChannel(client, grant.channelId);
|
|
222
|
+
guildName = channel.guild.name;
|
|
223
|
+
channelName = channel.name;
|
|
224
|
+
} catch {
|
|
225
|
+
visible = false;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
channelId: grant.channelId,
|
|
229
|
+
guildId: grant.guildId,
|
|
230
|
+
guildName,
|
|
231
|
+
channelName,
|
|
232
|
+
visible,
|
|
233
|
+
displayName: grant.displayName,
|
|
234
|
+
description: grant.description,
|
|
235
|
+
operations: grant.operations,
|
|
236
|
+
authorizedAt: grant.authorizedAt,
|
|
237
|
+
audit: await this.#grants.recentAudit(grant.channelId),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -13,10 +13,8 @@ import {
|
|
|
13
13
|
plain,
|
|
14
14
|
replyWithPanels,
|
|
15
15
|
} from "pi-roundtable/discord";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
newChannelEndpoint,
|
|
19
|
-
} from "./channel-grants.ts";
|
|
16
|
+
import type { ChannelBundle, ChannelGrantStore } from "./channel-grants.ts";
|
|
17
|
+
import { GrantOperations } from "./grant-operations.ts";
|
|
20
18
|
import {
|
|
21
19
|
authorizePanel,
|
|
22
20
|
describeGrant,
|
|
@@ -37,7 +35,7 @@ type ButtonOrMenu = ButtonInteraction | StringSelectMenuInteraction;
|
|
|
37
35
|
*/
|
|
38
36
|
export class McpGrantFlow {
|
|
39
37
|
readonly #grants: ChannelGrantStore;
|
|
40
|
-
readonly #
|
|
38
|
+
readonly #ops: GrantOperations;
|
|
41
39
|
readonly #root: string;
|
|
42
40
|
readonly #text: RemoteMcpMessages;
|
|
43
41
|
readonly #pending = new Map<string, Pending>();
|
|
@@ -49,7 +47,7 @@ export class McpGrantFlow {
|
|
|
49
47
|
text: RemoteMcpMessages,
|
|
50
48
|
) {
|
|
51
49
|
this.#grants = grants;
|
|
52
|
-
this.#
|
|
50
|
+
this.#ops = new GrantOperations(grants, publicUrl, root, text, plain);
|
|
53
51
|
this.#root = root;
|
|
54
52
|
this.#text = text;
|
|
55
53
|
}
|
|
@@ -85,9 +83,7 @@ export class McpGrantFlow {
|
|
|
85
83
|
const bundleName = interaction.options.getString("bundle", true).trim();
|
|
86
84
|
if (sub === "authorize")
|
|
87
85
|
return this.#beginAuthorize(interaction, bundleName);
|
|
88
|
-
const bundle = await this.#
|
|
89
|
-
if (!bundle)
|
|
90
|
-
throw new OwnerFacingError(text.noBundle(plain(bundleName), this.#root));
|
|
86
|
+
const bundle = await this.#ops.bundle(bundleName);
|
|
91
87
|
if (sub === "token") {
|
|
92
88
|
const id = this.#hold(interaction, {
|
|
93
89
|
bundleName: bundle.name,
|
|
@@ -98,14 +94,12 @@ export class McpGrantFlow {
|
|
|
98
94
|
await interaction.editReply(rotatePanel(bundle.name, id, text));
|
|
99
95
|
return;
|
|
100
96
|
}
|
|
101
|
-
const channelId =
|
|
102
|
-
interaction.options.getString("channel_id")
|
|
103
|
-
|
|
104
|
-
if (!/^\d{1,20}$/.test(channelId))
|
|
105
|
-
throw new OwnerFacingError(text.channelIdRule);
|
|
97
|
+
const channelId = this.#ops.channelId(
|
|
98
|
+
interaction.options.getString("channel_id") ?? interaction.channelId,
|
|
99
|
+
);
|
|
106
100
|
if (sub === "describe")
|
|
107
101
|
return this.#describe(interaction, bundle, channelId);
|
|
108
|
-
const removed = await this.#
|
|
102
|
+
const removed = await this.#ops.revoke(bundle, channelId);
|
|
109
103
|
for (const [id, value] of this.#pending)
|
|
110
104
|
if (value.channelId === channelId) this.#pending.delete(id);
|
|
111
105
|
await interaction.editReply(
|
|
@@ -174,15 +168,14 @@ export class McpGrantFlow {
|
|
|
174
168
|
|
|
175
169
|
async #describe(
|
|
176
170
|
interaction: ChatInputCommandInteraction,
|
|
177
|
-
bundle:
|
|
171
|
+
bundle: ChannelBundle,
|
|
178
172
|
channelId: string,
|
|
179
173
|
): Promise<void> {
|
|
180
174
|
const text = this.#text;
|
|
181
|
-
const displayName = interaction.options.getString("name")
|
|
182
|
-
const description =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const changed = await this.#grants.describe(bundle.id, channelId, {
|
|
175
|
+
const displayName = interaction.options.getString("name") ?? undefined;
|
|
176
|
+
const description =
|
|
177
|
+
interaction.options.getString("description") ?? undefined;
|
|
178
|
+
const changed = await this.#ops.describe(bundle, channelId, {
|
|
186
179
|
...(displayName === undefined ? {} : { displayName }),
|
|
187
180
|
...(description === undefined ? {} : { description }),
|
|
188
181
|
});
|
|
@@ -202,14 +195,13 @@ export class McpGrantFlow {
|
|
|
202
195
|
bundleId: string,
|
|
203
196
|
): Promise<void> {
|
|
204
197
|
const text = this.#text;
|
|
205
|
-
const
|
|
206
|
-
await this.#grants.rotateToken(bundleId, endpoint.tokenHash);
|
|
198
|
+
const url = await this.#ops.rotate(bundleId);
|
|
207
199
|
await interaction.editReply(
|
|
208
200
|
ownerPanel({
|
|
209
201
|
title: text.rotatedTitle,
|
|
210
202
|
sections: [
|
|
211
203
|
text.rotated(plain(pending.bundleName)),
|
|
212
|
-
text.urlSection(
|
|
204
|
+
text.urlSection(url),
|
|
213
205
|
],
|
|
214
206
|
footer: text.urlFooter,
|
|
215
207
|
}),
|
|
@@ -221,8 +213,7 @@ export class McpGrantFlow {
|
|
|
221
213
|
bundleName: string,
|
|
222
214
|
): Promise<void> {
|
|
223
215
|
const text = this.#text;
|
|
224
|
-
|
|
225
|
-
throw new OwnerFacingError(text.bundleNameRule);
|
|
216
|
+
this.#ops.bundleName(bundleName);
|
|
226
217
|
await managedChannel(
|
|
227
218
|
interaction.client,
|
|
228
219
|
interaction.channelId,
|
|
@@ -257,44 +248,26 @@ export class McpGrantFlow {
|
|
|
257
248
|
|
|
258
249
|
async #confirm(interaction: ButtonOrMenu, pending: Pending): Promise<void> {
|
|
259
250
|
const text = this.#text;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
pending.channelId,
|
|
263
|
-
pending.userId,
|
|
264
|
-
pending.operations,
|
|
265
|
-
text,
|
|
266
|
-
);
|
|
267
|
-
const endpoint = newChannelEndpoint(this.#publicUrl);
|
|
268
|
-
const { bundle, created } = await this.#grants.ensureBundle(
|
|
269
|
-
pending.bundleName,
|
|
270
|
-
endpoint.tokenHash,
|
|
271
|
-
pending.operations,
|
|
272
|
-
);
|
|
273
|
-
const existing = await this.#grants.grant(bundle.id, channel.id);
|
|
274
|
-
await this.#grants.save({
|
|
275
|
-
bundleId: bundle.id,
|
|
276
|
-
channelId: channel.id,
|
|
277
|
-
guildId: channel.guildId,
|
|
251
|
+
const done = await this.#ops.authorize(interaction.client, {
|
|
252
|
+
bundle: pending.bundleName,
|
|
253
|
+
channelId: pending.channelId,
|
|
254
|
+
userId: pending.userId,
|
|
278
255
|
operations: pending.operations,
|
|
279
|
-
displayName: pending.displayName
|
|
280
|
-
description: pending.description
|
|
281
|
-
guildName: channel.guild.name,
|
|
282
|
-
channelName: channel.name,
|
|
283
|
-
authorizedBy: pending.userId,
|
|
284
|
-
authorizedAt: new Date(),
|
|
256
|
+
...(pending.displayName ? { displayName: pending.displayName } : {}),
|
|
257
|
+
...(pending.description ? { description: pending.description } : {}),
|
|
285
258
|
});
|
|
286
259
|
await interaction.editReply(
|
|
287
260
|
ownerPanel({
|
|
288
261
|
title: text.grantedTitle,
|
|
289
262
|
sections: [
|
|
290
263
|
text.granted(
|
|
291
|
-
plain(
|
|
292
|
-
plain(bundle
|
|
293
|
-
opLabels(
|
|
264
|
+
plain(done.channelName),
|
|
265
|
+
plain(done.bundle),
|
|
266
|
+
opLabels(done.operations, text),
|
|
294
267
|
),
|
|
295
|
-
|
|
268
|
+
done.url ? text.urlSection(done.url) : text.existingUrl,
|
|
296
269
|
],
|
|
297
|
-
footer:
|
|
270
|
+
footer: done.url ? text.urlFooter : text.urlLostFooter(this.#root),
|
|
298
271
|
}),
|
|
299
272
|
);
|
|
300
273
|
}
|
|
@@ -79,6 +79,8 @@ export interface RemoteMcpMessages {
|
|
|
79
79
|
rotated(bundle: string): string;
|
|
80
80
|
urlFooter: string;
|
|
81
81
|
bundleNameRule: string;
|
|
82
|
+
/** Refuses an authorization with no operation chosen, or one that is not an operation. */
|
|
83
|
+
operationsRequired: string;
|
|
82
84
|
grantedTitle: string;
|
|
83
85
|
granted(channel: string, bundle: string, operations: string): string;
|
|
84
86
|
existingUrl: string;
|
|
@@ -187,6 +189,7 @@ export const REMOTE_MCP_MESSAGES: RemoteMcpMessages = {
|
|
|
187
189
|
urlFooter:
|
|
188
190
|
"This URL is the access credential and is shown only this once; only its hash is stored.",
|
|
189
191
|
bundleNameRule: "A bundle name needs 1 to 80 characters.",
|
|
192
|
+
operationsRequired: "Choose one or more of the allowed operations.",
|
|
190
193
|
grantedTitle: "Authorized",
|
|
191
194
|
granted: (channel, bundle, operations) =>
|
|
192
195
|
`**#${channel}** was added to bundle **${bundle}**, allowing: ${operations}.`,
|
|
@@ -212,7 +212,10 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
|
|
|
212
212
|
discord.commands.add(
|
|
213
213
|
mcpGrantCommands(discord.guard, grants, options.publicUrl, text),
|
|
214
214
|
);
|
|
215
|
-
services.provide(
|
|
215
|
+
services.provide(
|
|
216
|
+
REMOTE_MCP,
|
|
217
|
+
remoteMcpService(grants, text, options.publicUrl, discord.guard.root),
|
|
218
|
+
);
|
|
216
219
|
// Read before any service starts, by `preflight` above.
|
|
217
220
|
adopt = async () => {
|
|
218
221
|
found = await boundPrincipal(identities, identity);
|
|
@@ -1,31 +1,120 @@
|
|
|
1
1
|
import type { Client } from "discord.js";
|
|
2
2
|
import { serviceKey } from "pi-roundtable";
|
|
3
|
+
import {
|
|
4
|
+
CHANNEL_OPERATIONS,
|
|
5
|
+
type ChannelOperation,
|
|
6
|
+
OwnerFacingError,
|
|
7
|
+
operationLabel,
|
|
8
|
+
} from "pi-roundtable/discord";
|
|
3
9
|
import type { ChannelGrant, ChannelGrantStore } from "./channel-grants.ts";
|
|
10
|
+
import {
|
|
11
|
+
type Authorized,
|
|
12
|
+
type AuthorizeInput,
|
|
13
|
+
type BundleView,
|
|
14
|
+
GrantOperations,
|
|
15
|
+
} from "./grant-operations.ts";
|
|
4
16
|
import { describeGrant } from "./mcp-grant-panels.ts";
|
|
5
17
|
import type { RemoteMcpMessages } from "./messages.ts";
|
|
6
18
|
|
|
7
|
-
/**
|
|
19
|
+
/** A refusal the owner can act on, in the configured wording; `refusal` marks it for a host that does not know this class. */
|
|
20
|
+
export class RemoteMcpError extends Error {
|
|
21
|
+
readonly refusal = true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What a host reads to show the owner which channels outside agents may use, and to manage them as `/<root> mcp` does. */
|
|
8
25
|
export interface RemoteMcpService {
|
|
9
26
|
/** The owner's bundles and their channel grants, read only. */
|
|
10
27
|
grants: Pick<ChannelGrantStore, "bundles" | "grants">;
|
|
11
28
|
/** One grant as lines of text: the agent's name for the channel, where it is, its purpose, and the allowed operations. */
|
|
12
29
|
describeGrant(client: Client, grant: ChannelGrant): Promise<string>;
|
|
30
|
+
/** Every operation a grant may allow, in order, with its label. */
|
|
31
|
+
readonly operations: readonly { id: ChannelOperation; label: string }[];
|
|
32
|
+
/** Every bundle with its channels, their operations, and each channel's recent audit entries. */
|
|
33
|
+
bundles(client: Client): Promise<BundleView[]>;
|
|
34
|
+
/** Removes a channel from a bundle; refuses an unknown bundle or a channel not in it. */
|
|
35
|
+
revoke(bundle: string, channelId: string): Promise<void>;
|
|
36
|
+
/** Changes the channel name and purpose an agent sees; give either or both. */
|
|
37
|
+
describe(
|
|
38
|
+
bundle: string,
|
|
39
|
+
channelId: string,
|
|
40
|
+
change: { displayName?: string; description?: string },
|
|
41
|
+
): Promise<void>;
|
|
42
|
+
/** Replaces a bundle's MCP URL; the new URL is returned this once and only its hash is kept. */
|
|
43
|
+
rotate(bundle: string): Promise<{ url: string }>;
|
|
44
|
+
/**
|
|
45
|
+
* Adds a channel to a bundle with the chosen operations, creating the bundle when it does not
|
|
46
|
+
* exist, under the checks `/<root> mcp authorize` makes: the channel is a server text or
|
|
47
|
+
* announcement channel, `userId` (the acting owner's Discord ID) may manage it, and they and
|
|
48
|
+
* the bot both hold the permissions behind each operation. A new bundle's URL is in the result
|
|
49
|
+
* this once.
|
|
50
|
+
*/
|
|
51
|
+
authorize(client: Client, input: AuthorizeInput): Promise<Authorized>;
|
|
13
52
|
}
|
|
14
53
|
|
|
15
54
|
export const REMOTE_MCP = serviceKey<RemoteMcpService>(
|
|
16
55
|
"pi-roundtable-mcp.remote-mcp",
|
|
17
56
|
);
|
|
18
57
|
|
|
58
|
+
/** The flow's refusals, as this service's. */
|
|
59
|
+
async function refused<T>(call: () => Promise<T>): Promise<T> {
|
|
60
|
+
try {
|
|
61
|
+
return await call();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof OwnerFacingError)
|
|
64
|
+
throw new RemoteMcpError(error.message);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
19
69
|
/** The service over the plugin's grant store and its wording. */
|
|
20
70
|
export function remoteMcpService(
|
|
21
71
|
grants: ChannelGrantStore,
|
|
22
72
|
text: RemoteMcpMessages,
|
|
73
|
+
publicUrl: string,
|
|
74
|
+
root: string,
|
|
23
75
|
): RemoteMcpService {
|
|
76
|
+
const ops = new GrantOperations(grants, publicUrl, root, text);
|
|
24
77
|
return {
|
|
25
78
|
grants: {
|
|
26
79
|
bundles: () => grants.bundles(),
|
|
27
80
|
grants: (...args) => grants.grants(...args),
|
|
28
81
|
},
|
|
29
82
|
describeGrant: (client, grant) => describeGrant(client, grant, text),
|
|
83
|
+
operations: CHANNEL_OPERATIONS.map((id) => ({
|
|
84
|
+
id,
|
|
85
|
+
label: operationLabel(id),
|
|
86
|
+
})),
|
|
87
|
+
bundles: (client) => ops.bundles(client),
|
|
88
|
+
revoke: (bundle, channelId) =>
|
|
89
|
+
refused(async () => {
|
|
90
|
+
const found = await ops.bundle(bundle);
|
|
91
|
+
if (!(await ops.revoke(found, ops.channelId(channelId))))
|
|
92
|
+
throw new OwnerFacingError(text.revokeNotInBundle(found.name));
|
|
93
|
+
}),
|
|
94
|
+
describe: (bundle, channelId, change) =>
|
|
95
|
+
refused(async () => {
|
|
96
|
+
const found = await ops.bundle(bundle);
|
|
97
|
+
if (!(await ops.describe(found, ops.channelId(channelId), change)))
|
|
98
|
+
throw new OwnerFacingError(text.notInBundle(found.name));
|
|
99
|
+
}),
|
|
100
|
+
rotate: (bundle) =>
|
|
101
|
+
refused(async () => ({
|
|
102
|
+
url: await ops.rotate((await ops.bundle(bundle)).id),
|
|
103
|
+
})),
|
|
104
|
+
authorize: (client, input) =>
|
|
105
|
+
refused(async () => {
|
|
106
|
+
const done = await ops.authorize(client, {
|
|
107
|
+
...input,
|
|
108
|
+
bundle: ops.bundleName(input.bundle),
|
|
109
|
+
channelId: ops.channelId(input.channelId),
|
|
110
|
+
operations: ops.operations(input.operations),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
bundle: done.bundle,
|
|
114
|
+
channelName: done.channelName,
|
|
115
|
+
operations: done.operations,
|
|
116
|
+
...(done.url ? { url: done.url } : {}),
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
30
119
|
};
|
|
31
120
|
}
|