b2match-mcp 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +267 -2
- package/dist/api.d.ts +89 -0
- package/dist/api.js +264 -0
- package/dist/auth.d.ts +35 -0
- package/dist/auth.js +127 -0
- package/dist/availability.d.ts +16 -0
- package/dist/availability.js +65 -0
- package/dist/browser.d.ts +56 -0
- package/dist/browser.js +313 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +95 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +174 -0
- package/dist/cookies.d.ts +11 -0
- package/dist/cookies.js +28 -0
- package/dist/directory.d.ts +25 -0
- package/dist/directory.js +176 -0
- package/dist/eventIdentifier.d.ts +8 -0
- package/dist/eventIdentifier.js +39 -0
- package/dist/invocation.d.ts +2 -0
- package/dist/invocation.js +5 -0
- package/dist/keychain.d.ts +47 -0
- package/dist/keychain.js +146 -0
- package/dist/login.d.ts +11 -0
- package/dist/login.js +81 -0
- package/dist/participants.d.ts +9 -0
- package/dist/participants.js +28 -0
- package/dist/queries.d.ts +20 -0
- package/dist/queries.js +1382 -0
- package/dist/queryGuard.d.ts +9 -0
- package/dist/queryGuard.js +70 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +288 -0
- package/dist/sideEffects.d.ts +6 -0
- package/dist/sideEffects.js +25 -0
- package/dist/tooling.d.ts +58 -0
- package/dist/tooling.js +67 -0
- package/package.json +70 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 b2match-mcp contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,268 @@
|
|
|
1
|
-
#
|
|
1
|
+
# b2match-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/barzoj/b2match-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/barzoj/b2match-mcp)
|
|
5
|
+
[](https://www.npmjs.com/package/b2match-mcp)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
|
|
9
|
+
Read-only MCP server for the [B2match](https://www.b2match.com/) event and matchmaking platform.
|
|
10
|
+
|
|
11
|
+
This package is designed to be run locally by the user via `npx` and stdio MCP transport.
|
|
12
|
+
|
|
13
|
+
**Disclaimer:** This project is unofficial and is not affiliated with, endorsed by, or supported by B2match. It uses B2match's undocumented web GraphQL API, so field availability can change without notice. Use it in accordance with [B2match's Terms of Service](https://www.b2match.com/) and GDPR. Participant profiles can be other people's personal data — keep exports local, respect each event's visibility rules, and do not harvest or republish personal data.
|
|
14
|
+
|
|
15
|
+
## Read-only and privacy guarantees
|
|
16
|
+
|
|
17
|
+
- The MCP server sends only GraphQL `query` operations. Mutations and subscriptions are parsed and rejected before any network call.
|
|
18
|
+
- Known "read-looking" write operations are explicitly blocked, including profile-view, mark-as-read, session-attendance, and bookmark mutations.
|
|
19
|
+
- B2match can still update its own read/seen counters when certain query fields are selected. See [Server-side side effects](#server-side-side-effects).
|
|
20
|
+
- It never calls Plausible, Sentry, Google Analytics, HubSpot, LinkedIn pixel, or any non-B2match analytics endpoint.
|
|
21
|
+
- It makes no external calls other than `https://api.b2match.com/v4/query` and the B2match Google login URL opened by the CLI.
|
|
22
|
+
- No telemetry or analytics are collected by this package.
|
|
23
|
+
- Tokens are never printed by tools or status commands.
|
|
24
|
+
- Tokens are stored in the OS keychain when available, otherwise in a `0600` config file.
|
|
25
|
+
|
|
26
|
+
## Install / run
|
|
27
|
+
|
|
28
|
+
Requires Node.js 20 or newer. On macOS with Apple Silicon, the recommended install path is `npx`; no native build step is required for the auth storage path.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx -y b2match-mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
From a checkout:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install
|
|
38
|
+
npm run build
|
|
39
|
+
node dist/cli.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## MCP client config
|
|
43
|
+
|
|
44
|
+
### Claude Desktop
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"b2match": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "b2match-mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Cursor
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"b2match": {
|
|
63
|
+
"command": "npx",
|
|
64
|
+
"args": ["-y", "b2match-mcp"]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### VS Code
|
|
71
|
+
|
|
72
|
+
Add to `.vscode/mcp.json` or user MCP settings:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"servers": {
|
|
77
|
+
"b2match": {
|
|
78
|
+
"type": "stdio",
|
|
79
|
+
"command": "npx",
|
|
80
|
+
"args": ["-y", "b2match-mcp"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
For local development from this checkout:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"b2match": {
|
|
92
|
+
"command": "node",
|
|
93
|
+
"args": ["/absolute/path/to/b2match-mcp/dist/cli.js"]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Authentication
|
|
100
|
+
|
|
101
|
+
Public event data works anonymously. Personal data such as viewer profile, active events, meetings, schedule, conversations, notifications, and availability requires a locally stored B2match session token.
|
|
102
|
+
|
|
103
|
+
### macOS / Linux login
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
b2match-mcp login https://www.b2match.com/e/<event-slug>
|
|
107
|
+
# or: npx b2match-mcp login https://www.b2match.com/e/<event-slug>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The default command:
|
|
111
|
+
|
|
112
|
+
1. Resolves the event anonymously and asks B2match for its Google login URL.
|
|
113
|
+
2. Launches your installed Chrome, Chromium, Edge, or Brave as a separate process.
|
|
114
|
+
3. Uses a dedicated persistent profile under the tool config directory, not your everyday browser profile.
|
|
115
|
+
4. Opens a remote-debugging port on `127.0.0.1` for that dedicated profile only (not your everyday browser). The port is closed when login finishes or times out.
|
|
116
|
+
5. Lets you complete Google sign-in and 2FA by hand. The CLI does not fill any form.
|
|
117
|
+
6. Waits for a JWT on the event tab URL (`www.b2match.com/e/<slug>?token=…`) or the event session cookie, validates it with a cheap viewer query, stores it, and closes that browser window. Chrome can 404 the post-Google event page before a cookie is set; the URL token is the path that works in that case.
|
|
118
|
+
|
|
119
|
+
Repeat logins are usually one click because the dedicated profile keeps the Google session.
|
|
120
|
+
|
|
121
|
+
macOS may show a Keychain access prompt the first time the CLI stores or reads the token. Approve access for `b2match-mcp`/your terminal if you want Keychain storage. If access is denied or Keychain is unavailable, the CLI falls back to a private config file.
|
|
122
|
+
|
|
123
|
+
### Paste-only / environment token
|
|
124
|
+
|
|
125
|
+
Use these only when the dedicated browser flow cannot run:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
b2match-mcp login --paste
|
|
129
|
+
# or: npx b2match-mcp login --paste
|
|
130
|
+
B2MATCH_TOKEN='<session-cookie-value>' b2match-mcp
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`B2MATCH_TOKEN` is used in memory and is not persisted. Do not pass tokens on the command line; that leaks them into shell history.
|
|
134
|
+
|
|
135
|
+
### Status / logout
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
b2match-mcp status
|
|
139
|
+
b2match-mcp logout
|
|
140
|
+
b2match-mcp logout --all
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`logout` removes the stored B2match token only. `logout --all` also deletes the dedicated browser profile.
|
|
144
|
+
|
|
145
|
+
### Dedicated browser profile
|
|
146
|
+
|
|
147
|
+
Login uses a dedicated Chromium profile at `~/.config/b2match-mcp/browser-profile` (or `$B2MATCH_MCP_CONFIG_DIR/browser-profile` / `$XDG_CONFIG_HOME/b2match-mcp/browser-profile`). That profile keeps the Google session, so a later `login` is usually one click.
|
|
148
|
+
|
|
149
|
+
To wipe the Google session as well as the stored B2match token:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
b2match-mcp logout --all
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
To delete only the profile and keep the stored token:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
rm -rf ~/.config/b2match-mcp/browser-profile
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`logout` without `--all` keeps the profile so the next login can reuse Google.
|
|
162
|
+
|
|
163
|
+
### Storage locations
|
|
164
|
+
|
|
165
|
+
- macOS: login Keychain via `/usr/bin/security` (`b2match-mcp` service, `default` account).
|
|
166
|
+
- Linux: `secret-tool` / libsecret when installed and unlocked.
|
|
167
|
+
- Windows: private fallback file.
|
|
168
|
+
- Fallback file: `$B2MATCH_MCP_CONFIG_DIR/auth.json`, `$XDG_CONFIG_HOME/b2match-mcp/auth.json`, or `~/.config/b2match-mcp/auth.json`, written with `0600` permissions.
|
|
169
|
+
- Dedicated browser profile: `~/.config/b2match-mcp/browser-profile` (same config directory).
|
|
170
|
+
|
|
171
|
+
If a token is a JWT, the CLI records its `exp` claim as `expiresAt`. B2match also has opaque tokens, in which case lifetime cannot be known locally. A 401 on an authenticated viewer check is reported as invalid/expired and remembered for the process. Public tools then continue anonymously without deleting the stored token.
|
|
172
|
+
|
|
173
|
+
### Troubleshooting login
|
|
174
|
+
|
|
175
|
+
- **No supported browser found:** install Chrome, Chromium, Edge, or Brave, or use `b2match-mcp login --paste`.
|
|
176
|
+
- **Google blocks the window:** finish sign-in in the dedicated window if possible; otherwise use `--paste`.
|
|
177
|
+
- **Timeout / 404 after Google:** the CLI also reads a JWT from the event tab URL if Chrome 404s before a cookie is set. Finish sign-in and wait; or use `--paste`.
|
|
178
|
+
- **Keychain prompt denied:** run `b2match-mcp logout`, then `login` again and approve the prompt; or set `B2MATCH_MCP_DISABLE_KEYCHAIN=1` to use the private file fallback.
|
|
179
|
+
- **Wrong/expired token:** run `b2match-mcp logout`, sign in again, and repeat `login`.
|
|
180
|
+
|
|
181
|
+
## Tools
|
|
182
|
+
|
|
183
|
+
Public/anonymous:
|
|
184
|
+
|
|
185
|
+
- `get_event` - event metadata, dates, registration window, login methods, and navigation. Numeric ids and slugs return the same fields.
|
|
186
|
+
- `list_participants` - compact participant summaries with pagination and optional search. Set `detail=true` for the fuller list payload.
|
|
187
|
+
- `get_participant` - one participant profile, without firing profile-view tracking.
|
|
188
|
+
- `list_organisations`, `get_organisation` - organisation/participation pages. Menu paths such as `/components/<id>` are resolved automatically.
|
|
189
|
+
- `list_opportunities`, `get_opportunity` - marketplace entries. Menu paths are resolved automatically; unknown opportunities return an MCP not-found error.
|
|
190
|
+
- `get_agenda`, `get_session`, `list_session_attendees`, `list_speakers`.
|
|
191
|
+
|
|
192
|
+
Authenticated:
|
|
193
|
+
|
|
194
|
+
- `auth_status`
|
|
195
|
+
- `list_my_events`
|
|
196
|
+
- `my_participation`
|
|
197
|
+
- `my_schedule`
|
|
198
|
+
- `my_meetings` - meetings by status; unfiltered responses use a compact availability summary unless `detail=true`
|
|
199
|
+
- `my_availability` - merged free ranges per day by default; `detail=true` for the full timeslot grid
|
|
200
|
+
- `find_meeting_slots`
|
|
201
|
+
- `meeting_recommendations`
|
|
202
|
+
- `list_conversations` - last-message previews; does not mark conversations as read
|
|
203
|
+
- `get_conversation` - disabled by default because fetching messages marks the conversation as read; see side effects
|
|
204
|
+
- `list_notifications` - unread/unseen counters by default; the full list resets unseen
|
|
205
|
+
|
|
206
|
+
Optional raw query:
|
|
207
|
+
|
|
208
|
+
- `graphql_read` is registered only when `B2MATCH_ALLOW_RAW=1`. It still rejects non-query operations and, unless `B2MATCH_ALLOW_SIDE_EFFECT_READS=1`, queries that select `messagesV2` or the notification list.
|
|
209
|
+
|
|
210
|
+
## Server-side side effects
|
|
211
|
+
|
|
212
|
+
This client never sends B2match mutations such as `MarkConversationAsSeen` or `ReadNotification`. B2match can still mutate its own read/seen state when a **query** selects certain fields:
|
|
213
|
+
|
|
214
|
+
- `get_conversation` (`messagesV2`) marks that conversation as read (unread-conversations count decreases).
|
|
215
|
+
- `list_notifications` with the `notifications(...)` list field resets the unseen counter (unseen → 0). The unread counter is unchanged. Fetching `notificationsCounter` alone does not reset unseen.
|
|
216
|
+
- `list_conversations` (including `lastMessageV2`) has no known side effects.
|
|
217
|
+
|
|
218
|
+
Gating:
|
|
219
|
+
|
|
220
|
+
- Default `list_notifications` returns only `unread`/`unseen` counters.
|
|
221
|
+
- `get_conversation` and the full notification list require `B2MATCH_ALLOW_SIDE_EFFECT_READS=1` **and** an explicit tool argument (`acknowledge_marks_read` / `acknowledge_marks_seen`).
|
|
222
|
+
- `graphql_read` rejects queries that select `messagesV2` or the `notifications` list unless the same env var is set.
|
|
223
|
+
|
|
224
|
+
Use `list_conversations` when you only need the last message.
|
|
225
|
+
|
|
226
|
+
Each event-scoped tool accepts a current event URL (`https://www.b2match.com/e/<slug>`), a legacy event URL (`https://<slug>.b2match.io`), a bare slug, or a numeric event id.
|
|
227
|
+
|
|
228
|
+
Authenticated tools return an MCP error when no token is stored, with instructions to log in. They say the token is invalid or expired only after a stored token receives HTTP 401.
|
|
229
|
+
|
|
230
|
+
## Development
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
npm install
|
|
234
|
+
npm run build
|
|
235
|
+
npm test
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for lint, format, coverage, Conventional Commits, and the read-only guard.
|
|
239
|
+
|
|
240
|
+
Live smoke test against a public event (opt-in, not run in CI):
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
B2MATCH_LIVE_SMOKE=1 B2MATCH_SMOKE_EVENT='https://www.b2match.com/e/<event-slug>' npm test -- tests/live-smoke.test.ts
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The smoke test is skipped unless both environment variables are set.
|
|
247
|
+
|
|
248
|
+
## Publishing
|
|
249
|
+
|
|
250
|
+
Releases are cut by Release Please from Conventional Commits on `main`. The same workflow (`.github/workflows/release-please.yml`) publishes with `npm publish --provenance --access public` when a release is created. Provenance requires a public repo. Configure npm trusted publishing against `release-please.yml`. `NPM_TOKEN` is a documented fallback only. `.github/workflows/publish.yml` is a manual republish trigger. After the first 0.1.0 release, remove `"release-as": "0.1.0"` from `release-please-config.json`.
|
|
251
|
+
|
|
252
|
+
## Verification status
|
|
253
|
+
|
|
254
|
+
Verified in this repo:
|
|
255
|
+
|
|
256
|
+
- TypeScript build and unit tests.
|
|
257
|
+
- Query-only enforcement, event identifier parsing, cookie-name helpers, dedicated-browser launch args, section-path resolution, compact participant summaries, and 401/auth-error policy.
|
|
258
|
+
- Packed CLI `--help` / `status`.
|
|
259
|
+
- Opt-in anonymous live smoke that exercises every public tool when `B2MATCH_SMOKE_EVENT` is provided.
|
|
260
|
+
|
|
261
|
+
Requires a real login on a real machine:
|
|
262
|
+
|
|
263
|
+
- Dedicated-profile Google login, Keychain prompt, URL-token or cookie capture.
|
|
264
|
+
- Authenticated tools: `list_my_events`, `my_participation`, schedule, meetings, availability, meeting slots, recommendations, conversations, and notifications.
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT.
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { B2matchClient } from "./client.js";
|
|
2
|
+
export declare class ConversationNotFoundError extends Error {
|
|
3
|
+
constructor(message?: string);
|
|
4
|
+
}
|
|
5
|
+
export declare class RouteNotFoundError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class B2matchApi {
|
|
9
|
+
private readonly client;
|
|
10
|
+
private readonly routeTypeCache;
|
|
11
|
+
constructor(client: B2matchClient);
|
|
12
|
+
authStatus(): Promise<unknown>;
|
|
13
|
+
listMyEvents(): Promise<unknown>;
|
|
14
|
+
getEvent(event: string): Promise<import("./client.js").ResolvedEvent>;
|
|
15
|
+
myParticipation(event: string): Promise<unknown>;
|
|
16
|
+
listParticipants(event: string, options?: {
|
|
17
|
+
first?: number;
|
|
18
|
+
after?: string;
|
|
19
|
+
query?: string;
|
|
20
|
+
detail?: boolean;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
event: {
|
|
23
|
+
id: string;
|
|
24
|
+
euid: string;
|
|
25
|
+
title: string | undefined;
|
|
26
|
+
};
|
|
27
|
+
totalCount: unknown;
|
|
28
|
+
pageInfo: unknown;
|
|
29
|
+
nodes: any[];
|
|
30
|
+
}>;
|
|
31
|
+
getParticipant(event: string, participantId: string): Promise<unknown>;
|
|
32
|
+
listOrganisations(event: string, options?: {
|
|
33
|
+
path?: string;
|
|
34
|
+
first?: number;
|
|
35
|
+
after?: string;
|
|
36
|
+
}): Promise<unknown>;
|
|
37
|
+
getOrganisation(event: string, participationId: string, path?: string): Promise<unknown>;
|
|
38
|
+
listOpportunities(event: string, options?: {
|
|
39
|
+
path?: string;
|
|
40
|
+
first?: number;
|
|
41
|
+
after?: string;
|
|
42
|
+
}): Promise<unknown>;
|
|
43
|
+
getOpportunity(event: string, opportunityId: string, path?: string): Promise<unknown>;
|
|
44
|
+
getAgenda(event: string, path?: string): Promise<unknown>;
|
|
45
|
+
getSession(event: string, sessionId?: string, options?: {
|
|
46
|
+
path?: string;
|
|
47
|
+
first?: number;
|
|
48
|
+
after?: string;
|
|
49
|
+
}): Promise<unknown>;
|
|
50
|
+
listSessionAttendees(event: string, sessionId?: string, options?: {
|
|
51
|
+
path?: string;
|
|
52
|
+
first?: number;
|
|
53
|
+
after?: string;
|
|
54
|
+
}): Promise<unknown>;
|
|
55
|
+
listSpeakers(event: string, options?: {
|
|
56
|
+
path?: string;
|
|
57
|
+
first?: number;
|
|
58
|
+
after?: string;
|
|
59
|
+
}): Promise<unknown>;
|
|
60
|
+
mySchedule(event: string, includePdfLink?: boolean): Promise<Record<string, unknown>>;
|
|
61
|
+
myMeetings(event: string, status?: "accepted" | "receivedInvitations" | "sentInvitations" | "canceled", detail?: boolean): Promise<Record<string, unknown>>;
|
|
62
|
+
myAvailability(event: string, detail?: boolean): Promise<{
|
|
63
|
+
availability: unknown;
|
|
64
|
+
isAvailable: unknown;
|
|
65
|
+
openNetworkingEnabled: unknown;
|
|
66
|
+
}>;
|
|
67
|
+
findMeetingSlots(event: string, participantIds: string[], options?: {
|
|
68
|
+
day?: string;
|
|
69
|
+
duration?: string;
|
|
70
|
+
isLongTermNetworking?: boolean;
|
|
71
|
+
}): Promise<unknown>;
|
|
72
|
+
meetingRecommendations(event: string): Promise<unknown>;
|
|
73
|
+
listConversations(event: string, options?: {
|
|
74
|
+
first?: number;
|
|
75
|
+
after?: string;
|
|
76
|
+
}): Promise<unknown>;
|
|
77
|
+
getConversation(conversationId: string): Promise<{
|
|
78
|
+
conversation?: unknown;
|
|
79
|
+
}>;
|
|
80
|
+
listNotifications(event: string, options?: {
|
|
81
|
+
first?: number;
|
|
82
|
+
after?: string;
|
|
83
|
+
list?: boolean;
|
|
84
|
+
}): Promise<unknown>;
|
|
85
|
+
private routeSection;
|
|
86
|
+
private routeDetail;
|
|
87
|
+
private peekRoutes;
|
|
88
|
+
private route;
|
|
89
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { compactAvailability } from "./availability.js";
|
|
2
|
+
import { B2matchGraphQLError } from "./client.js";
|
|
3
|
+
import { DETAIL_TYPENAMES, SECTION_TYPENAMES, candidateSectionPaths, detailPathCandidates, normalizeRoutePath, opportunityDetailPaths, parseRouteTypenames, resolveSectionPath, routeTypename, sessionAttendees, unavailableSection } from "./directory.js";
|
|
4
|
+
import { compactParticipant } from "./participants.js";
|
|
5
|
+
import { CONVERSATION, CONVERSATIONS, DIRECTORY_ROUTE, FIND_MEETING_SLOTS, LIST_PARTICIPANTS, MY_EVENTS, MY_MEETINGS_AND_AVAILABILITY, MY_PARTICIPATION, MY_SCHEDULE, NOTIFICATION_COUNTERS, NOTIFICATIONS, PARTICIPANT_DETAIL, RECOMMENDATIONS, buildRouteTypenamesQuery, routeTypenameVariables, VIEWER_STATUS } from "./queries.js";
|
|
6
|
+
import { defaultRouteVariables, pageVars } from "./tooling.js";
|
|
7
|
+
export class ConversationNotFoundError extends Error {
|
|
8
|
+
constructor(message = "Conversation not found.") {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ConversationNotFoundError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class RouteNotFoundError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "RouteNotFoundError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class B2matchApi {
|
|
20
|
+
client;
|
|
21
|
+
routeTypeCache = new Map();
|
|
22
|
+
constructor(client) {
|
|
23
|
+
this.client = client;
|
|
24
|
+
}
|
|
25
|
+
async authStatus() {
|
|
26
|
+
return this.client.query(VIEWER_STATUS, {}, { requireAuth: true, cache: false });
|
|
27
|
+
}
|
|
28
|
+
async listMyEvents() {
|
|
29
|
+
return this.client.query(MY_EVENTS, {}, { requireAuth: true, cache: true });
|
|
30
|
+
}
|
|
31
|
+
async getEvent(event) {
|
|
32
|
+
return this.client.resolveEvent(event);
|
|
33
|
+
}
|
|
34
|
+
async myParticipation(event) {
|
|
35
|
+
const resolved = await this.client.resolveEvent(event);
|
|
36
|
+
return this.client.query(MY_PARTICIPATION, { euid: resolved.euid }, { requireAuth: true, cache: true });
|
|
37
|
+
}
|
|
38
|
+
async listParticipants(event, options = {}) {
|
|
39
|
+
const resolved = await this.client.resolveEvent(event);
|
|
40
|
+
const data = await this.client.query(LIST_PARTICIPANTS, {
|
|
41
|
+
eventId: resolved.id,
|
|
42
|
+
euid: resolved.euid,
|
|
43
|
+
query: options.query ?? "",
|
|
44
|
+
...pageVars(options.first, options.after)
|
|
45
|
+
}, { cache: true });
|
|
46
|
+
const connection = (data.event?.participants ?? {});
|
|
47
|
+
const nodes = Array.isArray(connection.nodes) ? connection.nodes : [];
|
|
48
|
+
return {
|
|
49
|
+
event: { id: resolved.id, euid: resolved.euid, title: resolved.title },
|
|
50
|
+
totalCount: connection.totalCount,
|
|
51
|
+
pageInfo: connection.pageInfo,
|
|
52
|
+
nodes: options.detail ? nodes : nodes.map((node) => compactParticipant(node))
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async getParticipant(event, participantId) {
|
|
56
|
+
const resolved = await this.client.resolveEvent(event);
|
|
57
|
+
return this.client.query(PARTICIPANT_DETAIL, { euid: resolved.euid, id: participantId }, { cache: true });
|
|
58
|
+
}
|
|
59
|
+
async listOrganisations(event, options = {}) {
|
|
60
|
+
return this.routeSection(event, "organisations", options);
|
|
61
|
+
}
|
|
62
|
+
async getOrganisation(event, participationId, path) {
|
|
63
|
+
return this.routeDetail(event, "organisations", participationId, path);
|
|
64
|
+
}
|
|
65
|
+
async listOpportunities(event, options = {}) {
|
|
66
|
+
return this.routeSection(event, "opportunities", options);
|
|
67
|
+
}
|
|
68
|
+
async getOpportunity(event, opportunityId, path) {
|
|
69
|
+
const resolved = await this.client.resolveEvent(event);
|
|
70
|
+
const expected = DETAIL_TYPENAMES.opportunities;
|
|
71
|
+
const sectionPaths = path?.trim() ? [] : candidateSectionPaths(resolved.raw, "opportunities");
|
|
72
|
+
const types = sectionPaths.length > 0
|
|
73
|
+
? await this.peekRoutes(resolved.euid, sectionPaths)
|
|
74
|
+
: new Map();
|
|
75
|
+
const section = sectionPaths.find((candidate) => types.get(candidate) === SECTION_TYPENAMES.opportunities);
|
|
76
|
+
const candidates = opportunityDetailPaths(section, opportunityId, path);
|
|
77
|
+
let last;
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
last = await this.route(resolved.euid, candidate);
|
|
80
|
+
if (routeTypename(last) === expected) {
|
|
81
|
+
return last;
|
|
82
|
+
}
|
|
83
|
+
if (path?.trim()) {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw new RouteNotFoundError("Opportunity not found.");
|
|
88
|
+
}
|
|
89
|
+
async getAgenda(event, path) {
|
|
90
|
+
return this.routeSection(event, "agenda", { path, first: 50 });
|
|
91
|
+
}
|
|
92
|
+
async getSession(event, sessionId, options = {}) {
|
|
93
|
+
if (options.path?.trim()) {
|
|
94
|
+
const resolved = await this.client.resolveEvent(event);
|
|
95
|
+
return this.route(resolved.euid, normalizeRoutePath(options.path), options.first, options.after);
|
|
96
|
+
}
|
|
97
|
+
if (!sessionId?.trim()) {
|
|
98
|
+
throw new Error("session_id is required unless path is provided.");
|
|
99
|
+
}
|
|
100
|
+
return this.routeDetail(event, "sessions", sessionId, undefined, options.first, options.after);
|
|
101
|
+
}
|
|
102
|
+
async listSessionAttendees(event, sessionId, options = {}) {
|
|
103
|
+
return sessionAttendees(await this.getSession(event, sessionId, options));
|
|
104
|
+
}
|
|
105
|
+
async listSpeakers(event, options = {}) {
|
|
106
|
+
return this.routeSection(event, "speakers", options);
|
|
107
|
+
}
|
|
108
|
+
async mySchedule(event, includePdfLink = false) {
|
|
109
|
+
const resolved = await this.client.resolveEvent(event);
|
|
110
|
+
const data = await this.client.query(MY_SCHEDULE, { euid: resolved.euid }, { requireAuth: true, cache: false });
|
|
111
|
+
if (!includePdfLink) {
|
|
112
|
+
const participant = (data.viewer?.participantByEuid ?? {});
|
|
113
|
+
delete participant.pdfAgendaUrl;
|
|
114
|
+
}
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
async myMeetings(event, status, detail = false) {
|
|
118
|
+
const resolved = await this.client.resolveEvent(event);
|
|
119
|
+
const data = await this.client.query(MY_MEETINGS_AND_AVAILABILITY, { euid: resolved.euid }, { requireAuth: true, cache: false });
|
|
120
|
+
const viewer = (data.viewer ?? {});
|
|
121
|
+
const participant = (viewer.participantByEuid ?? {});
|
|
122
|
+
if (status) {
|
|
123
|
+
return { [status]: participant.meetings?.[status] };
|
|
124
|
+
}
|
|
125
|
+
if (detail) {
|
|
126
|
+
return data;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
...data,
|
|
130
|
+
viewer: {
|
|
131
|
+
...viewer,
|
|
132
|
+
participantByEuid: {
|
|
133
|
+
...participant,
|
|
134
|
+
availability: compactAvailability(participant.availability)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async myAvailability(event, detail = false) {
|
|
140
|
+
const resolved = await this.client.resolveEvent(event);
|
|
141
|
+
const data = await this.client.query(MY_MEETINGS_AND_AVAILABILITY, { euid: resolved.euid }, { requireAuth: true, cache: false });
|
|
142
|
+
const participant = (data.viewer?.participantByEuid ?? {});
|
|
143
|
+
return {
|
|
144
|
+
availability: detail ? participant.availability : compactAvailability(participant.availability),
|
|
145
|
+
isAvailable: participant.isAvailable,
|
|
146
|
+
openNetworkingEnabled: participant.openNetworkingEnabled
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async findMeetingSlots(event, participantIds, options = {}) {
|
|
150
|
+
const resolved = await this.client.resolveEvent(event);
|
|
151
|
+
return this.client.query(FIND_MEETING_SLOTS, {
|
|
152
|
+
euid: resolved.euid,
|
|
153
|
+
participantIds,
|
|
154
|
+
day: options.day ?? "",
|
|
155
|
+
duration: options.duration ?? null,
|
|
156
|
+
meetingId: null,
|
|
157
|
+
isLtm: options.isLongTermNetworking ?? false
|
|
158
|
+
}, { requireAuth: true, cache: false });
|
|
159
|
+
}
|
|
160
|
+
async meetingRecommendations(event) {
|
|
161
|
+
const resolved = await this.client.resolveEvent(event);
|
|
162
|
+
return this.client.query(RECOMMENDATIONS, { euid: resolved.euid }, { requireAuth: true, cache: false });
|
|
163
|
+
}
|
|
164
|
+
async listConversations(event, options = {}) {
|
|
165
|
+
const resolved = await this.client.resolveEvent(event);
|
|
166
|
+
return this.client.query(CONVERSATIONS, { euid: resolved.euid, ...pageVars(options.first, options.after) }, { requireAuth: true, cache: false });
|
|
167
|
+
}
|
|
168
|
+
async getConversation(conversationId) {
|
|
169
|
+
try {
|
|
170
|
+
const data = await this.client.query(CONVERSATION, { conversationId }, { requireAuth: true, cache: false });
|
|
171
|
+
if (!data?.conversation) {
|
|
172
|
+
throw new ConversationNotFoundError();
|
|
173
|
+
}
|
|
174
|
+
return data;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof ConversationNotFoundError) {
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
if (isConversationNotFound(error)) {
|
|
181
|
+
throw new ConversationNotFoundError();
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async listNotifications(event, options = {}) {
|
|
187
|
+
const resolved = await this.client.resolveEvent(event);
|
|
188
|
+
if (!options.list) {
|
|
189
|
+
return this.client.query(NOTIFICATION_COUNTERS, { euid: resolved.euid }, { requireAuth: true, cache: false });
|
|
190
|
+
}
|
|
191
|
+
return this.client.query(NOTIFICATIONS, { euid: resolved.euid, ...pageVars(options.first, options.after), useClientCache: false }, { requireAuth: true, cache: false });
|
|
192
|
+
}
|
|
193
|
+
async routeSection(event, section, options = {}) {
|
|
194
|
+
const resolved = await this.client.resolveEvent(event);
|
|
195
|
+
const expected = SECTION_TYPENAMES[section];
|
|
196
|
+
if (options.path?.trim()) {
|
|
197
|
+
return this.route(resolved.euid, resolveSectionPath(resolved.raw, section, options.path), options.first, options.after);
|
|
198
|
+
}
|
|
199
|
+
const paths = candidateSectionPaths(resolved.raw, section);
|
|
200
|
+
const types = await this.peekRoutes(resolved.euid, paths);
|
|
201
|
+
const match = paths.find((path) => types.get(path) === expected);
|
|
202
|
+
if (!match) {
|
|
203
|
+
return unavailableSection(section);
|
|
204
|
+
}
|
|
205
|
+
return this.route(resolved.euid, match, options.first, options.after);
|
|
206
|
+
}
|
|
207
|
+
async routeDetail(event, kind, id, explicitPath, first, after) {
|
|
208
|
+
const resolved = await this.client.resolveEvent(event);
|
|
209
|
+
const expected = DETAIL_TYPENAMES[kind];
|
|
210
|
+
let last;
|
|
211
|
+
for (const path of detailPathCandidates(kind, id, explicitPath)) {
|
|
212
|
+
last = await this.route(resolved.euid, path, first, after);
|
|
213
|
+
if (routeTypename(last) === expected) {
|
|
214
|
+
return last;
|
|
215
|
+
}
|
|
216
|
+
if (explicitPath?.trim()) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return last ?? unavailableSection(kind);
|
|
221
|
+
}
|
|
222
|
+
async peekRoutes(euid, paths) {
|
|
223
|
+
const cached = this.routeTypeCache.get(euid) ?? new Map();
|
|
224
|
+
const missing = paths.filter((path) => !cached.has(path));
|
|
225
|
+
if (missing.length > 0) {
|
|
226
|
+
const data = await this.client.query(buildRouteTypenamesQuery(missing.length), routeTypenameVariables(euid, missing), { cache: true });
|
|
227
|
+
for (const [path, typename] of parseRouteTypenames(data, missing)) {
|
|
228
|
+
cached.set(path, typename);
|
|
229
|
+
}
|
|
230
|
+
this.routeTypeCache.set(euid, cached);
|
|
231
|
+
}
|
|
232
|
+
return new Map(paths.map((path) => [path, cached.get(path)]));
|
|
233
|
+
}
|
|
234
|
+
async route(euid, path, first, after) {
|
|
235
|
+
return this.client.query(DIRECTORY_ROUTE, defaultRouteVariables(euid, path, first, after), { cache: true });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function isConversationNotFound(error) {
|
|
239
|
+
const text = collectErrorText(error);
|
|
240
|
+
return (text.includes("not found") ||
|
|
241
|
+
text.includes("couldn't find") ||
|
|
242
|
+
text.includes("could not find") ||
|
|
243
|
+
text.includes("requested element is null") ||
|
|
244
|
+
text.includes("element is null"));
|
|
245
|
+
}
|
|
246
|
+
function collectErrorText(error) {
|
|
247
|
+
if (error instanceof B2matchGraphQLError) {
|
|
248
|
+
const extras = Array.isArray(error.errors)
|
|
249
|
+
? error.errors
|
|
250
|
+
.map((item) => {
|
|
251
|
+
if (item && typeof item === "object" && "message" in item) {
|
|
252
|
+
return String(item.message ?? "");
|
|
253
|
+
}
|
|
254
|
+
return typeof item === "string" ? item : "";
|
|
255
|
+
})
|
|
256
|
+
.join(" ")
|
|
257
|
+
: "";
|
|
258
|
+
return `${error.message} ${extras}`.toLowerCase();
|
|
259
|
+
}
|
|
260
|
+
if (error instanceof Error) {
|
|
261
|
+
return error.message.toLowerCase();
|
|
262
|
+
}
|
|
263
|
+
return String(error).toLowerCase();
|
|
264
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { SecretBackend } from "./keychain.js";
|
|
2
|
+
export interface StoredAuth {
|
|
3
|
+
token: string;
|
|
4
|
+
expiresAt?: string;
|
|
5
|
+
userId?: string;
|
|
6
|
+
loginEventId?: string;
|
|
7
|
+
obtainedVia: "browser" | "paste" | "env" | "password" | "magic-link";
|
|
8
|
+
savedAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AuthState {
|
|
11
|
+
loggedIn: boolean;
|
|
12
|
+
source: "env" | "keychain" | "file" | "none";
|
|
13
|
+
expiresAt?: string;
|
|
14
|
+
userId?: string;
|
|
15
|
+
loginEventId?: string;
|
|
16
|
+
obtainedVia?: StoredAuth["obtainedVia"];
|
|
17
|
+
}
|
|
18
|
+
export declare function configDir(): string;
|
|
19
|
+
export declare function authFilePath(dir?: string): string;
|
|
20
|
+
export declare function decodeJwtExpiry(token: string): string | undefined;
|
|
21
|
+
export declare function isExpired(expiresAt?: string, skewMs?: number): boolean;
|
|
22
|
+
export declare class AuthStore {
|
|
23
|
+
private readonly dir;
|
|
24
|
+
private readonly backend;
|
|
25
|
+
constructor(dir?: string, backend?: SecretBackend | null);
|
|
26
|
+
read(): Promise<{
|
|
27
|
+
record: StoredAuth;
|
|
28
|
+
source: "env" | "keychain" | "file";
|
|
29
|
+
} | null>;
|
|
30
|
+
save(record: Omit<StoredAuth, "savedAt" | "expiresAt"> & {
|
|
31
|
+
expiresAt?: string;
|
|
32
|
+
}): Promise<"keychain" | "file">;
|
|
33
|
+
clear(): Promise<void>;
|
|
34
|
+
status(): Promise<AuthState>;
|
|
35
|
+
}
|